Skip to content

Commit 03e1574

Browse files
committed
fix(router): lazy routeloaders
1 parent 12d2a24 commit 03e1574

11 files changed

Lines changed: 124 additions & 67 deletions

File tree

.changeset/lazy-route-loaders.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@qwik.dev/router': major
3+
---
4+
5+
FEAT: route loaders now do not block SSR start, and they can no longer redirect or error the response. Instead, use request handlers like `onGet`. Consider each route loader as a separate request that does not impact the page.

e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/[id]/index.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type DocumentHead,
44
Form,
55
Link,
6+
type RequestHandler,
67
routeAction$,
78
routeLoader$,
89
z,
@@ -12,20 +13,20 @@ import { delay } from '../../actions/login';
1213

1314
export const useDateLoader = routeLoader$(() => new Date('2021-01-01T00:00:00.000Z'));
1415

15-
// Note: a loader cannot read action state via `resolveValue(useForm)`. After an action
16-
// submission, the client invalidates loaders and refetches them as standalone GET
17-
// requests, which carry no action context — so `resolveValue(actionQrl)` returns
18-
// undefined. Read action state directly from the action signal (e.g. in the head, or
19-
// from `useForm.value` in components), not via a loader.
20-
export const useDependencyLoader = routeLoader$(async ({ params, redirect, json }) => {
21-
await delay(100);
16+
// Route control flow (redirect/json) lives in middleware, which runs eagerly before render.
17+
// Loaders are data-only and run lazily, so they must not drive redirects.
18+
export const onRequest: RequestHandler = ({ params, redirect, json }) => {
2219
if (params.id === 'redirect') {
2320
throw redirect(302, '/qwikrouter-test/');
2421
} else if (params.id === 'redirect-welcome') {
2522
throw redirect(302, '/qwikrouter-test/loaders/welcome/');
2623
} else if (params.id === 'json') {
2724
throw json(200, { nu: 42 });
2825
}
26+
};
27+
28+
export const useDependencyLoader = routeLoader$(async ({ params }) => {
29+
await delay(100);
2930
return {
3031
nu: 42,
3132
name: params.id,

e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/loader-error/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const useError = routeLoader$(async function ({ error }): Promise<string> {
66
});
77

88
export default component$(() => {
9+
// Lazy data-only loader: it fails, but nothing reads its value, so SSR is unaffected.
910
useError();
10-
return <></>;
11+
return <div id="loader-error-rendered">rendered</div>;
1112
});

e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/loader-error/uncaught-server/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const useCatchServerErrorInLoader = routeLoader$(async () => {
1111
});
1212

1313
export default component$(() => {
14+
// Lazy data-only loader: it fails, but nothing reads its value, so SSR is unaffected.
1415
useCatchServerErrorInLoader();
15-
return <></>;
16+
return <div id="uncaught-server-rendered">rendered</div>;
1617
});

e2e/qwik-e2e/apps/qwikrouter-test/src/routes/returned-control-flow/loader-error/index.tsx

Lines changed: 0 additions & 10 deletions
This file was deleted.

e2e/qwik-e2e/apps/qwikrouter-test/src/routes/returned-control-flow/loader-redirect/index.tsx

Lines changed: 0 additions & 12 deletions
This file was deleted.

e2e/qwik-e2e/tests/qwikrouter/loaders.e2e.ts

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -177,26 +177,20 @@ test.describe('loaders', () => {
177177
await expect(page.locator('#prop-unwrapped')).toHaveText('test');
178178
});
179179

180-
test('should modify ServerError in middleware', async ({ page }) => {
180+
test('a failing loader does not affect SSR when its value is not read', async ({ page }) => {
181181
const response = await page.goto('/qwikrouter-test/loaders/loader-error');
182-
const contentType = await response?.headerValue('Content-Type');
183-
const status = response?.status();
184182

185-
expect(status).toEqual(401);
186-
expect(contentType).toEqual('text/html; charset=utf-8');
187-
const body = page.locator('body');
188-
await expect(body).toContainText('loader-error-caught');
183+
expect(response?.status()).toEqual(200);
184+
await expect(page.locator('#loader-error-rendered')).toBeVisible();
189185
});
190186

191-
test('should return html with uncaught ServerErrors thrown in loaders', async ({ page }) => {
187+
test('a loader awaiting a failing server$ does not affect SSR when unread', async ({
188+
page,
189+
}) => {
192190
const response = await page.goto('/qwikrouter-test/loaders/loader-error/uncaught-server');
193-
const contentType = await response?.headerValue('Content-Type');
194-
const status = response?.status();
195191

196-
expect(status).toEqual(401);
197-
expect(contentType).toEqual('text/html; charset=utf-8');
198-
const body = page.locator('body');
199-
await expect(body).toContainText('server-error-data');
192+
expect(response?.status()).toEqual(200);
193+
await expect(page.locator('#uncaught-server-rendered')).toBeVisible();
200194
});
201195

202196
test('should not serialize loaders by default and serialize with serializationStrategy: always', async ({

e2e/qwik-e2e/tests/qwikrouter/returned-control-flow.e2e.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,14 @@ import { expect, test } from '@playwright/test';
33
const base = '/qwikrouter-test/returned-control-flow';
44

55
// Returning ev.redirect()/ev.error() must behave the same as throwing them.
6+
// Route loaders are data-only and lazy, so route control flow belongs in request
7+
// handlers/actions (eager), not loaders — hence no loader cases here.
68
test.describe('returned control-flow signals', () => {
7-
test('loader returning redirect redirects', async ({ page }) => {
8-
await page.goto(`${base}/loader-redirect/`);
9-
await expect(page.locator('#returned-control-flow-target')).toBeVisible();
10-
});
11-
129
test('request handler returning redirect redirects', async ({ page }) => {
1310
await page.goto(`${base}/handler-redirect/`);
1411
await expect(page.locator('#returned-control-flow-target')).toBeVisible();
1512
});
1613

17-
test('loader returning error renders the error response', async ({ page }) => {
18-
const response = await page.goto(`${base}/loader-error/`);
19-
expect(response?.status()).toEqual(401);
20-
await expect(page.locator('body')).toContainText('returned-loader-error');
21-
});
22-
2314
test('action returning error responds with the error status', async ({ page }) => {
2415
await page.goto(`${base}/action-error/`);
2516
// Submit via the real form so the request carries a valid Origin (passes CSRF).

packages/qwik-router/src/middleware/request-handler/resolve-request-handlers-core.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -310,23 +310,22 @@ function createResolveRequestHandlers() {
310310
}
311311

312312
function loadersMiddleware(routeLoaders: LoaderInternal[], route: LoadedRoute): RequestHandler {
313-
return async (requestEvent: RequestEvent) => {
313+
return (requestEvent: RequestEvent) => {
314314
const requestEv = requestEvent as RequestEventInternal;
315315
if (requestEv.headersSent) {
316316
requestEv.exit();
317317
return;
318318
}
319-
if (routeLoaders.length > 0) {
320-
setLoaderData(requestEv, routeLoaders, route);
319+
if (routeLoaders.length === 0) {
320+
return;
321+
}
322+
setLoaderData(requestEv, routeLoaders, route);
321323

322-
// Run loaders directly and store raw values.
323-
// Errors/redirects propagate so middleware can catch them (e.g. plugin@errors).
324-
const loaderValues = getRouteLoaderValues(requestEv);
325-
await Promise.all(
326-
routeLoaders.map(async (loader) => {
327-
loaderValues[loader.__id] = await loadRouteLoader(loader, requestEv);
328-
})
329-
);
324+
// Start every loader concurrently but don't await them: render only blocks on — and only
325+
// fails from — loaders whose .value it reads. Swallowing rejections keeps an unread failing
326+
// loader from crashing the request; the error resurfaces if .value is read during render.
327+
for (const loader of routeLoaders) {
328+
loadRouteLoader(loader, requestEv).catch(() => {});
330329
}
331330
};
332331
}

packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.unit.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,92 @@ describe('resolve-request-handler', () => {
369369
});
370370
});
371371

372+
describe('lazy loaders middleware', () => {
373+
function makeLoader(
374+
id: string,
375+
impl: (...args: any[]) => unknown,
376+
strategy: 'never' | 'always' = 'never'
377+
) {
378+
const loader: any = () => {};
379+
loader.__brand = 'server_loader';
380+
loader.__id = id;
381+
loader.__qrl = { call: (_thisArg: unknown, ev: unknown) => impl(ev), getHash: () => id };
382+
loader.__validators = undefined;
383+
loader.__serializationStrategy = strategy;
384+
loader.__search = undefined;
385+
return loader;
386+
}
387+
388+
function pageRouteWithLoader(loader: unknown): LoadedRoute {
389+
return {
390+
$routeName$: '/',
391+
$params$: {},
392+
$mods$: [{ useData: loader }, { default: () => null }] as any,
393+
$errorLoader$: vi.fn(async () => ({ default: () => null })),
394+
};
395+
}
396+
397+
function runPage(route: LoadedRoute, renderHandler: any) {
398+
globalThis.__NO_TRAILING_SLASH__ = false;
399+
const handlers = resolveRequestHandlers(undefined, route, 'GET', true, renderHandler);
400+
const requestEv = createRequestEvent(
401+
createMockServerRequestEvent('http://localhost:3000/'),
402+
route,
403+
handlers,
404+
'/',
405+
vi.fn()
406+
);
407+
return requestEv;
408+
}
409+
410+
it('starts loaders during the request even when render never reads them', async () => {
411+
const impl = vi.fn(() => 'value');
412+
const route = pageRouteWithLoader(makeLoader('l1', impl));
413+
const renderHandler = vi.fn((requestEv: { exit: () => void }) => {
414+
requestEv.exit();
415+
});
416+
const requestEv = runPage(route, renderHandler);
417+
418+
await requestEv.next();
419+
420+
expect(impl).toHaveBeenCalledTimes(1);
421+
expect(renderHandler).toHaveBeenCalledOnce();
422+
});
423+
424+
it('does not await loaders before render', async () => {
425+
let release!: () => void;
426+
const gate = new Promise<string>((resolve) => (release = () => resolve('late')));
427+
const route = pageRouteWithLoader(makeLoader('l1', () => gate));
428+
const renderHandler = vi.fn((requestEv: { exit: () => void }) => {
429+
requestEv.exit();
430+
});
431+
const requestEv = runPage(route, renderHandler);
432+
433+
// Resolves without awaiting the still-pending loader; eager middleware would hang here.
434+
await requestEv.next();
435+
436+
expect(renderHandler).toHaveBeenCalledOnce();
437+
release();
438+
});
439+
440+
it('does not let an unread failing loader affect the response', async () => {
441+
const route = pageRouteWithLoader(
442+
makeLoader('l1', () => {
443+
throw new ServerError(401, 'boom');
444+
})
445+
);
446+
const renderHandler = vi.fn((requestEv: { exit: () => void }) => {
447+
requestEv.exit();
448+
});
449+
const requestEv = runPage(route, renderHandler);
450+
451+
await expect(requestEv.next()).resolves.toBeUndefined();
452+
453+
expect(renderHandler).toHaveBeenCalledOnce();
454+
expect(requestEv.status()).toBe(200);
455+
});
456+
});
457+
372458
describe('action result resolution', () => {
373459
it('always returns undefined for actions, even when one was submitted', async () => {
374460
// Loaders must be a pure function of the URL — see route-loader docs and the

0 commit comments

Comments
 (0)