Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lazy-route-loaders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@qwik.dev/router': minor
---

feat: route loaders gain a `blockSSR` option (default `true`); set `blockSSR: false` to run a loader in the background without blocking SSR
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { component$ } from '@qwik.dev/core';
import { routeLoader$ } from '@qwik.dev/router';

// blockSSR:false runs in the background and must not block or error the initial SSR response.
export const useBackgroundError = routeLoader$(
async ({ error }): Promise<string> => {
throw error(401, 'background-loader-error');
},
{ blockSSR: false }
);

// A background loader returning fail() must surface the failed value on its signal without leaking
// its status (or crashing via check()) onto the page response.
export const useBackgroundFail = routeLoader$(
async ({ fail }) => fail(418, { reason: 'background-fail' }),
{ blockSSR: false }
);

export default component$(() => {
useBackgroundError();
const failed = useBackgroundFail();
return (
<div id="non-blocking-rendered">
rendered
<span id="non-blocking-fail">{failed.value.reason}</span>
</div>
);
});
9 changes: 9 additions & 0 deletions e2e/qwik-e2e/tests/qwikrouter/loaders.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,15 @@ test.describe('loaders', () => {
await expect(body).toContainText('server-error-data');
});

test('a blockSSR:false loader is isolated from the page response', async ({ page }) => {
const response = await page.goto('/qwikrouter-test/loaders/non-blocking/');

// Neither the unread error() nor the read fail() leaks its status onto the page response.
expect(response?.status()).toEqual(200);
await expect(page.locator('#non-blocking-rendered')).toBeVisible();
await expect(page.locator('#non-blocking-fail')).toHaveText('background-fail');
});

