diff --git a/.changeset/returned-control-flow-signals.md b/.changeset/returned-control-flow-signals.md new file mode 100644 index 00000000000..06e0c31eba0 --- /dev/null +++ b/.changeset/returned-control-flow-signals.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/router': minor +--- + +feat: returning `ev.redirect()`, `ev.error()` or `ev.rewrite()` from a loader, action, request handler or server function now behaves the same as throwing them diff --git a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/returned-control-flow/action-error/index.tsx b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/returned-control-flow/action-error/index.tsx new file mode 100644 index 00000000000..57fac7ba8a9 --- /dev/null +++ b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/returned-control-flow/action-error/index.tsx @@ -0,0 +1,16 @@ +import { component$ } from '@qwik.dev/core'; +import { Form, routeAction$ } from '@qwik.dev/router'; + +// Returns the error signal instead of throwing it. +export const useErrorAction = routeAction$((_data, { error }) => + error(403, 'returned-action-error') +); + +export default component$(() => { + const action = useErrorAction(); + return ( +
+ +
+ ); +}); diff --git a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/returned-control-flow/handler-redirect/index.tsx b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/returned-control-flow/handler-redirect/index.tsx new file mode 100644 index 00000000000..45ee03a7833 --- /dev/null +++ b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/returned-control-flow/handler-redirect/index.tsx @@ -0,0 +1,10 @@ +import { component$ } from '@qwik.dev/core'; +import type { RequestHandler } from '@qwik.dev/router'; + +// Returns the redirect signal from a request handler instead of throwing it. +export const onGet: RequestHandler = ({ redirect }) => + redirect(302, '/qwikrouter-test/returned-control-flow/target/'); + +export default component$(() => { + return

Should not render

; +}); diff --git a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/returned-control-flow/loader-error/index.tsx b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/returned-control-flow/loader-error/index.tsx new file mode 100644 index 00000000000..556f4c5ad9f --- /dev/null +++ b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/returned-control-flow/loader-error/index.tsx @@ -0,0 +1,10 @@ +import { component$ } from '@qwik.dev/core'; +import { routeLoader$ } from '@qwik.dev/router'; + +// Returns the error signal instead of throwing it. +export const useErrorLoader = routeLoader$(({ error }) => error(401, 'returned-loader-error')); + +export default component$(() => { + useErrorLoader(); + return

Should not render

; +}); diff --git a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/returned-control-flow/loader-redirect/index.tsx b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/returned-control-flow/loader-redirect/index.tsx new file mode 100644 index 00000000000..666e645058f --- /dev/null +++ b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/returned-control-flow/loader-redirect/index.tsx @@ -0,0 +1,12 @@ +import { component$ } from '@qwik.dev/core'; +import { routeLoader$ } from '@qwik.dev/router'; + +// Returns the redirect signal instead of throwing it. +export const useRedirectLoader = routeLoader$(({ redirect }) => + redirect(302, '/qwikrouter-test/returned-control-flow/target/') +); + +export default component$(() => { + useRedirectLoader(); + return

Should not render

