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\nProperty\n\n\n | \n\nModifiers\n\n\n | \n\nType\n\n\n | \n\nDescription\n\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 |
\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)
append
@@ -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 {