test('should not serialize loaders by default and serialize with serializationStrategy: always', async ({
page,
javaScriptEnabled,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ A few properties of route loaders:
- **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.
- **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.
- **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.
- **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).

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).

Expand Down Expand Up @@ -97,7 +98,7 @@ Loaders signal failure two ways:
- **`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.
- **`return requestEvent.fail(status, data)`** — returns an inline failure. `signal.value.failed` is `true` and `signal.value` contains the rest of the data.

In both cases, the HTTP response status is set to `status`, and the result is not cacheable.
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).

## Multiple `routeLoader$`s

Expand Down Expand Up @@ -256,6 +257,23 @@ export default () => ({

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).

## Background loaders (`blockSSR`)

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 to error, in route order, decides the error, however the last one to set the status, in chronological order, decides the status.

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.

```tsx title="src/routes/product/[productId]/index.tsx"
export const useRecommendations = routeLoader$(
async ({ params }) => fetchRecommendations(params.productId),
{ blockSSR: false }
);
```

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.

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.

## Accessing `RequestEvent`

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

function loadersMiddleware(routeLoaders: LoaderInternal[], route: LoadedRoute): RequestHandler {
return async (requestEvent: RequestEvent) => {
return (requestEvent: RequestEvent) => {
const requestEv = requestEvent as RequestEventInternal;
if (requestEv.headersSent) {
requestEv.exit();
return;
}
if (routeLoaders.length > 0) {
setLoaderData(requestEv, routeLoaders, route);

// Run loaders directly and store raw values.
// Errors/redirects propagate so middleware can catch them (e.g. plugin@errors).
const loaderValues = getRouteLoaderValues(requestEv);
await Promise.all(
routeLoaders.map(async (loader) => {
loaderValues[loader.__id] = await loadRouteLoader(loader, requestEv);
})
);
if (routeLoaders.length === 0) {
return;
}
setLoaderData(requestEv, routeLoaders, route);

// Start every loader concurrently. `blockSSR` loaders (the default) are awaited before SSR so
// a redirect/error short-circuits the response; the first one in route order wins. Loaders
// with `blockSSR: false` resolve in the background and only surface when their `.value` is
// read.
let allBlockSSRLoaders: Promise<void> | undefined;
for (let i = 0; i < routeLoaders.length; i++) {
const loader = routeLoaders[i];
const promise = loadRouteLoader(loader, requestEv);
// Handle every rejection so a background loader can't crash the request.
promise.catch(() => {});
if (loader.__blockSSR) {
// Chain the promises so a thrown error is handled in route order
// Note: status changes are last-writer wins, but that's fine
allBlockSSRLoaders = (
allBlockSSRLoaders ? allBlockSSRLoaders.then(() => promise) : promise
) as Promise<void>;
Comment on lines +337 to +339

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why no Promise.all?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this way, the first error in route order will throw even if there are multiple errors.
Also, the loads all start at the same time so the .then() chaining doesn't cause a waterfall

}
}
return allBlockSSRLoaders;
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,132 @@ describe('resolve-request-handler', () => {
});
});

describe('blockSSR loaders middleware', () => {
function makeLoader(
id: string,
impl: (...args: any[]) => unknown,
{ blockSSR = true }: { blockSSR?: boolean } = {}
) {
const loader: any = () => {};
loader.__brand = 'server_loader';
loader.__id = id;
loader.__qrl = { call: (_thisArg: unknown, ev: unknown) => impl(ev), getHash: () => id };
loader.__validators = undefined;
loader.__serializationStrategy = 'never';
loader.__search = undefined;
loader.__blockSSR = blockSSR;
return loader;
}

function pageRouteWithLoaders(...loaders: unknown[]): LoadedRoute {
const loaderModule: Record<string, unknown> = {};
loaders.forEach((loader, i) => (loaderModule[`useData${i}`] = loader));
return {
$routeName$: '/',
$params$: {},
$mods$: [loaderModule, { default: () => null }] as any,
$errorLoader$: [vi.fn(async () => ({ default: () => null }))],
};
}

function runPage(route: LoadedRoute, renderHandler: any) {
globalThis.__NO_TRAILING_SLASH__ = false;
const handlers = resolveRequestHandlers(undefined, route, 'GET', true, renderHandler);
const requestEv = createRequestEvent(
createMockServerRequestEvent('http://localhost:3000/'),
route,
handlers,
'/',
vi.fn()
);
return requestEv;
}

const exitRender = () =>
vi.fn((requestEv: { exit: () => void }) => {
requestEv.exit();
});

it('starts every loader during the request', async () => {
const blocking = vi.fn(() => 'a');
const background = vi.fn(() => 'b');
const route = pageRouteWithLoaders(
makeLoader('a', blocking),
makeLoader('b', background, { blockSSR: false })
);
const requestEv = runPage(route, exitRender());

await requestEv.next();

expect(blocking).toHaveBeenCalledTimes(1);
expect(background).toHaveBeenCalledTimes(1);
});

it('does not await blockSSR:false loaders before render', async () => {
let release!: () => void;
const gate = new Promise<string>((resolve) => (release = () => resolve('late')));
const route = pageRouteWithLoaders(makeLoader('l1', () => gate, { blockSSR: false }));
const renderHandler = exitRender();
const requestEv = runPage(route, renderHandler);

// Resolves without awaiting the still-pending background loader.
await requestEv.next();

expect(renderHandler).toHaveBeenCalledOnce();
release();
});

it('errors the response when a blockSSR loader errors, before render', async () => {
const route = pageRouteWithLoaders(
makeLoader('l1', () => {
throw new ServerError(401, 'boom');
})
);
const requestEv = runPage(route, exitRender());

await requestEv.next();

expect(requestEv.status()).toBe(401);
expect(requestEv.sharedMap.get(RequestEvHttpStatusMessage)).toBe('boom');
});

it('reports the first blockSSR loader (in route order) that errors', async () => {
const route = pageRouteWithLoaders(
makeLoader('first', () => {
throw new ServerError(401, 'first-error');
}),
makeLoader('second', () => {
throw new ServerError(500, 'second-error');
})
);
const requestEv = runPage(route, exitRender());

await requestEv.next();

expect(requestEv.status()).toBe(401);
expect(requestEv.sharedMap.get(RequestEvHttpStatusMessage)).toBe('first-error');
});

it('a failing blockSSR:false loader does not affect the response when unread', async () => {
const route = pageRouteWithLoaders(
makeLoader(
'l1',
() => {
throw new ServerError(401, 'boom');
},
{ blockSSR: false }
)
);
const renderHandler = exitRender();
const requestEv = runPage(route, renderHandler);

await expect(requestEv.next()).resolves.toBeUndefined();

expect(renderHandler).toHaveBeenCalledOnce();
expect(requestEv.status()).toBe(200);
});
});

describe('action result resolution', () => {
it('always returns undefined for actions, even when one was submitted', async () => {
// Loaders must be a pure function of the URL — see route-loader docs and the
Expand Down
Loading
Loading