; +}); diff --git a/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/returned-control-flow/target/index.tsx b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/returned-control-flow/target/index.tsx new file mode 100644 index 00000000000..6dbea6708fe --- /dev/null +++ b/e2e/qwik-e2e/apps/qwikrouter-test/src/routes/returned-control-flow/target/index.tsx @@ -0,0 +1,5 @@ +import { component$ } from '@qwik.dev/core'; + +export default component$(() => { + return

Target

; +}); diff --git a/e2e/qwik-e2e/tests/qwikrouter/returned-control-flow.e2e.ts b/e2e/qwik-e2e/tests/qwikrouter/returned-control-flow.e2e.ts new file mode 100644 index 00000000000..db3f4e3840f --- /dev/null +++ b/e2e/qwik-e2e/tests/qwikrouter/returned-control-flow.e2e.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test'; + +const base = '/qwikrouter-test/returned-control-flow'; + +// Returning ev.redirect()/ev.error() must behave the same as throwing them. +test.describe('returned control-flow signals', () => { + test('loader returning redirect redirects', async ({ page }) => { + await page.goto(`${base}/loader-redirect/`); + await expect(page.locator('#returned-control-flow-target')).toBeVisible(); + }); + + test('request handler returning redirect redirects', async ({ page }) => { + await page.goto(`${base}/handler-redirect/`); + await expect(page.locator('#returned-control-flow-target')).toBeVisible(); + }); + + test('loader returning error renders the error response', async ({ page }) => { + const response = await page.goto(`${base}/loader-error/`); + expect(response?.status()).toEqual(401); + await expect(page.locator('body')).toContainText('returned-loader-error'); + }); + + test('action returning error responds with the error status', async ({ page }) => { + await page.goto(`${base}/action-error/`); + const actionPath = await page.locator('form').getAttribute('action'); + const response = await page.request.post(new URL(actionPath!, page.url()).href, { + headers: { Accept: 'application/json' }, + form: {}, + }); + expect(response.status()).toEqual(403); + }); +}); 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..3535b05005d 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 @@ -2,6 +2,23 @@ "id": "qwik-router-middleware-request-handler", "package": "@qwik.dev/qwik-router/middleware/request-handler", "members": [ + { + "name": "__controlFlow", + "id": "abortmessage-__controlflow", + "hierarchy": [ + { + "name": "AbortMessage", + "id": "abortmessage-__controlflow" + }, + { + "name": "__controlFlow", + "id": "abortmessage-__controlflow" + } + ], + "kind": "Property", + "content": "Type-only brand so `Exclude<>` can drop control-flow signals from resolved loader/action data.\n\n\n```typescript\nreadonly __controlFlow: true;\n```", + "mdFile": "router.abortmessage.__controlflow.md" + }, { "name": "AbortMessage", "id": "abortmessage", @@ -12,7 +29,7 @@ } ], "kind": "Class", - "content": "```typescript\nexport declare class AbortMessage \n```", + "content": "```typescript\nexport declare class AbortMessage \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[\\_\\_controlFlow](#abortmessage-__controlflow)\n\n\n\n\n`readonly`\n\n\n\n\ntrue\n\n\n\n\nType-only brand so `Exclude<>` can drop control-flow signals from resolved loader/action data.\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/redirect-handler.ts", "mdFile": "router.abortmessage.md" }, @@ -414,7 +431,7 @@ } ], "kind": "Function", - "content": "```typescript\nexport type RequestHandler = (ev: RequestEvent) => Promise | void;\n```\n**References:** [RequestEvent](#requestevent)", + "content": "```typescript\nexport type RequestHandler = (ev: RequestEvent) => Promise | void | AbortMessage | ServerError;\n```\n**References:** [RequestEvent](#requestevent), [AbortMessage](#abortmessage), [ServerError](#servererror)", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/request-handler.ts", "mdFile": "router.requesthandler.md" }, @@ -428,7 +445,7 @@ } ], "kind": "TypeAlias", - "content": "```typescript\nexport type RequestHandler = (ev: RequestEvent) => Promise | void;\n```\n**References:** [RequestEvent](#requestevent)", + "content": "```typescript\nexport type RequestHandler = (ev: RequestEvent) => Promise | void | AbortMessage | ServerError;\n```\n**References:** [RequestEvent](#requestevent), [AbortMessage](#abortmessage), [ServerError](#servererror)", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/types.ts", "mdFile": "router.requesthandler.md" }, 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..37bc14c53f3 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 @@ -4,12 +4,56 @@ title: \@qwik.dev/qwik-router/middleware/request-handler API Reference # [API](/api) › @qwik.dev/qwik-router/middleware/request-handler +

__controlFlow

+ +Type-only brand so `Exclude<>` can drop control-flow signals from resolved loader/action data. + +```typescript +readonly __controlFlow: true; +``` +

AbortMessage

