Skip to content

Commit 285d1c3

Browse files
committed
fix(router): blockSSR routeLoader
1 parent 03e1574 commit 285d1c3

14 files changed

Lines changed: 246 additions & 58 deletions

File tree

.changeset/lazy-route-loaders.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
'@qwik.dev/router': major
2+
'@qwik.dev/router': minor
33
---
44

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.
5+
route loaders gain a `blockSSR` option (default `true`); set `blockSSR: false` to run a loader in the background without blocking SSR

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

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

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

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 }) => {
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);
1922
if (params.id === 'redirect') {
2023
throw redirect(302, '/qwikrouter-test/');
2124
} else if (params.id === 'redirect-welcome') {
2225
throw redirect(302, '/qwikrouter-test/loaders/welcome/');
2326
} else if (params.id === 'json') {
2427
throw json(200, { nu: 42 });
2528
}
26-
};
27-
28-
export const useDependencyLoader = routeLoader$(async ({ params }) => {
29-
await delay(100);
3029
return {
3130
nu: 42,
3231
name: params.id,

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ 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.
109
useError();
11-
return <div id="loader-error-rendered">rendered</div>;
10+
return <></>;
1211
});

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ 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.
1514
useCatchServerErrorInLoader();
16-
return <div id="uncaught-server-rendered">rendered</div>;
15+
return <></>;
1716
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { component$ } from '@qwik.dev/core';
2+
import { routeLoader$ } from '@qwik.dev/router';
3+
4+
// blockSSR:false runs in the background and must not block or error the initial SSR response.
5+
export const useBackgroundError = routeLoader$(
6+
async ({ error }): Promise<string> => {
7+
throw error(401, 'background-loader-error');
8+
},
9+
{ blockSSR: false }
10+
);
11+
12+
export default component$(() => {
13+
useBackgroundError();
14+
return <div id="non-blocking-rendered">rendered</div>;
15+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { component$ } from '@qwik.dev/core';
2+
import { routeLoader$ } from '@qwik.dev/router';
3+
4+
// Returns the error signal instead of throwing it.
5+
export const useErrorLoader = routeLoader$(({ error }) => error(401, 'returned-loader-error'));
6+
7+
export default component$(() => {
8+
useErrorLoader();
9+
return <h1 id="returned-control-flow-loader-error">Should not render</h1>;
10+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { component$ } from '@qwik.dev/core';
2+
import { routeLoader$ } from '@qwik.dev/router';
3+
4+
// Returns the redirect signal instead of throwing it.
5+
export const useRedirectLoader = routeLoader$(({ redirect }) =>
6+
redirect(302, '/qwikrouter-test/returned-control-flow/target/')
7+
);
8+
9+
export default component$(() => {
10+
useRedirectLoader();
11+
return <h1 id="returned-control-flow-loader-redirect">Should not render</h1>;
12+
});

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

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

180-
test('a failing loader does not affect SSR when its value is not read', async ({ page }) => {
180+
test('should modify ServerError in middleware', 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();
182184

183-
expect(response?.status()).toEqual(200);
184-
await expect(page.locator('#loader-error-rendered')).toBeVisible();
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');
185189
});
186190

187-
test('a loader awaiting a failing server$ does not affect SSR when unread', async ({
188-
page,
189-
}) => {
191+
test('should return html with uncaught ServerErrors thrown in loaders', async ({ page }) => {
190192
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();
195+
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');
200+
});
201+
202+
test('a blockSSR:false loader does not block or error SSR when unread', async ({ page }) => {
203+
const response = await page.goto('/qwikrouter-test/loaders/non-blocking/');
191204

192205
expect(response?.status()).toEqual(200);
193-
await expect(page.locator('#uncaught-server-rendered')).toBeVisible();
206+
await expect(page.locator('#non-blocking-rendered')).toBeVisible();
194207
});
195208

196209
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: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,23 @@ 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.
86
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+
912
test('request handler returning redirect redirects', async ({ page }) => {
1013
await page.goto(`${base}/handler-redirect/`);
1114
await expect(page.locator('#returned-control-flow-target')).toBeVisible();
1215
});
1316

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+
1423
test('action returning error responds with the error status', async ({ page }) => {
1524
await page.goto(`${base}/action-error/`);
1625
// Submit via the real form so the request carries a valid Origin (passes CSRF).

packages/docs/src/routes/docs/(qwikrouter)/route-loader/index.mdx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ A few properties of route loaders:
5555
- **Keyed by route, not by call site.** Two visits to the same URL share one logical "result" per "route + url params + search params" set, not one per `useProductDetails()` call.
5656
- **Result is an `AsyncSignal`.** Components read `.value`, watch `.loading`, and check `.error`. Reading `.value` while the signal is in error state re-throws the error.
5757
- **Each loader has its own JSON endpoint.** The client fetches `q-loader-{id}.{hash}.json` for navigation, polling, and post-action invalidation. Browser HTTP cache, ETags, and the `expires` option all apply.
58+
- **Blocks SSR by default.** The server awaits the loader before rendering the page, so a `throw redirect()` or `throw error()` controls the response. Opt out with `blockSSR: false` to run it in the background — see [Background loaders](#background-loaders-blockssr).
5859

5960
This means a `routeLoader$()` should be a **pure function of the URL**: same `params` and `search` → same response. Mixing in any other input (action state, request bodies, one-shot tokens) makes the cached responses inconsistent — see [Loaders cannot read action state](#loaders-cannot-read-action-state).
6061

@@ -97,7 +98,7 @@ Loaders signal failure two ways:
9798
- **`throw requestEvent.error(status, data)`** — throw a `ServerError` directly. `signal.error` is `ServerError {status, data}`. Reading `signal.value` re-throws the error, so you should branch on `signal.error` first. This is the recommended way to signal failure from a loader.
9899
- **`return requestEvent.fail(status, data)`** — returns an inline failure. `signal.value.failed` is `true` and `signal.value` contains the rest of the data.
99100

100-
In both cases, the HTTP response status is set to `status`, and the result is not cacheable.
101+
In both cases the result is not cacheable. For a blocking loader (the default) the HTTP response status is also set to `status`; a `blockSSR: false` loader is isolated from the page response — see [Background loaders](#background-loaders-blockssr).
101102

102103
## Multiple `routeLoader$`s
103104

@@ -256,6 +257,23 @@ export default () => ({
256257

257258
When using lazy-loaded data, branch on `.loading` to show a placeholder until it arrives — see [Reading the result in a component](#reading-the-result-in-a-component).
258259

260+
## Background loaders (`blockSSR`)
261+
262+
By default a loader **blocks SSR**: the server awaits it before rendering, so a `throw redirect()` or `throw error()` short-circuits the HTTP response. When several loaders block, the first one — in route order — to redirect or error wins.
263+
264+
Set `blockSSR: false` to run a loader in the background instead. The server starts it but does not wait: rendering begins immediately, and reading `.value` suspends only that read until the loader resolves. This starts SSR sooner, and is the recommended setting, unless you need to control the request from the loader.
265+
266+
```tsx title="src/routes/product/[productId]/index.tsx"
267+
export const useRecommendations = routeLoader$(
268+
async ({ params }) => fetchRecommendations(params.productId),
269+
{ blockSSR: false }
270+
);
271+
```
272+
273+
A background loader is treated as a **separate request**: it cannot redirect or error the page response. If it calls `redirect()` or `error()`, that control flow is captured on its own signal (surfacing when `.value` is read) and never changes the page's status or headers. Keep route guards that must redirect or set a status in a [request handler](/docs/middleware) such as `onRequest`, or in a blocking loader.
274+
275+
Note: when the client fetches a background loader for SPA navigation, there is no difference between a blocking and non-blocking loader — the SPA fetch is always async, and the page is already rendered. Errors are not surfaced directly, but redirects are followed automatically.
276+
259277
## Accessing `RequestEvent`
260278

261279
The loader function receives the same [`RequestEvent`](/docs/middleware#requestevent) as middleware. Use it to read params, headers, cookies, the URL, and so on.
@@ -341,6 +359,7 @@ export default component$(() => {
341359
| `poll` | `false` | Auto-refetch when expired, but only while components are reading the loader. |
342360
| `allowStale` | `true` | Show stale data while refetching. `false` blocks readers until fresh data arrives. |
343361
| `serializationStrategy` | `'never'` | `'never'` / `'always'` / `'auto'` — embed loader data in the SSR HTML? |
362+
| `blockSSR` | `true` | Await the loader before SSR (can redirect/error the response). `false` runs it in the background. |
344363
| `eTag` || `true` (auto-hash), string, or `(ev) => string \| null`. Returns 304 on match. |
345364
| `search` | `[]` * | Allowlist of search params the loader depends on. *(default `[]` when `strictLoaders` is enabled)* |
346365
| `validation` || Array of [`DataValidator`](/docs/(qwikrouter)/validator/index.mdx)s applied before the loader runs. |

0 commit comments

Comments
 (0)