Skip to content
Merged
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/loader-fail-value.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@qwik.dev/router': patch
---

fix: routeLoader$ fail() now sets the loader value to { failed } instead of throwing an error, as it was before.
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,12 @@ export default component$(() => {

### Signaling failure from a loader

Loaders signal failure two ways. Both put the signal into error state with `signal.error` set to a `ServerError` carrying `.status` (HTTP status) and `.data` (the payload):
Loaders signal failure two ways:

- **`return requestEvent.fail(status, data)`** — return a tagged failure. `signal.error.data` is `{ failed: true, ...data }`.
- **`throw requestEvent.error(status, data)`** — throw a `ServerError` directly. `signal.error.data` is `data`.
- **`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.
Comment on lines +97 to +98

In both cases, the HTTP response status is set to `status`, and the result is not cacheable.

## Multiple `routeLoader$`s

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,10 @@ export function loaderHandler(
);
const data = await _serialize(responseData);

// Only successful data envelopes are cacheable; never cache redirects or errors.
const cacheable = cacheKey && !responseData.r && !responseData.e;
// Only successful data envelopes are cacheable; never cache redirects, errors, or fail() results.
const failed =
responseData.d && typeof responseData.d === 'object' && (responseData.d as any).failed;
const cacheable = cacheKey && !responseData.r && !responseData.e && !failed;
Comment on lines +95 to +98

// When caching is enabled but there's no eTag, auto-hash.
const finalETag = normalizedETag || (cacheable ? hash(data) : '');
Expand Down
8 changes: 3 additions & 5 deletions packages/qwik-router/src/runtime/src/route-loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ export const FULLPATH_HEADER = 'X-Qwik-fullpath';
/**
* Response envelope for loader.json requests. Exactly one of `d`, `r`, or `e` is set.
*
* - `d` — data: the loader's successful return value
* - `d` — data: the loader's return value (including a `fail()` result, which is plain data)
* - `r` — redirect: URL to navigate to (from `throw redirect()`)
* - `e` — error: a ServerError (from `fail()` or `throw serverError()`)
* - `e` — error: a ServerError (from a thrown `ServerError` / `error()`)
*/
export type LoaderResponse = {
d?: unknown;
Expand Down Expand Up @@ -766,10 +766,8 @@ export const getRouteLoaderResponse = async (
requestEv: RequestEvent
): Promise<LoaderResponse> => {
try {
// A fail() result is plain data ({ failed: true, ... }); only thrown errors use `e`.
const value = await getRouteLoaderData(loaderQrl, validators, requestEv);
if (value && typeof value === 'object' && (value as any).failed) {
return { e: new ServerError(requestEv.status(), value) };
}
return { d: value };
} catch (err) {
if (err instanceof RedirectMessage) {
Expand Down
27 changes: 27 additions & 0 deletions packages/qwik-router/src/runtime/src/route-loaders.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { describe, expect, it, vi } from 'vitest';
import { _UNINITIALIZED, type SerializationStrategy } from '@qwik.dev/core/internal';
import {
ensureRouteLoaderSignal,
getRouteLoaderResponse,
loadRouteLoader,
routeLoaderQrl,
type RouteLoaderState,
} from './route-loaders';
import { ServerError } from '../../middleware/request-handler/server-error';
import type { LoaderInternal } from './types';

describe('search filter early-return logic', () => {
Expand Down Expand Up @@ -91,6 +93,31 @@ describe('route loader execution', () => {
});
});

describe('getRouteLoaderResponse envelope', () => {
const requestEv = {} as any;

it('keeps a fail() result as the loader value, not an error', async () => {
const qrl = createQrl('fail-loader', async () => ({ failed: true, msg: 'nope' }));

const response = await getRouteLoaderResponse(qrl, undefined, requestEv);

expect(response).toEqual({ d: { failed: true, msg: 'nope' } });
expect(response.e).toBeUndefined();
});

it('routes a thrown ServerError to the error channel', async () => {
const qrl = createQrl('error-loader', async () => {
throw new ServerError(500, 'boom');
});

const response = await getRouteLoaderResponse(qrl, undefined, requestEv);

expect(response.d).toBeUndefined();
expect(response.e).toBeInstanceOf(ServerError);
expect(response.e?.status).toBe(500);
});
});

function createLoader(
id: string,
fn: (thisArg: unknown, ev: any) => unknown,
Expand Down
Loading