diff --git a/.changeset/loader-action-error-state.md b/.changeset/loader-action-error-state.md new file mode 100644 index 00000000000..00c3117b71e --- /dev/null +++ b/.changeset/loader-action-error-state.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/router': major +--- + +BREAKING: loader/action failures moved from `.value` to `.error`. Return `fail(status, data)` (or fail a validator) and read the typed `ServerError` from `loader.error` / `action.error` — `.value` is the success type only and `value.failed` is gone. `throw error()` keeps aborting to the error page. diff --git a/.changeset/submit-rejects-on-abort.md b/.changeset/submit-rejects-on-abort.md new file mode 100644 index 00000000000..16dc4513ed3 --- /dev/null +++ b/.changeset/submit-rejects-on-abort.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/router': patch +--- + +A thrown `error()` (or unexpected server error) during an SPA submission was silently swallowed: the action resolved as an empty success and `isNavigating` could stick. `submit()`/`run()` now rejects with the error, navigation state resets, and a non-JSON action response (e.g. a proxy error page) settles instead of hanging forever. diff --git a/.changeset/submitcompleted-aborted-detail.md b/.changeset/submitcompleted-aborted-detail.md new file mode 100644 index 00000000000..c3c3c134f96 --- /dev/null +++ b/.changeset/submitcompleted-aborted-detail.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/router': minor +--- + +feat: `submitcompleted` now also fires when a `
` submission aborts, with the error on `detail.aborted`. diff --git a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/(auth)/sign-in/index.tsx b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/(auth)/sign-in/index.tsx index 44b8525729f..096092c70b8 100644 --- a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/(auth)/sign-in/index.tsx +++ b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/(auth)/sign-in/index.tsx @@ -18,7 +18,7 @@ export const onGet: RequestHandler = async ({ redirect, cookie }) => { }; export const useSigninAction = globalAction$( - async (data, { cookie, redirect, status, fail }) => { + async (data, { cookie, redirect, fail }) => { const result = await signIn(data, cookie); if (result.status === 'signed-in') { @@ -26,7 +26,7 @@ export const useSigninAction = globalAction$( } return fail(403, { - message: ['Invalid username or password'], + message: 'Invalid username or password', }); }, zod$( @@ -65,26 +65,26 @@ export default component$(() => {

Sign In

- {signIn.value?.message &&

{signIn.value.message}

} + {signIn.error?.message &&

{signIn.error.message}

} diff --git a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/actions/abort/index.tsx b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/actions/abort/index.tsx new file mode 100644 index 00000000000..348fdc32525 --- /dev/null +++ b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/actions/abort/index.tsx @@ -0,0 +1,48 @@ +import { component$, useSignal } from '@qwik.dev/core'; +import { Form, routeAction$, useLocation } from '@qwik.dev/router'; + +export const useAbortAction = routeAction$((form, ev) => { + if (form.boom) { + throw ev.error(418, { reason: 'teapot' }); + } + return { ok: true }; +}); + +export default component$(() => { + const action = useAbortAction(); + const loc = useLocation(); + const caught = useSignal(''); + const formDetail = useSignal('none'); + return ( +
+ +

{caught.value}

+

{`${action.isRunning}:${String(action.value)}:${String(action.error)}:${loc.isNavigating}`}

+ { + formDetail.value = ev.detail.aborted + ? `aborted:${ev.detail.aborted.status}` + : `completed:${ev.detail.status}`; + }} + > + + + +

{formDetail.value}

+
+ ); +}); diff --git a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/actions/issue5065/index.tsx b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/actions/issue5065/index.tsx index 699a281d4a6..7be4143554a 100644 --- a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/actions/issue5065/index.tsx +++ b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/actions/issue5065/index.tsx @@ -22,12 +22,12 @@ export default component$(() => { fooValue satisfies MyObject; const zodAction = useZodObjectAction(); - const zodValue = zodAction.value!; - if (zodValue.failed) { - zodValue satisfies { failed: true } & ValidatorErrorType<{ + if (zodAction.error) { + zodAction.error.data satisfies ValidatorErrorType<{ name: string; }>; } else { + const zodValue = zodAction.value!; zodValue satisfies MyObject; } return <>TEST; diff --git a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/actions/issue5463/index.tsx b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/actions/issue5463/index.tsx index 98a360730ab..3990532f24c 100644 --- a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/actions/issue5463/index.tsx +++ b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/actions/issue5463/index.tsx @@ -34,7 +34,7 @@ export default component$(() => { > >; - const errors = dotNotation.value?.fieldErrors satisfies ConfirmType | undefined; + const errors = dotNotation.error?.fieldErrors satisfies ConfirmType | undefined; return ( <> @@ -45,7 +45,7 @@ export default component$(() => { name="credentials.username" value="user" class={{ - error: dotNotation.value?.fieldErrors?.['credentials.username'], + error: dotNotation.error?.fieldErrors?.['credentials.username'], }} /> { name="credentials.password" value="pass" class={{ - error: dotNotation.value?.fieldErrors?.['credentials.password'], + error: dotNotation.error?.fieldErrors?.['credentials.password'], }} /> { name="credentials.password" value="pass" class={{ - error: dotNotation.value?.fieldErrors?.['evenMoreComplex.deep.firstName'], + error: dotNotation.error?.fieldErrors?.['evenMoreComplex.deep.firstName'], }} /> {errors?.['credentials.password'] ?? 'no error'} @@ -71,7 +71,7 @@ export default component$(() => { name="evenMoreComplex.deep.firstName" value="John" class={{ - error: dotNotation.value?.fieldErrors?.['evenMoreComplex.deep.firstName'], + error: dotNotation.error?.fieldErrors?.['evenMoreComplex.deep.firstName'], }} /> @@ -80,7 +80,7 @@ export default component$(() => { name="persons.0.name" value="John" class={{ - error: dotNotation.value?.fieldErrors?.['persons[].name']?.[0], + error: dotNotation.error?.fieldErrors?.['persons[].name']?.[0], }} /> diff --git a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/actions/login.tsx b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/actions/login.tsx index 298a4bd9557..5035eed1639 100644 --- a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/actions/login.tsx +++ b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/actions/login.tsx @@ -51,8 +51,8 @@ export const SecretForm = component$(() => { placeholder="admin" value={action.formData?.get('username')} /> - {action.value?.fieldErrors?.username && ( -

{action.value.fieldErrors.username}

+ {action.error?.fieldErrors?.username && ( +

{action.error.fieldErrors.username}

)} @@ -60,14 +60,14 @@ export const SecretForm = component$(() => { - {action.value?.message && ( + {action.error?.message && (

- {action.value.message} + {action.error.message}

)} {action.value?.secret && ( diff --git a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/actions/validated/index.tsx b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/actions/validated/index.tsx index 9c1f0004274..47219fae46f 100644 --- a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/actions/validated/index.tsx +++ b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/actions/validated/index.tsx @@ -45,7 +45,7 @@ const actionQrl = (data: JSONObject, { fail }: RequestEventAction) => { if (Math.random() > 0.5) { return fail(500, { actionFail: 'secret', - } as ActionFailedObject); + } satisfies ActionFailedObject); } return { @@ -79,99 +79,75 @@ export default component$(() => { // Use options object, use typed data validator, use data validator const action1 = useAction1(); - if (action1.value) { - if (action1.value.failed) { - action1.value satisfies { failed: true } & ( - | TypedDataValidatorError - | DataValidatorError - | ActionFailedObject - ); - } else { - action1.value satisfies ActionSuccessObject; - } + if (action1.error) { + action1.error.data satisfies TypedDataValidatorError | DataValidatorError | ActionFailedObject; + } else if (action1.value) { + action1.value satisfies ActionSuccessObject; } // Use options object, use typed data validator const action2 = useAction2(); - if (action2.value) { - if (action2.value.failed) { - action2.value satisfies { failed: true } & (TypedDataValidatorError | ActionFailedObject); - } else { - action2.value satisfies ActionSuccessObject; - } + if (action2.error) { + action2.error.data satisfies TypedDataValidatorError | ActionFailedObject; + } else if (action2.value) { + action2.value satisfies ActionSuccessObject; } // Use options object, use data validator const action3 = useAction3(); - if (action3.value) { - if (action3.value.failed) { - action3.value satisfies { failed: true } & (DataValidatorError | ActionFailedObject); - } else { - action3.value satisfies ActionSuccessObject; - } + if (action3.error) { + action3.error.data satisfies DataValidatorError | ActionFailedObject; + } else if (action3.value) { + action3.value satisfies ActionSuccessObject; } // Use typed data validator, use data validator const action4 = useAction4(); - if (action4.value) { - if (action4.value.failed) { - action4.value satisfies { failed: true } & ( - | TypedDataValidatorError - | DataValidatorError - | ActionFailedObject - ); - } else { - action4.value satisfies ActionSuccessObject; - } + if (action4.error) { + action4.error.data satisfies TypedDataValidatorError | DataValidatorError | ActionFailedObject; + } else if (action4.value) { + action4.value satisfies ActionSuccessObject; } // Use typed data validator const action5 = useAction5(); - if (action5.value) { - if (action5.value.failed) { - action5.value satisfies { failed: true } & (TypedDataValidatorError | ActionFailedObject); - } else { - action5.value satisfies ActionSuccessObject; - } + if (action5.error) { + action5.error.data satisfies TypedDataValidatorError | ActionFailedObject; + } else if (action5.value) { + action5.value satisfies ActionSuccessObject; } // Use data validator const action6 = useAction6(); - if (action6.value) { - if (action6.value.failed) { - action6.value satisfies { failed: true } & (DataValidatorError | ActionFailedObject); - } else { - action6.value satisfies ActionSuccessObject; - } + if (action6.error) { + action6.error.data satisfies DataValidatorError | ActionFailedObject; + } else if (action6.value) { + action6.value satisfies ActionSuccessObject; } // No validators const action7 = useAction7(); - if (action7.value) { - if (action7.value.failed) { - action7.value satisfies { failed: true } & ActionFailedObject; - } else { - action7.value satisfies ActionSuccessObject; - } + if (action7.error) { + action7.error.data satisfies ActionFailedObject; + } else if (action7.value) { + action7.value satisfies ActionSuccessObject; } // No validators, with action id const action8 = useAction7(); - if (action8.value) { - if (action8.value.failed) { - action8.value satisfies { failed: true } & ActionFailedObject; - } else { - action8.value satisfies ActionSuccessObject; - } + if (action8.error) { + action8.error.data satisfies ActionFailedObject; + } else if (action8.value) { + action8.value satisfies ActionSuccessObject; } return (

Validated

- {loader.value.failed ? ( + {loader.error ? (

Failed

-

{loader.value.message}

+

{loader.error.message}

) : (
diff --git a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/[id]/index.tsx b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/[id]/index.tsx index 788f1d9fa48..83eec4abb38 100644 --- a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/[id]/index.tsx +++ b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/[id]/index.tsx @@ -176,8 +176,8 @@ export const head: DocumentHead = ({ resolveValue }) => { if (action) { title += ` - ACTION: ${action.name}`; } - if (actionWithError) { - title += ` - Error: ${actionWithError.name} ${actionWithError.message}`; + if (actionWithError?.message) { + title += ` - Error: ${actionWithError.message}`; } return { title, diff --git a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/issue3979/index.tsx b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/issue3979/index.tsx index 1bec9f4b101..9d83bad2256 100644 --- a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/issue3979/index.tsx +++ b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/issue3979/index.tsx @@ -1,4 +1,4 @@ -import { routeLoader$, validator$, type RequestEventAction } from '@qwik.dev/router'; +import { routeLoader$, validator$, type RequestEventLoader } from '@qwik.dev/router'; import { component$ } from '@qwik.dev/core'; const dataValidator = validator$((ev) => { @@ -33,9 +33,9 @@ const dynamicPetLoaderQrl = () => { }; }; -const randomFailedLoaderQrl = ({ fail }: RequestEventAction) => { +const randomFailedLoaderQrl = (ev: RequestEventLoader) => { if (Math.random() > 0.5) { - return fail(500, { + return ev.fail(500, { loaderFailedReason: 'Reach Limit', }); } @@ -69,30 +69,22 @@ export default component$(() => {
{pet.value.pet}
- {petWithValidation.value.pet} - {petWithValidation.value.failed} - {petWithValidation.value.validationFailReason} + {petWithValidation.error ? petWithValidation.error.message : petWithValidation.value.pet}
{dynamicPet.value.dog} {dynamicPet.value.rat}
- {dynamicPetWithValidation.value.dog} - {dynamicPetWithValidation.value.rat} - {dynamicPetWithValidation.value.failed} - {dynamicPetWithValidation.value.validationFailReason} + {dynamicPetWithValidation.error + ? dynamicPetWithValidation.error.message + : `${dynamicPetWithValidation.value.dog ?? ''}${dynamicPetWithValidation.value.rat ?? ''}`}
+
{randomFailed.error ? randomFailed.error.message : randomFailed.value.pet}
- {randomFailed.value.pet} - {randomFailed.value.failed} - {randomFailed.value.loaderFailedReason} -
-
- {randomFailedWithValidator.value.pet} - {randomFailedWithValidator.value.failed} - {randomFailedWithValidator.value.loaderFailedReason} - {randomFailedWithValidator.value.validationFailReason} + {randomFailedWithValidator.error + ? randomFailedWithValidator.error.message + : randomFailedWithValidator.value.pet}
); diff --git a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/loader-error/index.tsx b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/loader-error/index.tsx index cd823858aa2..5658c3a5917 100644 --- a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/loader-error/index.tsx +++ b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/loader-error/index.tsx @@ -6,6 +6,7 @@ const useError = routeLoader$(async function ({ error }): Promise { }); export default component$(() => { + // The thrown error() aborts the request — the error page renders, never this component. useError(); return <>; }); diff --git a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/loader-error/uncaught-server/index.tsx b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/loader-error/uncaught-server/index.tsx index 67dffaeb2ad..47ec37ac9f9 100644 --- a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/loader-error/uncaught-server/index.tsx +++ b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/loader-error/uncaught-server/index.tsx @@ -11,6 +11,7 @@ const useCatchServerErrorInLoader = routeLoader$(async () => { }); export default component$(() => { + // The thrown ServerError aborts the request — the error page renders, never this component. useCatchServerErrorInLoader(); return <>; }); diff --git a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/loader-fail/index.tsx b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/loader-fail/index.tsx new file mode 100644 index 00000000000..dbbd794ebff --- /dev/null +++ b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/(common)/loaders/loader-fail/index.tsx @@ -0,0 +1,37 @@ +import { component$ } from '@qwik.dev/core'; +import { Link, routeLoader$ } from '@qwik.dev/router'; + +export const useFailingLoader = routeLoader$((ev) => { + if (ev.query.get('ok') === '1') { + return { product: 'tshirt' }; + } + return ev.fail(429, { reason: 'Rate limited' }); +}); + +export default component$(() => { + const loader = useFailingLoader(); + + if (loader.error) { + return ( +
+ {loader.error.status + ? `${loader.error.status} ${loader.error.reason}` + : loader.error.message} +
+ ); + } + + return ( +
+
{loader.value.product}
+ {/* Same route, new search param: SPA nav refetches the loader (transport-failure spec) */} + + retry + + {/* Used by the SPA abort fallback spec */} + + To loader-error + +
+ ); +}); diff --git a/e2e/qwik-e2e/tests/qwikrouter/actions.e2e.ts b/e2e/qwik-e2e/tests/qwikrouter/actions.e2e.ts index 8360c1e0317..69e2dae7a50 100644 --- a/e2e/qwik-e2e/tests/qwikrouter/actions.e2e.ts +++ b/e2e/qwik-e2e/tests/qwikrouter/actions.e2e.ts @@ -177,3 +177,22 @@ test.describe('actions', () => { }); } }); + +test.describe('action abort semantics (spa)', () => { + test.use({ javaScriptEnabled: true }); + test.beforeEach(async ({ page }) => { + await page.goto('/qwikrouter-test/actions/abort/'); + }); + + test('programmatic submit() rejects on thrown error() and records no state', async ({ page }) => { + await page.locator('#abort-run').click(); + await expect(page.locator('#abort-caught')).toHaveText('caught:418'); + await expect(page.locator('#abort-state')).toHaveText('false:undefined:undefined:false'); + }); + + test('submitcompleted fires with detail.aborted when the submission aborts', async ({ page }) => { + await page.locator('#abort-form-submit').click(); + await expect(page.locator('#abort-form-detail')).toHaveText('aborted:418'); + await expect(page.locator('#abort-state')).toHaveText('false:undefined:undefined:false'); + }); +}); diff --git a/e2e/qwik-e2e/tests/qwikrouter/loaders.e2e.ts b/e2e/qwik-e2e/tests/qwikrouter/loaders.e2e.ts index 5eecd8818fd..fdbf5169cba 100644 --- a/e2e/qwik-e2e/tests/qwikrouter/loaders.e2e.ts +++ b/e2e/qwik-e2e/tests/qwikrouter/loaders.e2e.ts @@ -10,6 +10,50 @@ test.describe('loaders', () => { test.use({ javaScriptEnabled: true }); tests(); + test('falls back to a full-page load when an SPA-navigated loader throws error()', async ({ + page, + }) => { + await page.goto('/qwikrouter-test/loaders/loader-fail/?ok=1'); + await expect(page.locator('#loader-fail-value')).toHaveText('tshirt'); + + const documentRequest = page.waitForRequest( + (request) => + request.isNavigationRequest() && request.url().includes('/loaders/loader-error') + ); + await page.locator('#link-loader-error').click(); + const request = await documentRequest; + const response = await request.response(); + expect(response?.status()).toBe(401); + await expect(page.locator('body')).toContainText('loader-error-caught'); + }); + + test('fail() lands reactively on loader.error after SPA navigation', async ({ page }) => { + await page.goto('/qwikrouter-test/loaders/loader-fail/?ok=1'); + await expect(page.locator('#loader-fail-value')).toHaveText('tshirt'); + + const loaderResponse = page.waitForResponse( + (r) => r.url().includes('/loaders/loader-fail/') && r.url().includes('q-loader') + ); + await page.locator('#link-loader-fail-retry').click(); + const response = await loaderResponse; + expect(response.status()).toBe(200); + await expect(page.locator('#loader-fail-error')).toHaveText('429 Rate limited'); + }); + + test('a transport failure lands a plain Error on loader.error', async ({ page }) => { + await page.goto('/qwikrouter-test/loaders/loader-fail/?ok=1'); + await expect(page.locator('#loader-fail-value')).toHaveText('tshirt'); + + await page.route('**/q-loader-*', (route) => route.abort()); + await page.locator('#link-loader-fail-retry').click(); + + const err = page.locator('#loader-fail-error'); + await expect(err).toBeVisible(); + // The network Error branch rendered (message, no status), not the ServerError one. + await expect(err).not.toContainText('429'); + await expect(err).not.toBeEmpty(); + }); + test('should reuse filtered search loaders only for the same SPA route path', async ({ page, }) => { @@ -177,6 +221,17 @@ test.describe('loaders', () => { await expect(page.locator('#prop-unwrapped')).toHaveText('test'); }); + test('renders inline error UI with the fail() status', async ({ page }) => { + const response = await page.goto('/qwikrouter-test/loaders/loader-fail/'); + const contentType = await response?.headerValue('Content-Type'); + const status = response?.status(); + + expect(status).toEqual(429); + expect(contentType).toEqual('text/html; charset=utf-8'); + expect(await response?.headerValue('Cache-Control')).toBeNull(); + await expect(page.locator('#loader-fail-error')).toHaveText('429 Rate limited'); + }); + test('should modify ServerError in middleware', async ({ page }) => { const response = await page.goto('/qwikrouter-test/loaders/loader-error'); const contentType = await response?.headerValue('Content-Type'); diff --git a/packages/docs/src/routes/api/qwik-router-middleware-request-handler/api.json b/packages/docs/src/routes/api/qwik-router-middleware-request-handler/api.json index f4227f5bb6d..cc270e9e0de 100644 --- a/packages/docs/src/routes/api/qwik-router-middleware-request-handler/api.json +++ b/packages/docs/src/routes/api/qwik-router-middleware-request-handler/api.json @@ -131,23 +131,6 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/types.ts", "mdFile": "router.cookievalue.md" }, - { - "name": "data", - "id": "servererror-data", - "hierarchy": [ - { - "name": "ServerError", - "id": "servererror-data" - }, - { - "name": "data", - "id": "servererror-data" - } - ], - "kind": "Property", - "content": "```typescript\ndata: T;\n```", - "mdFile": "router.servererror.data.md" - }, { "name": "DeferReturn", "id": "deferreturn", @@ -193,6 +176,90 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/types.ts", "mdFile": "router.envgetter.md" }, + { + "name": "ExcludeFail", + "id": "excludefail", + "hierarchy": [ + { + "name": "ExcludeFail", + "id": "excludefail" + } + ], + "kind": "TypeAlias", + "content": "Removes fail branches from a loader/action return union — `.value` is the success type only.\n\n\n```typescript\nexport type ExcludeFail = T extends Failed ? never : T;\n```\n**References:** [Failed](#failed)", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/fail.ts", + "mdFile": "router.excludefail.md" + }, + { + "name": "FailBrand", + "id": "failbrand", + "hierarchy": [ + { + "name": "FailBrand", + "id": "failbrand" + } + ], + "kind": "Variable", + "content": "Brand for values produced by `requestEv.fail()`. Non-enumerable and `Symbol.for` so JSON/user data can't spoof a failure and the brand survives duplicate bundling.\n\n\n```typescript\nFailBrand: unique symbol\n```", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/fail.ts", + "mdFile": "router.failbrand.md" + }, + { + "name": "Failed", + "id": "failed", + "hierarchy": [ + { + "name": "Failed", + "id": "failed" + } + ], + "kind": "TypeAlias", + "content": "Marker for results produced by `requestEv.fail()`.\n\n\n```typescript\nexport type Failed = {\n readonly [FailBrand]: FailMeta;\n};\n```\n**References:** [FailBrand](#failbrand), [FailMeta](#failmeta)", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/fail.ts", + "mdFile": "router.failed.md" + }, + { + "name": "FailMeta", + "id": "failmeta", + "hierarchy": [ + { + "name": "FailMeta", + "id": "failmeta" + } + ], + "kind": "Interface", + "content": "Metadata carried by a fail result, hidden under the [FailBrand](#failbrand) symbol.\n\n\n```typescript\nexport interface FailMeta \n```\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nstatus\n\n\n\n\n\n\n\nnumber\n\n\n\n\n\n
", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/fail.ts", + "mdFile": "router.failmeta.md" + }, + { + "name": "FailPayload", + "id": "failpayload", + "hierarchy": [ + { + "name": "FailPayload", + "id": "failpayload" + } + ], + "kind": "TypeAlias", + "content": "Extracts the payload types of the fail branches of a loader/action return union.\n\n\n```typescript\nexport type FailPayload = T extends Failed ? {\n [K in keyof T as K extends typeof FailBrand ? never : K]: T[K];\n} : never;\n```\n**References:** [Failed](#failed), [FailBrand](#failbrand)", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/fail.ts", + "mdFile": "router.failpayload.md" + }, + { + "name": "FailReturn", + "id": "failreturn", + "hierarchy": [ + { + "name": "FailReturn", + "id": "failreturn" + } + ], + "kind": "TypeAlias", + "content": "A typed failure result returned from a loader/action via `requestEv.fail(status, data)`. It surfaces as the loader/action `.error` state and is excluded from the `.value` type.\n\n\n```typescript\nexport type FailReturn = T & Failed;\n```\n**References:** [Failed](#failed)", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/fail.ts", + "mdFile": "router.failreturn.md" + }, { "name": "get", "id": "cookie-get", @@ -358,7 +425,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface RequestEventAction extends RequestEventCommon \n```\n**Extends:** [RequestEventCommon](#requesteventcommon)<PLATFORM>\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nfail\n\n\n\n\n\n\n\n<T extends Record<string, any>>(status: number, returnData: T) => FailReturn<T>\n\n\n\n\n\n
", + "content": "```typescript\nexport interface RequestEventAction extends RequestEventCommon \n```\n**Extends:** [RequestEventCommon](#requesteventcommon)<PLATFORM>", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/types.ts", "mdFile": "router.requesteventaction.md" }, @@ -386,7 +453,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface RequestEventCommon extends RequestEventBase \n```\n**Extends:** [RequestEventBase](#requesteventbase)<PLATFORM>\n\n\n\n\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nerror\n\n\n\n\n`readonly`\n\n\n\n\n<T = any>(statusCode: ErrorCodes, message: T) => [ServerError](#servererror)<T>\n\n\n\n\nWhen called, the response will immediately end with the given status code. This could be useful to end a response with `404`, and use the 404 handler in the routes directory. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Status for which status code should be used.\n\n\n
\n\nexit\n\n\n\n\n`readonly`\n\n\n\n\n() => [AbortMessage](#abortmessage)\n\n\n\n\n\n
\n\nhtml\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode: StatusCodes, html: string) => [AbortMessage](#abortmessage)\n\n\n\n\nConvenience method to send an HTML body response. The response will be automatically set the `Content-Type` header to`text/html; charset=utf-8`. An `html()` response can only be called once.\n\n\n
\n\njson\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode: StatusCodes, data: any) => [AbortMessage](#abortmessage)\n\n\n\n\nConvenience method to JSON stringify the data and send it in the response. The response will be automatically set the `Content-Type` header to `application/json; charset=utf-8`. A `json()` response can only be called once.\n\n\n
\n\nlocale\n\n\n\n\n`readonly`\n\n\n\n\n(local?: string) => string\n\n\n\n\nWhich locale the content is in.\n\nThe locale value can be retrieved from selected methods using `getLocale()`:\n\n\n
\n\nredirect\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode: RedirectCode, url: string) => [RedirectMessage](#redirectmessage)\n\n\n\n\nURL to redirect to. When called, the response will immediately end with the correct redirect status and headers.\n\nhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections\n\n\n
\n\nrewrite\n\n\n\n\n`readonly`\n\n\n\n\n(pathname: string) => [RewriteMessage](#rewritemessage)\n\n\n\n\nWhen called, qwik-router will execute the path's matching route flow.\n\nThe url in the browser will remain unchanged.\n\n\n
\n\nsend\n\n\n\n\n`readonly`\n\n\n\n\nSendMethod\n\n\n\n\nSend a body response. The `Content-Type` response header is not automatically set when using `send()` and must be set manually. A `send()` response can only be called once.\n\n\n
\n\nstatus\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode?: StatusCodes) => number\n\n\n\n\nHTTP response status code. Sets the status code when called with an argument. Always returns the status code, so calling `status()` without an argument will can be used to return the current status code.\n\nhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Status\n\n\n
\n\ntext\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode: StatusCodes, text: string) => [AbortMessage](#abortmessage)\n\n\n\n\nConvenience method to send an text body response. The response will be automatically set the `Content-Type` header to`text/plain; charset=utf-8`. An `text()` response can only be called once.\n\n\n
", + "content": "```typescript\nexport interface RequestEventCommon extends RequestEventBase \n```\n**Extends:** [RequestEventBase](#requesteventbase)<PLATFORM>\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nerror\n\n\n\n\n`readonly`\n\n\n\n\n<T = any>(statusCode: ErrorCodes, message: T) => [ServerError](#servererror-variable)<T>\n\n\n\n\nWhen called, the response will immediately end with the given status code. This could be useful to end a response with `404`, and use the 404 handler in the routes directory. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Status for which status code should be used.\n\n\n
\n\nexit\n\n\n\n\n`readonly`\n\n\n\n\n() => [AbortMessage](#abortmessage)\n\n\n\n\n\n
\n\nfail\n\n\n\n\n`readonly`\n\n\n\n\n<T extends Record<string, any>>(statusCode: ErrorCodes, data: T) => [FailReturn](#failreturn)<T>\n\n\n\n\nReturns a typed failure result to `return` from a loader or action. It surfaces as the loader's/action's `.error` state (a `ServerError`) while `.value` stays the success type and the page keeps rendering. Unlike `error()`, which is thrown and aborts the request, `fail()` is for expected failures the page should display inline.\n\n\n
\n\nhtml\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode: StatusCodes, html: string) => [AbortMessage](#abortmessage)\n\n\n\n\nConvenience method to send an HTML body response. The response will be automatically set the `Content-Type` header to`text/html; charset=utf-8`. An `html()` response can only be called once.\n\n\n
\n\njson\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode: StatusCodes, data: any) => [AbortMessage](#abortmessage)\n\n\n\n\nConvenience method to JSON stringify the data and send it in the response. The response will be automatically set the `Content-Type` header to `application/json; charset=utf-8`. A `json()` response can only be called once.\n\n\n
\n\nlocale\n\n\n\n\n`readonly`\n\n\n\n\n(local?: string) => string\n\n\n\n\nWhich locale the content is in.\n\nThe locale value can be retrieved from selected methods using `getLocale()`:\n\n\n
\n\nredirect\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode: RedirectCode, url: string) => [RedirectMessage](#redirectmessage)\n\n\n\n\nURL to redirect to. When called, the response will immediately end with the correct redirect status and headers.\n\nhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections\n\n\n
\n\nrewrite\n\n\n\n\n`readonly`\n\n\n\n\n(pathname: string) => [RewriteMessage](#rewritemessage)\n\n\n\n\nWhen called, qwik-router will execute the path's matching route flow.\n\nThe url in the browser will remain unchanged.\n\n\n
\n\nsend\n\n\n\n\n`readonly`\n\n\n\n\nSendMethod\n\n\n\n\nSend a body response. The `Content-Type` response header is not automatically set when using `send()` and must be set manually. A `send()` response can only be called once.\n\n\n
\n\nstatus\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode?: StatusCodes) => number\n\n\n\n\nHTTP response status code. Sets the status code when called with an argument. Always returns the status code, so calling `status()` without an argument will can be used to return the current status code.\n\nhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Status\n\n\n
\n\ntext\n\n\n\n\n`readonly`\n\n\n\n\n(statusCode: StatusCodes, text: string) => [AbortMessage](#abortmessage)\n\n\n\n\nConvenience method to send an text body response. The response will be automatically set the `Content-Type` header to`text/plain; charset=utf-8`. An `text()` response can only be called once.\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/types.ts", "mdFile": "router.requesteventcommon.md" }, @@ -483,8 +550,22 @@ "id": "servererror" } ], - "kind": "Class", - "content": "```typescript\nexport declare class ServerError extends Error \n```\n**Extends:** Error\n\n\n\n\n
\n\nConstructor\n\n\n\n\nModifiers\n\n\n\n\nDescription\n\n\n
\n\n(constructor)(status, data)\n\n\n\n\n\n\n\nConstructs a new instance of the `ServerError` class\n\n\n
\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\n[data](#servererror-data)\n\n\n\n\n\n\n\nT\n\n\n\n\n\n
\n\n[status](#servererror-status)\n\n\n\n\n\n\n\nnumber\n\n\n\n\n\n
", + "kind": "TypeAlias", + "content": "```typescript\nServerError: {\n new (status: number, data: T): ServerError;\n readonly prototype: ServerErrorImpl;\n}\n```", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/server-error.ts", + "mdFile": "router.servererror.md" + }, + { + "name": "ServerError", + "id": "servererror", + "hierarchy": [ + { + "name": "ServerError", + "id": "servererror" + } + ], + "kind": "Variable", + "content": "```typescript\nServerError: {\n new (status: number, data: T): ServerError;\n readonly prototype: ServerErrorImpl;\n}\n```", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/server-error.ts", "mdFile": "router.servererror.md" }, @@ -560,23 +641,6 @@ "kind": "MethodSignature", "content": "Sets a `Response` cookie header using the `Set-Cookie` header.\n\n\n```typescript\nset(name: string, value: string | number | Record, options?: CookieOptions): void;\n```\n\n\n\n\n\n\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nname\n\n\n\n\nstring\n\n\n\n\n\n
\n\nvalue\n\n\n\n\nstring \\| number \\| Record<string, any>\n\n\n\n\n\n
\n\noptions\n\n\n\n\n[CookieOptions](#cookieoptions)\n\n\n\n\n_(Optional)_\n\n\n
\n\n**Returns:**\n\nvoid", "mdFile": "router.cookie.set.md" - }, - { - "name": "status", - "id": "servererror-status", - "hierarchy": [ - { - "name": "ServerError", - "id": "servererror-status" - }, - { - "name": "status", - "id": "servererror-status" - } - ], - "kind": "Property", - "content": "```typescript\nstatus: number;\n```", - "mdFile": "router.servererror.status.md" } ] } \ No newline at end of file diff --git a/packages/docs/src/routes/api/qwik-router-middleware-request-handler/index.mdx b/packages/docs/src/routes/api/qwik-router-middleware-request-handler/index.mdx index 6819b95c780..d8245ab692f 100644 --- a/packages/docs/src/routes/api/qwik-router-middleware-request-handler/index.mdx +++ b/packages/docs/src/routes/api/qwik-router-middleware-request-handler/index.mdx @@ -522,12 +522,6 @@ string [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/types.ts) -

data

- -```typescript -data: T; -``` -

DeferReturn

```typescript @@ -613,6 +607,112 @@ get(key) [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/types.ts) +

ExcludeFail

+ +Removes fail branches from a loader/action return union — `.value` is the success type only. + +```typescript +export type ExcludeFail = T extends Failed ? never : T; +``` + +**References:** [Failed](#failed) + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/fail.ts) + +

FailBrand

+ +Brand for values produced by `requestEv.fail()`. Non-enumerable and `Symbol.for` so JSON/user data can't spoof a failure and the brand survives duplicate bundling. + +```typescript +FailBrand: unique symbol +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/fail.ts) + +

Failed

+ +Marker for results produced by `requestEv.fail()`. + +```typescript +export type Failed = { + readonly [FailBrand]: FailMeta; +}; +``` + +**References:** [FailBrand](#failbrand), [FailMeta](#failmeta) + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/fail.ts) + +

FailMeta

+ +Metadata carried by a fail result, hidden under the [FailBrand](#failbrand) symbol. + +```typescript +export interface FailMeta +``` + + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +status + + + + + +number + + + +
+ +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/fail.ts) + +

FailPayload

+ +Extracts the payload types of the fail branches of a loader/action return union. + +```typescript +export type FailPayload = T extends Failed + ? { + [K in keyof T as K extends typeof FailBrand ? never : K]: T[K]; + } + : never; +``` + +**References:** [Failed](#failed), [FailBrand](#failbrand) + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/fail.ts) + +

FailReturn

+ +A typed failure result returned from a loader/action via `requestEv.fail(status, data)`. It surfaces as the loader/action `.error` state and is excluded from the `.value` type. + +```typescript +export type FailReturn = T & Failed; +``` + +**References:** [Failed](#failed) + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/fail.ts) + Gets a `Request` cookie header value by name. @@ -944,38 +1044,6 @@ export interface RequestEventAction extends Reque **Extends:** [RequestEventCommon](#requesteventcommon)<PLATFORM> - - -
- -Property - - - -Modifiers - - - -Type - - - -Description - -
- -fail - - - - - -<T extends Record<string, any>>(status: number, returnData: T) => FailReturn<T> - - - -
- [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/types.ts)

RequestEventBase

@@ -1368,7 +1436,7 @@ error -<T = any>(statusCode: ErrorCodes, message: T) => [ServerError](#servererror)<T> +<T = any>(statusCode: ErrorCodes, message: T) => [ServerError](#servererror-variable)<T> @@ -1392,6 +1460,23 @@ exit +fail + + + +`readonly` + + + +<T extends Record<string, any>>(statusCode: ErrorCodes, data: T) => [FailReturn](#failreturn)<T> + + + +Returns a typed failure result to `return` from a loader or action. It surfaces as the loader's/action's `.error` state (a `ServerError`) while `.value` stays the success type and the page keeps rendering. Unlike `error()`, which is thrown and aborts the request, `fail()` is for expected failures the page should display inline. + + + + html @@ -1703,84 +1788,25 @@ string [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/rewrite-handler.ts) -

ServerError

+

ServerError

```typescript -export declare class ServerError extends Error +ServerError: { + new (status: number, data: T): ServerError; + readonly prototype: ServerErrorImpl; +} ``` -**Extends:** Error - - - -
- -Constructor - - - -Modifiers - - - -Description - -
- -(constructor)(status, data) - - - - - -Constructs a new instance of the `ServerError` class - -
- - - - -
- -Property - - - -Modifiers - - - -Type - - - -Description - -
- -[data](#servererror-data) - - - - - -T - - - -
- -[status](#servererror-status) - - - - - -number +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/server-error.ts) - +

ServerError

-
+```typescript +ServerError: { + new (status: number, data: T): ServerError; + readonly prototype: ServerErrorImpl; +} +``` [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/server-error.ts) @@ -2065,9 +2091,3 @@ _(Optional)_ **Returns:** void - -

status

- -```typescript -status: number; -``` diff --git a/packages/docs/src/routes/api/qwik-router/api.json b/packages/docs/src/routes/api/qwik-router/api.json index 32299105f98..e4e7d35af84 100644 --- a/packages/docs/src/routes/api/qwik-router/api.json +++ b/packages/docs/src/routes/api/qwik-router/api.json @@ -12,7 +12,7 @@ } ], "kind": "TypeAlias", - "content": "```typescript\nexport type Action, OPTIONAL extends boolean = true> = {\n (): ActionStore;\n};\n```\n**References:** [ActionStore](#actionstore)", + "content": "```typescript\nexport type Action, OPTIONAL extends boolean = true, ERROR = unknown> = {\n (): ActionStore;\n};\n```\n**References:** [ActionStore](#actionstore)", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", "mdFile": "router.action.md" }, @@ -26,7 +26,7 @@ } ], "kind": "TypeAlias", - "content": "```typescript\nexport type ActionConstructor = {\n | void | null, VALIDATOR extends TypedDataValidator, REST extends [DataValidator, ...DataValidator[]]>(actionQrl: (data: GetValidatorOutputType, event: RequestEventAction) => ValueOrPromise, options: {\n readonly id?: string;\n readonly validation: [VALIDATOR, ...REST];\n }): Action>> | FailReturn>>, GetValidatorInputType, false>;\n | void | null, VALIDATOR extends TypedDataValidator>(actionQrl: (data: GetValidatorOutputType, event: RequestEventAction) => ValueOrPromise, options: {\n readonly id?: string;\n readonly validation: [VALIDATOR];\n }): Action>>>, GetValidatorInputType, false>;\n | void | null, REST extends [DataValidator, ...DataValidator[]]>(actionQrl: (data: JSONObject, event: RequestEventAction) => ValueOrPromise, options: {\n readonly id?: string;\n readonly validation: REST;\n }): Action>>>;\n | void | null, VALIDATOR extends TypedDataValidator, REST extends [DataValidator, ...DataValidator[]]>(actionQrl: (data: GetValidatorOutputType, event: RequestEventAction) => ValueOrPromise, options: VALIDATOR, ...rest: REST): Action>> | FailReturn>>, GetValidatorInputType, false>;\n | void | null, VALIDATOR extends TypedDataValidator>(actionQrl: (data: GetValidatorOutputType, event: RequestEventAction) => ValueOrPromise, options: VALIDATOR): Action>>>, GetValidatorInputType, false>;\n | void | null, REST extends [DataValidator, ...DataValidator[]]>(actionQrl: (form: JSONObject, event: RequestEventAction) => ValueOrPromise, ...rest: REST): Action>>>;\n (actionQrl: (form: JSONObject, event: RequestEventAction) => ValueOrPromise, options?: {\n readonly id?: string;\n }): Action>;\n};\n```\n**References:** [TypedDataValidator](#typeddatavalidator), [DataValidator](#datavalidator), [GetValidatorOutputType](#getvalidatoroutputtype), [Action](#action), [StrictUnion](#strictunion), [FailReturn](#failreturn), [ValidatorErrorType](#validatorerrortype), [GetValidatorInputType](#getvalidatorinputtype), [FailOfRest](#failofrest), [JSONObject](#jsonobject)", + "content": "```typescript\nexport type ActionConstructor = {\n | void | null, VALIDATOR extends TypedDataValidator, REST extends [DataValidator, ...DataValidator[]]>(actionQrl: (data: GetValidatorOutputType, event: RequestEventAction) => ValueOrPromise, options: {\n readonly id?: string;\n readonly validation: [VALIDATOR, ...REST];\n }): Action, GetValidatorInputType, false, ValidatorErrorType> | FailOfRest | FailPayload>;\n | void | null, VALIDATOR extends TypedDataValidator>(actionQrl: (data: GetValidatorOutputType, event: RequestEventAction) => ValueOrPromise, options: {\n readonly id?: string;\n readonly validation: [VALIDATOR];\n }): Action, GetValidatorInputType, false, ValidatorErrorType> | FailPayload>;\n | void | null, REST extends [DataValidator, ...DataValidator[]]>(actionQrl: (data: JSONObject, event: RequestEventAction) => ValueOrPromise, options: {\n readonly id?: string;\n readonly validation: REST;\n }): Action, Record, true, FailOfRest | FailPayload>;\n | void | null, VALIDATOR extends TypedDataValidator, REST extends [DataValidator, ...DataValidator[]]>(actionQrl: (data: GetValidatorOutputType, event: RequestEventAction) => ValueOrPromise, options: VALIDATOR, ...rest: REST): Action, GetValidatorInputType, false, ValidatorErrorType> | FailOfRest | FailPayload>;\n | void | null, VALIDATOR extends TypedDataValidator>(actionQrl: (data: GetValidatorOutputType, event: RequestEventAction) => ValueOrPromise, options: VALIDATOR): Action, GetValidatorInputType, false, ValidatorErrorType> | FailPayload>;\n | void | null, REST extends [DataValidator, ...DataValidator[]]>(actionQrl: (form: JSONObject, event: RequestEventAction) => ValueOrPromise, ...rest: REST): Action, Record, true, FailOfRest | FailPayload>;\n (actionQrl: (form: JSONObject, event: RequestEventAction) => ValueOrPromise, options?: {\n readonly id?: string;\n }): Action, Record, true, FailPayload>;\n};\n```\n**References:** [TypedDataValidator](#typeddatavalidator), [DataValidator](#datavalidator), [GetValidatorOutputType](#getvalidatoroutputtype), [Action](#action), [GetValidatorInputType](#getvalidatorinputtype), [ValidatorErrorType](#validatorerrortype), [FailOfRest](#failofrest), [JSONObject](#jsonobject)", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", "mdFile": "router.actionconstructor.md" }, @@ -40,7 +40,7 @@ } ], "kind": "TypeAlias", - "content": "```typescript\nexport type ActionReturn = {\n readonly status?: number;\n readonly value: RETURN;\n};\n```", + "content": "```typescript\nexport type ActionReturn = {\n readonly status?: number;\n readonly value: (unknown extends RETURN ? RETURN : StrictUnion) | undefined;\n readonly error: ServerError> | undefined;\n};\n```\n**References:** [StrictUnion](#strictunion)", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", "mdFile": "router.actionreturn.md" }, @@ -54,7 +54,7 @@ } ], "kind": "TypeAlias", - "content": "```typescript\nexport type ActionStore = {\n readonly actionPath: string;\n readonly isRunning: boolean;\n readonly status?: number;\n readonly formData: FormData | undefined;\n readonly value: RETURN | undefined;\n readonly submit: QRL Promise> : (form: INPUT | FormData | SubmitEvent) => Promise>>;\n readonly submitted: boolean;\n};\n```\n**References:** [ActionReturn](#actionreturn)", + "content": "```typescript\nexport type ActionStore = {\n readonly actionPath: string;\n readonly isRunning: boolean;\n readonly status?: number;\n readonly formData: FormData | undefined;\n readonly value: (unknown extends RETURN ? RETURN : StrictUnion) | undefined;\n readonly error: ServerError> | undefined;\n readonly submit: QRL Promise> : (form: INPUT | FormData | SubmitEvent) => Promise>>;\n readonly submitted: boolean;\n};\n```\n**References:** [StrictUnion](#strictunion), [ActionReturn](#actionreturn)", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", "mdFile": "router.actionstore.md" }, @@ -296,20 +296,6 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", "mdFile": "router.failofrest.md" }, - { - "name": "FailReturn", - "id": "failreturn", - "hierarchy": [ - { - "name": "FailReturn", - "id": "failreturn" - } - ], - "kind": "TypeAlias", - "content": "```typescript\nexport type FailReturn = T & Failed;\n```", - "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", - "mdFile": "router.failreturn.md" - }, { "name": "Form", "id": "form", @@ -348,7 +334,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface FormSubmitCompletedDetail \n```\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nstatus\n\n\n\n\n\n\n\nnumber\n\n\n\n\n\n
\n\nvalue\n\n\n\n\n\n\n\nT\n\n\n\n\n\n
", + "content": "```typescript\nexport interface FormSubmitCompletedDetail \n```\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\naborted?\n\n\n\n\n\n\n\n[ServerError](#servererror-variable)\n\n\n\n\n_(Optional)_ Set when the submission aborted (a thrown `error()` or an unexpected server error).\n\n\n
\n\nerror\n\n\n\n\n\n\n\n[ServerError](#servererror-variable)<ERROR> \\| undefined\n\n\n\n\nThe `ServerError` from a returned `fail()` or a failed validator.\n\n\n
\n\nstatus\n\n\n\n\n\n\n\nnumber\n\n\n\n\n\n
\n\nvalue\n\n\n\n\n\n\n\nT \\| undefined\n\n\n\n\nThe action's successful return value. `undefined` when the action failed or aborted.\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/form-component.tsx", "mdFile": "router.formsubmitsuccessdetail.md" }, @@ -488,7 +474,7 @@ } ], "kind": "TypeAlias", - "content": "```typescript\nexport type Loader = {\n (): LoaderSignal;\n};\n```\n**References:** [LoaderSignal](#loadersignal)", + "content": "```typescript\nexport type Loader = {\n (): LoaderSignal;\n};\n```\n**References:** [LoaderSignal](#loadersignal)", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", "mdFile": "router.loader_2.md" }, @@ -502,7 +488,7 @@ } ], "kind": "TypeAlias", - "content": "```typescript\nexport type LoaderSignal = (TYPE extends () => ValueOrPromise ? Signal> : Signal) & Pick;\n```", + "content": "```typescript\nexport type LoaderSignal = ([TYPE] extends [\n () => ValueOrPromise\n] ? Signal> : Signal) & Pick & {\n error: ServerError> | TransportError | undefined;\n};\n```\n**References:** [StrictUnion](#strictunion), [TransportError](#transporterror)", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", "mdFile": "router.loadersignal.md" }, @@ -982,6 +968,34 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", "mdFile": "router.serverdata.md" }, + { + "name": "ServerError", + "id": "servererror", + "hierarchy": [ + { + "name": "ServerError", + "id": "servererror" + } + ], + "kind": "TypeAlias", + "content": "```typescript\nServerError: {\n new (status: number, data: T): ServerError;\n readonly prototype: ServerErrorImpl;\n}\n```", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/server-error.ts", + "mdFile": "router.servererror.md" + }, + { + "name": "ServerError", + "id": "servererror", + "hierarchy": [ + { + "name": "ServerError", + "id": "servererror" + } + ], + "kind": "Variable", + "content": "```typescript\nServerError: {\n new (status: number, data: T): ServerError;\n readonly prototype: ServerErrorImpl;\n}\n```", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/server-error.ts", + "mdFile": "router.servererror.md" + }, { "name": "ServerFunction", "id": "serverfunction", @@ -1066,6 +1080,20 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", "mdFile": "router.strictunion.md" }, + { + "name": "TransportError", + "id": "transporterror", + "hierarchy": [ + { + "name": "TransportError", + "id": "transporterror" + } + ], + "kind": "TypeAlias", + "content": "A client-side transport failure on the same `.error` slot as ServerError. The server failure's fields exist as `?: never` so plain property checks discriminate the union.\n\n\n```typescript\nexport type TransportError = Error & {\n [K in keyof StrictUnion]?: never;\n} & {\n status?: never;\n data?: never;\n};\n```\n**References:** [StrictUnion](#strictunion)", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", + "mdFile": "router.transporterror.md" + }, { "name": "TypedDataValidator", "id": "typeddatavalidator", @@ -1258,10 +1286,38 @@ } ], "kind": "TypeAlias", - "content": "```typescript\nexport type ValidatorReturn = {}> = ValidatorReturnSuccess | ValidatorReturnFail;\n```", + "content": "```typescript\nexport type ValidatorReturn = {}> = ValidatorReturnSuccess | ValidatorReturnFail;\n```\n**References:** [ValidatorReturnSuccess](#validatorreturnsuccess), [ValidatorReturnFail](#validatorreturnfail)", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", "mdFile": "router.validatorreturn.md" }, + { + "name": "ValidatorReturnFail", + "id": "validatorreturnfail", + "hierarchy": [ + { + "name": "ValidatorReturnFail", + "id": "validatorreturnfail" + } + ], + "kind": "TypeAlias", + "content": "```typescript\nexport type ValidatorReturnFail = {}> = {\n readonly success: false;\n readonly error: T;\n readonly status?: number;\n};\n```", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", + "mdFile": "router.validatorreturnfail.md" + }, + { + "name": "ValidatorReturnSuccess", + "id": "validatorreturnsuccess", + "hierarchy": [ + { + "name": "ValidatorReturnSuccess", + "id": "validatorreturnsuccess" + } + ], + "kind": "TypeAlias", + "content": "```typescript\nexport type ValidatorReturnSuccess = {\n readonly success: true;\n readonly data?: unknown;\n};\n```", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", + "mdFile": "router.validatorreturnsuccess.md" + }, { "name": "zod$", "id": "zod_", diff --git a/packages/docs/src/routes/api/qwik-router/index.mdx b/packages/docs/src/routes/api/qwik-router/index.mdx index bd90e998a54..4ed3a9832fc 100644 --- a/packages/docs/src/routes/api/qwik-router/index.mdx +++ b/packages/docs/src/routes/api/qwik-router/index.mdx @@ -11,8 +11,9 @@ export type Action< RETURN, INPUT = Record, OPTIONAL extends boolean = true, + ERROR = unknown, > = { - (): ActionStore; + (): ActionStore; }; ``` @@ -38,13 +39,12 @@ export type ActionConstructor = { readonly validation: [VALIDATOR, ...REST]; }, ): Action< - StrictUnion< - | OBJ - | FailReturn>> - | FailReturn> - >, + ExcludeFail, GetValidatorInputType, - false + false, + | ValidatorErrorType> + | FailOfRest + | FailPayload >; < OBJ extends Record | void | null, @@ -59,11 +59,10 @@ export type ActionConstructor = { readonly validation: [VALIDATOR]; }, ): Action< - StrictUnion< - OBJ | FailReturn>> - >, + ExcludeFail, GetValidatorInputType, - false + false, + ValidatorErrorType> | FailPayload >; < OBJ extends Record | void | null, @@ -77,7 +76,12 @@ export type ActionConstructor = { readonly id?: string; readonly validation: REST; }, - ): Action>>>; + ): Action< + ExcludeFail, + Record, + true, + FailOfRest | FailPayload + >; < OBJ extends Record | void | null, VALIDATOR extends TypedDataValidator, @@ -90,13 +94,12 @@ export type ActionConstructor = { options: VALIDATOR, ...rest: REST ): Action< - StrictUnion< - | OBJ - | FailReturn>> - | FailReturn> - >, + ExcludeFail, GetValidatorInputType, - false + false, + | ValidatorErrorType> + | FailOfRest + | FailPayload >; < OBJ extends Record | void | null, @@ -108,11 +111,10 @@ export type ActionConstructor = { ) => ValueOrPromise, options: VALIDATOR, ): Action< - StrictUnion< - OBJ | FailReturn>> - >, + ExcludeFail, GetValidatorInputType, - false + false, + ValidatorErrorType> | FailPayload >; < OBJ extends Record | void | null, @@ -123,7 +125,12 @@ export type ActionConstructor = { event: RequestEventAction, ) => ValueOrPromise, ...rest: REST - ): Action>>>; + ): Action< + ExcludeFail, + Record, + true, + FailOfRest | FailPayload + >; ( actionQrl: ( form: JSONObject, @@ -132,44 +139,61 @@ export type ActionConstructor = { options?: { readonly id?: string; }, - ): Action>; + ): Action, Record, true, FailPayload>; }; ``` -**References:** [TypedDataValidator](#typeddatavalidator), [DataValidator](#datavalidator), [GetValidatorOutputType](#getvalidatoroutputtype), [Action](#action), [StrictUnion](#strictunion), [FailReturn](#failreturn), [ValidatorErrorType](#validatorerrortype), [GetValidatorInputType](#getvalidatorinputtype), [FailOfRest](#failofrest), [JSONObject](#jsonobject) +**References:** [TypedDataValidator](#typeddatavalidator), [DataValidator](#datavalidator), [GetValidatorOutputType](#getvalidatoroutputtype), [Action](#action), [GetValidatorInputType](#getvalidatorinputtype), [ValidatorErrorType](#validatorerrortype), [FailOfRest](#failofrest), [JSONObject](#jsonobject) [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts)

ActionReturn

```typescript -export type ActionReturn = { +export type ActionReturn = { readonly status?: number; - readonly value: RETURN; + readonly value: + | (unknown extends RETURN ? RETURN : StrictUnion) + | undefined; + readonly error: ServerError> | undefined; }; ``` +**References:** [StrictUnion](#strictunion) + [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts)

ActionStore

```typescript -export type ActionStore = { +export type ActionStore< + RETURN, + INPUT, + OPTIONAL extends boolean = true, + ERROR = unknown, +> = { readonly actionPath: string; readonly isRunning: boolean; readonly status?: number; readonly formData: FormData | undefined; - readonly value: RETURN | undefined; + readonly value: + | (unknown extends RETURN ? RETURN : StrictUnion) + | undefined; + readonly error: ServerError> | undefined; readonly submit: QRL< OPTIONAL extends true - ? (form?: INPUT | FormData | SubmitEvent) => Promise> - : (form: INPUT | FormData | SubmitEvent) => Promise> + ? ( + form?: INPUT | FormData | SubmitEvent, + ) => Promise> + : ( + form: INPUT | FormData | SubmitEvent, + ) => Promise> >; readonly submitted: boolean; }; ``` -**References:** [ActionReturn](#actionreturn) +**References:** [StrictUnion](#strictunion), [ActionReturn](#actionreturn) [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts) @@ -794,14 +818,6 @@ export type FailOfRest = [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts) -

FailReturn

- -```typescript -export type FailReturn = T & Failed; -``` - -[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts) -

Form

```typescript @@ -972,7 +988,7 @@ Defaults to `false`

FormSubmitSuccessDetail

```typescript -export interface FormSubmitCompletedDetail +export interface FormSubmitCompletedDetail ``` + +
@@ -994,6 +1010,36 @@ Description
+aborted? + + + + + +[ServerError](#servererror-variable) + + + +_(Optional)_ Set when the submission aborted (a thrown `error()` or an unexpected server error). + +
+ +error + + + + + +[ServerError](#servererror-variable)<ERROR> \| undefined + + + +The `ServerError` from a returned `fail()` or a failed validator. + +
+ status @@ -1013,10 +1059,12 @@ value -T +T \| undefined +The action's successful return value. `undefined` when the action failed or aborted. +
@@ -1251,8 +1299,8 @@ _(Optional)_

Loader_2

```typescript -export type Loader = { - (): LoaderSignal; +export type Loader = { + (): LoaderSignal; }; ``` @@ -1263,14 +1311,18 @@ export type Loader = {

LoaderSignal

```typescript -export type LoaderSignal = (TYPE extends () => ValueOrPromise< - infer VALIDATOR -> +export type LoaderSignal = ([TYPE] extends [ + () => ValueOrPromise, +] ? Signal> : Signal) & - Pick; + Pick & { + error: ServerError> | TransportError | undefined; + }; ``` +**References:** [StrictUnion](#strictunion), [TransportError](#transporterror) + [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts) @@ -2609,6 +2661,28 @@ export type ServerData = { [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts) +

ServerError

+ +```typescript +ServerError: { + new (status: number, data: T): ServerError; + readonly prototype: ServerErrorImpl; +} +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/server-error.ts) + +

ServerError

+ +```typescript +ServerError: { + new (status: number, data: T): ServerError; + readonly prototype: ServerErrorImpl; +} +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/server-error.ts) +

ServerFunction

```typescript @@ -2741,6 +2815,23 @@ export type StrictUnion = Prettify>; [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts) +

TransportError

+ +A client-side transport failure on the same `.error` slot as ServerError. The server failure's fields exist as `?: never` so plain property checks discriminate the union. + +```typescript +export type TransportError = Error & { + [K in keyof StrictUnion]?: never; +} & { + status?: never; + data?: never; +}; +``` + +**References:** [StrictUnion](#strictunion) + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts) +

TypedDataValidator

```typescript @@ -3057,6 +3148,31 @@ export type ValidatorReturn = {}> = | ValidatorReturnFail; ``` +**References:** [ValidatorReturnSuccess](#validatorreturnsuccess), [ValidatorReturnFail](#validatorreturnfail) + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts) + +

ValidatorReturnFail

+ +```typescript +export type ValidatorReturnFail = {}> = { + readonly success: false; + readonly error: T; + readonly status?: number; +}; +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts) + +

ValidatorReturnSuccess

+ +```typescript +export type ValidatorReturnSuccess = { + readonly success: true; + readonly data?: unknown; +}; +``` + [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts)

zod$

diff --git a/packages/docs/src/routes/docs/(qwikrouter)/action/index.mdx b/packages/docs/src/routes/docs/(qwikrouter)/action/index.mdx index 860e8b756f7..b2114c72e38 100644 --- a/packages/docs/src/routes/docs/(qwikrouter)/action/index.mdx +++ b/packages/docs/src/routes/docs/(qwikrouter)/action/index.mdx @@ -290,7 +290,7 @@ export default component$(() => { - {action.value?.failed &&

{action.value.fieldErrors?.firstName}

} + {action.error &&

{action.error.fieldErrors?.firstName}

} {action.value?.success && ( @@ -301,7 +301,7 @@ export default component$(() => { }); ``` -When submitting data to a `routeAction()`, the data is validated against the Zod schema. If the data is invalid, the action will put the validation error in the `routeAction.value` property. +When submitting data to a `routeAction()`, the data is validated against the Zod schema. If the data is invalid, the action fails: the validation error surfaces on `action.error` (a `ServerError`), and the field-level messages are exposed directly on the error as `action.error.fieldErrors` (the validator error fields are spread onto the `ServerError`). Please refer to the [Zod documentation](https://zod.dev/) for more information on how to use Zod schemas. @@ -366,7 +366,7 @@ export const useProductRecommendations = routeAction$( ## Action Failures -In order to return non-success values, the action must use the `fail()` method. +To signal an expected failure from an action — invalid input, a domain rule that didn't pass — destructure `fail` from the `RequestEvent` and `return fail(status, data)`: ```tsx /fail/ import { routeAction$, zod$, z } from '@qwik.dev/router'; @@ -390,7 +390,11 @@ export const useAddUser = routeAction$( ); ``` -Failures are stored in the `action.value` property, just like the success value. However, the `action.value.failed` property is set to `true` when the action fails. Futhermore, failure messages can be found in the `fieldErrors` object according to properties defined in your Zod schema. +Failures surface on the reactive `action.error` property as a `ServerError`, separate from the success value on `action.value`. The error carries `error.status` (the HTTP status) and `error.data` (the canonical payload object), and the payload's fields are also exposed flat on the error, so you can read them directly: `error.message`, `error.fieldErrors`, and so on. The page keeps rendering normally — the response just carries the failure's HTTP status (and is never cached). + +The data you pass to `fail()` is fully type-inferred into `action.error` (unioned with any `zod$()`/`valibot$()`/`validator$()` error types), while `action.value` stays the success type only — it is `undefined` whenever the action failed. + +For validators, the validator error fields are exposed flat on the error the same way: failure messages live in `action.error.fieldErrors` according to the properties defined in your Zod schema. The `fieldErrors` become a dot notation object. See [Complex forms](/docs/advanced/complex-forms) for more information. @@ -404,14 +408,38 @@ export default component$(() => {
- {action.value?.failed &&

{action.value.fieldErrors.name}

} + {/* fieldErrors comes from a zod$ failure, message from the fail() payload */} + {action.error &&

{action.error.fieldErrors?.name ?? action.error.message}

} {action.value?.userID &&

User added successfully

}
); }); ``` -Thanks to Typescript type discrimination, you can use the `action.value.failed` property to discriminate between success and failure. +Branch on `action.error` to render failure UI, and on `action.value` for the success result — they are never both set at once. + +### Aborting to the error page + +`fail()` is for failures the page should display inline. To abort the request entirely and render the nearest error page instead — for example a 404 for a missing record — destructure `error` from the `RequestEvent` and `throw error(status, data)`: + +```tsx /error/ +import { routeAction$, zod$, z } from '@qwik.dev/router'; + +export const useDeletePost = routeAction$( + async (data, { error }) => { + const post = await db.posts.get(data.id); + if (!post) { + // Aborts the request — the error page renders with a 404 status. + throw error(404, 'Post not found'); + } + await db.posts.delete(post.id); + return { success: true }; + }, + zod$({ id: z.string() }) +); +``` + +A thrown `error()` never lands on `action.error`: the action records no state and `action.submit()` rejects with the error. `
` handles the rejection internally and fires `submitcompleted` with `detail.aborted` set — use `fail()` for anything the UI should display. Plain (non-`error()`) throws behave like unexpected errors and result in a sanitized 500. ## Previous form state diff --git a/packages/docs/src/routes/docs/(qwikrouter)/advanced/complex-forms/index.mdx b/packages/docs/src/routes/docs/(qwikrouter)/advanced/complex-forms/index.mdx index d3bda0340c3..43ce02c807c 100644 --- a/packages/docs/src/routes/docs/(qwikrouter)/advanced/complex-forms/index.mdx +++ b/packages/docs/src/routes/docs/(qwikrouter)/advanced/complex-forms/index.mdx @@ -113,7 +113,7 @@ export const action = routeAction$( ### Field errors also in dot notation (almost) -If you use dot notation, the error messages are also returned in dot notation in the `fieldErrors` property. This has the advantage that the input name and fieldError key match. +If you use dot notation, the error messages are also returned in dot notation in the `fieldErrors` property of the action's error state (`action.error.fieldErrors`). This has the advantage that the input name and fieldError key match. For the this action: ```tsx @@ -202,19 +202,19 @@ export default component$(() => { return ( - {renderError(testAction.value?.fieldErrors?.["person.email"])} + {renderError(testAction.error?.fieldErrors?.["person.email"])} - {renderError(testAction.value?.fieldErrors?.["person.name"])} + {renderError(testAction.error?.fieldErrors?.["person.name"])} - {renderError(testAction.value?.fieldErrors?.["person.address.street"])} + {renderError(testAction.error?.fieldErrors?.["person.address.street"])} - {renderError(testAction.value?.fieldErrors?.["person.address.city"])} + {renderError(testAction.error?.fieldErrors?.["person.address.city"])} - {renderError(testAction.value?.fieldErrors?.["person.address.state"])} + {renderError(testAction.error?.fieldErrors?.["person.address.state"])} - {renderError(testAction.value?.fieldErrors?.["person.address.zip"])} + {renderError(testAction.error?.fieldErrors?.["person.address.zip"])} - {renderError(testAction.value?.fieldErrors?.["person.pets[]"]?.[0])} + {renderError(testAction.error?.fieldErrors?.["person.pets[]"]?.[0])}
); diff --git a/packages/docs/src/routes/docs/(qwikrouter)/error-handling/index.mdx b/packages/docs/src/routes/docs/(qwikrouter)/error-handling/index.mdx index f69060cf8cb..75a7570d2a3 100644 --- a/packages/docs/src/routes/docs/(qwikrouter)/error-handling/index.mdx +++ b/packages/docs/src/routes/docs/(qwikrouter)/error-handling/index.mdx @@ -10,19 +10,19 @@ import { Note } from '~/components/note/note'; # Error handling -When an error is thrown in a loader or `server$` function, a 500 error is returned to the client along with the error. This is useful during development but isn't always desirable for production systems. Qwik provides the tools necessary to customise how errors are handled. +When a plain error is thrown in a loader, action, or `server$` function, the request aborts and a sanitized 500 error is returned to the client. The full error is visible during development, but the details are hidden in production. Qwik provides the tools necessary to customise how errors are handled. -Throwing a `ServerError` instance allows you to return custom errors to the browser with a different status code and serialised data. +Throwing a `ServerError` instance (or the `requestEvent.error()` helper, which creates one) allows you to abort the request on purpose with a different status code and serialised data — the nearest error page renders with that status. -Loaders also provide a helper function on the event object to easily create new ServerErrors. +Throwing is for **aborting** to the error page. For expected failures that the page should display inline (form validation, domain rules), `return requestEvent.fail(status, data)` instead — the failure surfaces on the reactive `loader.error` / `action.error` and the page keeps rendering. See [routeLoader$](/docs/route-loader/#signaling-failure-from-a-loader) and [routeAction$](/docs/action/#action-failures). ```tsx -// Throw ServerErrors from a routerLoader$ +// Throw ServerErrors from a routeLoader$ const useProduct = routeLoader$(async (ev) => { const product = await fetch('api/product/1') if (!product) { - // Throw a 404 with a custom payload + // Throw a 404 with a custom payload — the error page renders with a 404 status throw new ServerError(404, 'Product not found') // Or use the existing helper function @@ -44,7 +44,7 @@ const getPrices = server$(() => { export default component$(() => { const product = useProduct() - useVisibleTask(() => { + useVisibleTask$(() => { getPrices() .then() .catch(err => { @@ -65,6 +65,8 @@ export default component$(() => { Intercepting errors with middleware has a few usecases: you might want to hide error details in production systems, add structured error logging, or map the error status codes from RPC API calls to HTTP status codes. This is all achieveable with middleware in a `plugin` file. +Thrown errors — `throw error(...)`, thrown `ServerError`s, and unexpected errors — propagate up through `next()`, so middleware can catch them. Note that a returned `fail(...)` is **not** an exception: it becomes the loader/action `.error` state and never passes through the interceptor. + ```tsx // src/routes/plugin@errors.ts import { type RequestHandler } from '@qwik.dev/router' diff --git a/packages/docs/src/routes/docs/(qwikrouter)/route-loader/index.mdx b/packages/docs/src/routes/docs/(qwikrouter)/route-loader/index.mdx index 7548fcf3bb5..71821bbd6ef 100644 --- a/packages/docs/src/routes/docs/(qwikrouter)/route-loader/index.mdx +++ b/packages/docs/src/routes/docs/(qwikrouter)/route-loader/index.mdx @@ -81,8 +81,7 @@ export default component$(() => { return
Loading…
; } if (product.error) { - // ServerError — read .status and .data - return
{product.error.status}: {product.error.data.message}
; + return
{product.error.message}
; } return
Product name: {product.value.name}
; }); @@ -92,10 +91,52 @@ 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): +To signal an expected failure — a result the page should render inline — destructure `fail` from the `RequestEvent` and `return fail(status, data)`. This puts the signal into error state, with `signal.error` set to a `ServerError` carrying `.status` (the HTTP status) and `.data` (the canonical payload object you passed to `fail()`), with the payload's fields also exposed flat on the error (e.g. `.message`). The page still renders — the response just carries the failure's HTTP status (and is never cached). -- **`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`. +```tsx +export const useProduct = routeLoader$(async ({ params, fail }) => { + const product = await db.products.findById(params.productId); + if (!product) { + // signal.error.status === 404, signal.error.data === { message: 'Product not found' } + return fail(404, { message: 'Product not found' }); + } + return product; +}); +``` + +Read it back on `signal.error`, never on `signal.value` — `.value` holds the success type only and is `undefined` on failure; they are never both set at once. The data you pass to `fail()` is fully type-inferred into `signal.error`, so failure payloads are as strongly typed as success values. + +On the client, `signal.error` can also hold a plain `Error` when the loader's JSON fetch itself failed (a network problem). Its `status` and payload fields are typed optional across that union, so a plain property check tells the two apart: + +```tsx +if (product.error) { + if (product.error.status) { + // server failure — typed: .status, .data, and your fail() payload fields + console.warn(product.error.status, product.error.data); + } else { + // plain Error: the fetch itself failed + } +} +``` + +When an error reaches you as `unknown` (a `catch` block around `resolveValue()`), check it structurally — `err instanceof Error && typeof err.status === 'number'` — since `instanceof ServerError` is `false` on the client after deserialization. + +### Aborting to the error page + +`fail()` is for failures your page renders inline. When the right response is the error page itself — a 404 for a missing product, say — destructure `error` from the `RequestEvent` and `throw error(status, data)` instead: + +```tsx +export const useProduct = routeLoader$(async ({ params, error }) => { + const product = await db.products.findById(params.productId); + if (!product) { + // Aborts the request — the nearest error page renders with a 404 status. + throw error(404, 'Product not found'); + } + return product; +}); +``` + +A thrown `error()` never lands on `signal.error`: it aborts the request to the nearest error handler (the route's error page, or a middleware `catch`). During SPA navigation the client falls back to a full-page load so the server can render the real error page. Plain (non-`error()`) throws behave like unexpected errors and result in a sanitized 500. ## Multiple `routeLoader$`s @@ -329,7 +370,7 @@ export default component$(() => { | Echo the just-submitted form value back to the page | Loader returns the URL-derived value; the component overlays `action.value` on top, e.g. `action.value?.name ?? loader.value.name`. | | Recompute derived data from the submission | Put the inputs in the URL (search params, or `goto()` after the action) so the loader reads them like any other route input. | | Refresh authoritative data after a mutation | The action writes to your store (DB, KV, etc.); the loader reads from that store. The action's `invalidate` list re-runs the loader and it picks up the new state — no action result needed. | -| Show success / error feedback | Read the action signal directly: `action.value`, `action.isRunning`, `action.value?.failed`. Ephemeral UI feedback doesn't belong in a loader. | +| Show success / error feedback | Read the action signal directly: `action.value`, `action.isRunning`, `action.error`. Ephemeral UI feedback doesn't belong in a loader. | ## Options reference diff --git a/packages/docs/src/routes/docs/(qwikrouter)/validator/index.mdx b/packages/docs/src/routes/docs/(qwikrouter)/validator/index.mdx index 91885057a0b..192195f002a 100644 --- a/packages/docs/src/routes/docs/(qwikrouter)/validator/index.mdx +++ b/packages/docs/src/routes/docs/(qwikrouter)/validator/index.mdx @@ -37,20 +37,23 @@ export const useAction = routeAction$( ); ``` -When submitting a request to a `routeAction()`, the request event and data undergo validation against the defined validator. If the validation fails, the action will place the validation error in the `routeAction.value` property. +When submitting a request to a `routeAction()`, the request event and data undergo validation against the defined validator. If the validation fails, the action surfaces the validation error on the `routeAction.error` property (a `ServerError`), separate from the success value on `routeAction.value`. ```tsx export default component$(() => { const action = useAction(); - // action value is undefined before submitting - if (action.value) { - if (action.value.failed) { - // action failed if query string has no secret - action.value satisfies { failed: true; message: string }; - } else { - action.value satisfies { searchResult: string }; - } + // both are undefined before submitting; only one is set after + if (action.error) { + // validation failed if query string has no secret + // action.error is a ServerError: .status is the HTTP status, and the + // validator error fields are spread directly onto the error, so you read + // them flat (e.g. action.error.message). The canonical payload object is + // still available as action.error.data. + action.error.message satisfies string; + action.error.data satisfies { message: string }; + } else if (action.value) { + action.value satisfies { searchResult: string }; } return ( @@ -144,26 +147,28 @@ interface Fail { } ``` -Validator behaves like using the `fail()` method in the action or loader when it returns a failed object. +When a validator returns a failed object, the framework turns it into a `ServerError` — the same outcome as `return fail(status, data)` inside the action or loader. The error surfaces on `action.error`, with `action.error.status` set to the validator's `status`, and the page keeps rendering (the failure is meant to be displayed inline; `action.value` stays `undefined`). The validator's `error` object fields are spread directly onto the error, so you read them flat (e.g. `action.error.message`); the canonical payload object is also available as `action.error.data`. -```tsx /status/#a /errorObject/#b +```tsx /status/#a /errorData/#b const status = 500; -const errorObject = { message: "123" }; +const errorData = { message: "123" }; export const useAction = routeAction$( async (_, { fail }) => { - return fail(status, errorObject); + return fail(status, errorData); }, validator$(async () => { return { success: false, status, - errorObject, + error: errorData, }; }), ); ``` +To abort the request and render the error page instead of surfacing an inline failure, `throw error(status, data)` from the action or loader body — a thrown `error()` never lands on `action.error`. + ## Use `validator$()` with `zod$()` together in actions For actions, the typed data validator `zod$()` should be the second argument of `routeAction$`, followed by other data validators `validator$()`s. diff --git a/packages/docs/src/routes/docs/upgrade/index.mdx b/packages/docs/src/routes/docs/upgrade/index.mdx index 180b2a6d316..49165b83074 100644 --- a/packages/docs/src/routes/docs/upgrade/index.mdx +++ b/packages/docs/src/routes/docs/upgrade/index.mdx @@ -251,6 +251,26 @@ export default component$(() => { If your root component is reactive (reads signals), use `` instead. `useQwikRouter()` only runs once during SSR. +### Action and loader failures: `value.failed` → `error` + +The producer side is **unchanged**: keep using `return requestEvent.fail(status, data)` for expected failures (form validation, domain rules), and `throw requestEvent.error(status, data)` still aborts to the nearest error page, exactly like v1. + +What moved is the consumer side. In v1, `fail()` results landed on `action.value` with a `failed: true` marker, so `.value` was a union of the success and failure shapes. In v2, failures surface on the reactive `action.error` / `loader.error` as a `ServerError` — `.status` is the HTTP status, `.data` is the canonical payload, and the payload's fields are also exposed flat (e.g. `error.fieldErrors`). `.value` is now the success type only and is `undefined` on failure; `.value` and `.error` are never both set. + +```tsx +// v1 +{action.value?.failed &&

{action.value.fieldErrors?.name}

} +{action.value?.success &&

Done!

} + +// v2 +{action.error &&

{action.error.fieldErrors?.name}

} +{action.value?.success &&

Done!

} +``` + +- The `StrictUnion` success/failure value unions are gone — no more `action.value?.failed` narrowing. `FailReturn` still exists but is symbol-branded; it no longer has a `failed: true` property and never appears in `.value`'s type. `fail()` payloads are type-inferred into `.error` instead. +- Loaders that used `throw error(status, data)` to render error pages **keep that behavior** — thrown `error()`s abort to the error page and never land on `.error`. +- On loaders, `.error` can also hold a plain `Error` on the client (the loader's fetch itself failed). Its `status` and payload fields are typed optional across the union, so a plain property check discriminates (`if (loader.error?.status)`). Reading `loader.value` while the signal is in error state throws — check `.error` first. In `catch` blocks (around `submit()` or `resolveValue()`), check errors structurally (`typeof err.status === 'number'`) — `instanceof ServerError` is `false` on the client after deserialization. + ### Serialization v1 serialized state into `