```typescript export declare class AbortMessage ``` + + +
+ +Property + + + +Modifiers + + + +Type + + + +Description + +
+ +[\_\_controlFlow](#abortmessage-__controlflow) + + + +`readonly` + + + +true + + + +Type-only brand so `Exclude<>` can drop control-flow signals from resolved loader/action data. + +
+ [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/redirect-handler.ts) @@ -1598,10 +1642,14 @@ resolveValue ```typescript export type RequestHandler = ( ev: RequestEvent, -) => Promise | void; +) => + | Promise + | void + | AbortMessage + | ServerError; ``` -**References:** [RequestEvent](#requestevent) +**References:** [RequestEvent](#requestevent), [AbortMessage](#abortmessage), [ServerError](#servererror) [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/request-handler.ts) @@ -1610,10 +1658,14 @@ export type RequestHandler = ( ```typescript export type RequestHandler = ( ev: RequestEvent, -) => Promise | void; +) => + | Promise + | void + | AbortMessage + | ServerError; ``` -**References:** [RequestEvent](#requestevent) +**References:** [RequestEvent](#requestevent), [AbortMessage](#abortmessage), [ServerError](#servererror) [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/middleware/request-handler/types.ts) diff --git a/packages/docs/src/routes/api/qwik-router/api.json b/packages/docs/src/routes/api/qwik-router/api.json index 13af0f132e4..2bbdc1a761e 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> = {\n (): ActionStore, INPUT, OPTIONAL>;\n};\n```\n**References:** [ActionStore](#actionstore), [ExcludeControlFlow](#excludecontrolflow)", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", "mdFile": "router.action.md" }, @@ -282,6 +282,20 @@ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/error-boundary.tsx", "mdFile": "router.errorboundary.md" }, + { + "name": "ExcludeControlFlow", + "id": "excludecontrolflow", + "hierarchy": [ + { + "name": "ExcludeControlFlow", + "id": "excludecontrolflow" + } + ], + "kind": "TypeAlias", + "content": "Drops control-flow signals (`ev.redirect()`, `ev.error()`, etc.) from a loader/action return type: those are thrown, not surfaced as data. `ev.fail()` is plain data and is kept.\n\n\n```typescript\nexport type ExcludeControlFlow = Exclude;\n```", + "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", + "mdFile": "router.excludecontrolflow.md" + }, { "name": "FailOfRest", "id": "failofrest", @@ -502,7 +516,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), [ExcludeControlFlow](#excludecontrolflow)", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", "mdFile": "router.loader_2.md" }, diff --git a/packages/docs/src/routes/api/qwik-router/index.mdx b/packages/docs/src/routes/api/qwik-router/index.mdx index 058f7a3371a..a8dc9b8c6f2 100644 --- a/packages/docs/src/routes/api/qwik-router/index.mdx +++ b/packages/docs/src/routes/api/qwik-router/index.mdx @@ -12,11 +12,11 @@ export type Action< INPUT = Record, OPTIONAL extends boolean = true, > = { - (): ActionStore; + (): ActionStore, INPUT, OPTIONAL>; }; ``` -**References:** [ActionStore](#actionstore) +**References:** [ActionStore](#actionstore), [ExcludeControlFlow](#excludecontrolflow) [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts) @@ -783,6 +783,16 @@ ErrorBoundary: import("@qwik.dev/core").Component; [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/error-boundary.tsx) +

ExcludeControlFlow

+ +Drops control-flow signals (`ev.redirect()`, `ev.error()`, etc.) from a loader/action return type: those are thrown, not surfaced as data. `ev.fail()` is plain data and is kept. + +```typescript +export type ExcludeControlFlow = Exclude; +``` + +[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts) +

FailOfRest

```typescript @@ -1294,11 +1304,11 @@ _(Optional)_ ```typescript export type Loader = { - (): LoaderSignal; + (): LoaderSignal>; }; ``` -**References:** [LoaderSignal](#loadersignal) +**References:** [LoaderSignal](#loadersignal), [ExcludeControlFlow](#excludecontrolflow) [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts) diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.ts b/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.ts index e2ab182127c..9cf2715bd3f 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.ts @@ -9,6 +9,7 @@ import type { } from '../../../runtime/src/types'; import { RequestEvSharedActionId, type RequestEventInternal } from '../request-event-core'; import { IsQAction, QActionId } from '../request-path'; +import { throwIfControlFlowSignal } from '../server-error'; import type { QRL } from '@qwik.dev/core'; import type { RequestEventBase } from '../types'; @@ -92,6 +93,7 @@ export function actionHandler(routeActions: ActionInternal[]): RequestHandler { action!.__qrl.call(requestEv, result.data as JSONObject, requestEv) ) : await action.__qrl.call(requestEv, result.data as JSONObject, requestEv); + throwIfControlFlowSignal(actionResolved); if (devMode) { verifySerializable(actionResolved, action.__qrl); } diff --git a/packages/qwik-router/src/middleware/request-handler/middleware.request-handler.api.md b/packages/qwik-router/src/middleware/request-handler/middleware.request-handler.api.md index 7824e24a2dd..31b2e04b2e0 100644 --- a/packages/qwik-router/src/middleware/request-handler/middleware.request-handler.api.md +++ b/packages/qwik-router/src/middleware/request-handler/middleware.request-handler.api.md @@ -20,6 +20,7 @@ import type { ValueOrPromise } from '@qwik.dev/core'; // @public (undocumented) export class AbortMessage { + readonly __controlFlow: true; } // Warning: (ae-forgotten-export) The symbol "RequestEventInternal" needs to be exported by the entry point index.d.ts @@ -170,7 +171,7 @@ export interface RequestEventLoader extends Reque } // @public (undocumented) -export type RequestHandler = (ev: RequestEvent) => Promise | void; +export type RequestHandler = (ev: RequestEvent) => Promise | void | AbortMessage | ServerError; // Warning: (ae-forgotten-export) The symbol "QwikRouterRun" needs to be exported by the entry point index.d.ts // diff --git a/packages/qwik-router/src/middleware/request-handler/redirect-handler.ts b/packages/qwik-router/src/middleware/request-handler/redirect-handler.ts index 8fd0f5a7c7e..70811edb408 100644 --- a/packages/qwik-router/src/middleware/request-handler/redirect-handler.ts +++ b/packages/qwik-router/src/middleware/request-handler/redirect-handler.ts @@ -1,5 +1,8 @@ /** @public */ -export class AbortMessage {} +export class AbortMessage { + /** Type-only brand so `Exclude<>` can drop control-flow signals from resolved loader/action data. */ + declare readonly __controlFlow: true; +} /** @public */ export class RedirectMessage extends AbortMessage {} diff --git a/packages/qwik-router/src/middleware/request-handler/request-event-core.ts b/packages/qwik-router/src/middleware/request-handler/request-event-core.ts index f99a2282625..b21994c6dc9 100644 --- a/packages/qwik-router/src/middleware/request-handler/request-event-core.ts +++ b/packages/qwik-router/src/middleware/request-handler/request-event-core.ts @@ -20,7 +20,7 @@ import { } from './request-path'; import { AbortMessage, RedirectMessage } from './redirect-handler'; import { RewriteMessage } from './rewrite-handler'; -import { ServerError } from './server-error'; +import { ServerError, throwIfControlFlowSignal } from './server-error'; import { encoder, getContentType } from './request-utils'; import type { CacheControl, @@ -92,9 +92,7 @@ export function createRequestEvent( while (routeModuleIndex < requestHandlers.length) { const moduleRequestHandler = requestHandlers[routeModuleIndex]; const result = moduleRequestHandler(requestEv); - if (isPromise(result)) { - await result; - } + throwIfControlFlowSignal(isPromise(result) ? await result : result); routeModuleIndex++; } }; diff --git a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers-core.ts b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers-core.ts index 99c55785623..7ea05151d6f 100644 --- a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers-core.ts +++ b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers-core.ts @@ -49,7 +49,7 @@ import { import { HttpStatus } from './http-status-codes'; import { getQwikRouterServerData } from './response-page'; import { encoder, isContentType } from './request-utils'; -import { ServerError } from './server-error'; +import { ServerError, throwIfControlFlowSignal } from './server-error'; const loadHttpError = () => import('../../runtime/src/http-error'); @@ -296,6 +296,7 @@ function createResolveRequestHandlers() { action.__qrl.call(requestEv, result.data as JSONObject, requestEv) ) : await action.__qrl.call(requestEv, result.data as JSONObject, requestEv); + throwIfControlFlowSignal(actionResolved); if (isDev) { verifySerializable(actionResolved, action.__qrl); } @@ -554,6 +555,7 @@ function createResolveRequestHandlers() { console.error(`Server function ${serverFnHash} failed:`, err); throw ev.error(500, 'Invalid request'); } + throwIfControlFlowSignal(result); if (isAsyncIterator(result)) { ev.headers.set('Content-Type', 'text/qwik-json-stream'); const writable = ev.getWritableStream(); diff --git a/packages/qwik-router/src/middleware/request-handler/server-error.ts b/packages/qwik-router/src/middleware/request-handler/server-error.ts index 36520c0e71b..93b830d2e8f 100644 --- a/packages/qwik-router/src/middleware/request-handler/server-error.ts +++ b/packages/qwik-router/src/middleware/request-handler/server-error.ts @@ -1,3 +1,5 @@ +import { AbortMessage } from './redirect-handler'; + /** @public */ export class ServerError extends Error { constructor( @@ -7,3 +9,13 @@ export class ServerError extends Error { super(typeof data === 'string' ? data : undefined); } } + +/** + * `ev.redirect()`, `ev.error()`, etc. return a control-flow signal meant to be thrown. Throw it for + * the user when they return it instead, so returning and throwing behave the same. + */ +export const throwIfControlFlowSignal = (value: unknown): void => { + if (value instanceof AbortMessage || value instanceof ServerError) { + throw value; + } +}; diff --git a/packages/qwik-router/src/middleware/request-handler/server-error.unit.ts b/packages/qwik-router/src/middleware/request-handler/server-error.unit.ts new file mode 100644 index 00000000000..d5e72885232 --- /dev/null +++ b/packages/qwik-router/src/middleware/request-handler/server-error.unit.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { RedirectMessage } from './redirect-handler'; +import { RewriteMessage } from './rewrite-handler'; +import { ServerError, throwIfControlFlowSignal } from './server-error'; + +describe('throwIfControlFlowSignal', () => { + it('throws control-flow signals so returning behaves like throwing', () => { + const redirect = new RedirectMessage(); + expect(() => throwIfControlFlowSignal(redirect)).toThrow(redirect); + expect(() => throwIfControlFlowSignal(new RewriteMessage('/x'))).toThrow(RewriteMessage); + const error = new ServerError(404, 'nope'); + expect(() => throwIfControlFlowSignal(error)).toThrow(error); + }); + + it('passes plain data through untouched', () => { + for (const value of [undefined, null, 0, 'data', { ok: true }, [1, 2]]) { + expect(() => throwIfControlFlowSignal(value)).not.toThrow(); + } + }); +}); diff --git a/packages/qwik-router/src/middleware/request-handler/types.ts b/packages/qwik-router/src/middleware/request-handler/types.ts index 3e027a822ac..0682d8f5a13 100644 --- a/packages/qwik-router/src/middleware/request-handler/types.ts +++ b/packages/qwik-router/src/middleware/request-handler/types.ts @@ -62,7 +62,7 @@ export interface ServerRenderOptions extends RenderOptions { /** @public */ export type RequestHandler = ( ev: RequestEvent -) => Promise | void; +) => Promise | void | AbortMessage | ServerError; /** * Internal JSON request kind handled by Qwik Router, or `false` for normal page requests. diff --git a/packages/qwik-router/src/runtime/src/index.ts b/packages/qwik-router/src/runtime/src/index.ts index d8b02bfa808..d8de8bbaf0a 100644 --- a/packages/qwik-router/src/runtime/src/index.ts +++ b/packages/qwik-router/src/runtime/src/index.ts @@ -21,6 +21,7 @@ export type { DocumentMeta, DocumentScript, DocumentStyle, + ExcludeControlFlow, FailReturn, HttpStatus as HttpErrorProps, InternalRequest, diff --git a/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md b/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md index 3c5e07a5053..6aed89f0058 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md +++ b/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md @@ -4,6 +4,7 @@ ```ts +import type { AbortMessage } from '@qwik.dev/router/middleware/request-handler'; import type { AsyncSignal } from '@qwik.dev/core'; import { Component } from '@qwik.dev/core'; import { Cookie } from '@qwik.dev/router/middleware/request-handler'; @@ -28,6 +29,7 @@ import { RequestEventLoader } from '@qwik.dev/router/middleware/request-handler' import { RequestHandler } from '@qwik.dev/router/middleware/request-handler'; import type { ResolveSyncValue } from '@qwik.dev/router/middleware/request-handler'; import type { SerializationStrategy } from '@qwik.dev/core/internal'; +import type { ServerError } from '@qwik.dev/router/middleware/request-handler'; import type { Signal } from '@qwik.dev/core'; import type * as v from 'valibot'; import type { ValueOrPromise } from '@qwik.dev/core'; @@ -37,7 +39,7 @@ import type * as z_2 from 'zod'; // @public (undocumented) export type Action, OPTIONAL extends boolean = true> = { - (): ActionStore; + (): ActionStore, INPUT, OPTIONAL>; }; // @public (undocumented) @@ -193,6 +195,9 @@ export type DocumentStyle = Readonly<((Omit; +// @public +export type ExcludeControlFlow = Exclude; + // @public (undocumented) export type FailOfRest = REST extends readonly DataValidator[] ? ERROR : never; @@ -285,7 +290,7 @@ export interface LinkProps extends AnchorAttributes { // @public (undocumented) type Loader_2 = { - (): LoaderSignal; + (): LoaderSignal>; }; export { Loader_2 as Loader } diff --git a/packages/qwik-router/src/runtime/src/route-loaders.ts b/packages/qwik-router/src/runtime/src/route-loaders.ts index 060aefcf20a..f67384dbdf4 100644 --- a/packages/qwik-router/src/runtime/src/route-loaders.ts +++ b/packages/qwik-router/src/runtime/src/route-loaders.ts @@ -22,7 +22,10 @@ import type { import { _asyncRequestStore } from '../../middleware/request-handler/async-request-store'; import { getLoaderName } from '../../middleware/request-handler/request-path'; import { RedirectMessage } from '../../middleware/request-handler/redirect-handler'; -import { ServerError } from '../../middleware/request-handler/server-error'; +import { + ServerError, + throwIfControlFlowSignal, +} from '../../middleware/request-handler/server-error'; import { ensureSlash } from '../../utils/pathname'; import { DEFAULT_LOADERS_SERIALIZATION_STRATEGY } from './constants'; import { RouteLoaderCtxContext, RouteStateContext } from './contexts'; @@ -657,6 +660,7 @@ export const getRouteLoaderData = async ( loaderRequestEv ); const value = typeof resolved === 'function' ? resolved() : resolved; + throwIfControlFlowSignal(value); if (isDev) { verifySerializable(value, loaderQrl); } diff --git a/packages/qwik-router/src/runtime/src/types.ts b/packages/qwik-router/src/runtime/src/types.ts index 221878fca6c..a9ca5ddb2ad 100644 --- a/packages/qwik-router/src/runtime/src/types.ts +++ b/packages/qwik-router/src/runtime/src/types.ts @@ -9,6 +9,7 @@ import type { } from '@qwik.dev/core'; import type { SerializationStrategy } from '@qwik.dev/core/internal'; import type { + AbortMessage, EnvGetter, RequestEvent, RequestEventAction, @@ -16,6 +17,7 @@ import type { RequestEventLoader, RequestHandler, ResolveSyncValue, + ServerError, } from '@qwik.dev/router/middleware/request-handler'; import type * as v from 'valibot'; import type * as z from 'zod'; @@ -991,6 +993,14 @@ type Failed = { /** @public */ export type FailReturn = T & Failed; +/** + * Drops control-flow signals (`ev.redirect()`, `ev.error()`, etc.) from a loader/action return + * type: those are thrown, not surfaced as data. `ev.fail()` is plain data and is kept. + * + * @public + */ +export type ExcludeControlFlow = Exclude; + /** @public */ export type LoaderSignal = (TYPE extends () => ValueOrPromise ? Signal> @@ -1003,7 +1013,7 @@ export type Loader = { * Returns the `Signal` containing the data returned by the `loader$` function. Like all `use-` * functions and methods, it can only be invoked within a `component$()`. */ - (): LoaderSignal; + (): LoaderSignal>; }; export interface LoaderInternal extends Loader { @@ -1028,7 +1038,7 @@ export type Action, OPTIONAL extends boo * component$(). Like all `use-` functions and methods, it can only be invoked within a * `component$()`. */ - (): ActionStore; + (): ActionStore, INPUT, OPTIONAL>; }; export interface ActionInternal extends Action {