Skip to content
Merged
2 changes: 1 addition & 1 deletion docs/guide/pages-routing/actions-and-forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ export const actions = {
};
```

Named actions are dispatched via a `?actionName` suffix on the URL (e.g. `/users/42?update`). The client primitives `useForm` and `action()` handle this automatically.
Named actions are dispatched via a `?actionName` suffix on the URL (e.g. `/users/42?update`). The client primitives `useForm` and `action()` handle this automatically. The action key stays out of the page URL. When `POST /users/42?update&tab=profile` renders the page again, `useForm` and `action()` leave the address bar at `/users/42?tab=profile`, and `c.redirect('/users/42')` goes to `/users/42?tab=profile`.

Use `action` or `actions` in a file, not both.

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/api/rewrites.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ URL rewrites re-dispatch a request at a different internal path without changing

Re-dispatches the current request at `destination` and returns the resulting `Response`. The browser URL does not change — this is a server-side hop. If `destination` has no query string, the original request query is preserved. If `destination` includes a query string, it replaces the original query. `destination` must start with `/`; known generated routes autocomplete via [`RewriteDestination`](#rewritedestination), while arbitrary strings remain accepted for dynamic paths.

`c.redirect()` also preserves request query params for internal destinations, but it merges rather than replaces: destination params win on collision, and request-only params are appended. Inside a [named action](../../guide/pages-routing/actions-and-forms.md#named-actions), the action key (`?delete` in `POST /users/42?delete`) is not carried over.
`c.redirect()` also preserves request query params for internal destinations, but it merges rather than replaces: destination params win on collision, and request-only params are appended. On a request to a [named action](../../guide/pages-routing/actions-and-forms.md#named-actions), the action key (`?delete` in `POST /users/42?delete`) is not carried over, whether the action or middleware redirects. A redirect after which the browser sends the same request again keeps it: `307` and `308`, and `301` or `302` after a `PUT`, `PATCH` or `DELETE`.

```ts
import { defineHandler } from 'void';
Expand Down
28 changes: 28 additions & 0 deletions packages/void/src/pages/action-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// On a page with named actions, the first query key selects the action
// (`/books?delete&page=2`). It addresses the action, not the page, so the page
// URL, history and c.redirect() leave it out.
const actionKeyRequests = new WeakSet<Request>();

export function markActionKey(request: Request): void {
actionKeyRequests.add(request);
}

export function hasActionKey(request: Request): boolean {
return actionKeyRequests.has(request);
}

/** `search` without its first non-empty segment; the rest is kept as sent. */
export function searchWithoutActionKey(search: string): string {
let start = 1;
while (search[start] === '&') {
start++;
}
let rest = search.indexOf('&', start);
if (rest === -1) {
return '';
}
while (search[rest] === '&') {
rest++;
}
return rest < search.length ? '?' + search.slice(rest) : '';
}
18 changes: 18 additions & 0 deletions packages/void/src/pages/navigation-engine.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/// <reference lib="dom" />

import { searchWithoutActionKey } from './action-key.ts';
import { createActionErrorFromResponse, isAbortError } from './action-result.ts';
import { initClientHead, applyClientHead } from './head-client.ts';
import type { HeadDescriptor } from './head.ts';
Expand All @@ -9,6 +10,22 @@ import type { PageObject } from './protocol.ts';
import { createScrollRestoration } from './scroll-restoration.ts';
import { isSameOrigin } from './url.ts';

/**
* The server leaves a named action's key out of the page URL. When the page URL
* shows that, drop the key from the URL written to history as well.
*/
function withoutActionKey(fetchUrl: string, pageUrl: string): string {
const q = fetchUrl.indexOf('?');
if (q === -1) {
return fetchUrl;
}
const search = searchWithoutActionKey(fetchUrl.slice(q));
const pageQ = pageUrl.indexOf('?');
return search === (pageQ === -1 ? '' : pageUrl.slice(pageQ))
? fetchUrl.slice(0, q) + search
: fetchUrl;
}

export type PageComponentModule = {
default: unknown;
};
Expand Down Expand Up @@ -809,6 +826,7 @@ export async function createPageNavigationEngine(

if (method !== 'GET') {
deferredRegistry.preserveValuesFrom(_committedDeferredRegistry, pageData);
commitTarget.fetchUrl = withoutActionKey(fetchUrl, pageData.url);
}

const [component, layouts] = await Promise.race([
Expand Down
5 changes: 4 additions & 1 deletion packages/void/src/pages/protocol.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CloudBindings, CloudContext } from '../runtime/handler.ts';
import { isConvertedResponse } from '../runtime/response.ts';
import { hasActionKey, searchWithoutActionKey } from './action-key.ts';
import { DEFERRED, separateDeferred, type ResolvedDeferredGroup } from './defer.ts';
import {
resolveHead,
Expand Down Expand Up @@ -611,7 +612,9 @@ export async function handlePageMutation(
props,
params: requestParams(c),
shared,
url: requestPathWithSearch(c),
url: hasActionKey(c.req.raw)
? c.req.path + searchWithoutActionKey(new URL(c.req.url).search)
: requestPathWithSearch(c),
errors,
...(head && { head }),
};
Expand Down
1 change: 1 addition & 0 deletions packages/void/src/plugin-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug
const runtimeEmailPath = resolveFile('#self/email');
const compileOptions: CompileOptions = {
honoPath: resolveFile('hono'),
honoRoutePath: resolveFile('hono/route'),
runtimeEnvPath: resolveFile('#self/_env'),
emailDir,
projectRoot: root,
Expand Down
41 changes: 26 additions & 15 deletions packages/void/src/router/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export type AuthCompileOptions = {
export type CompileOptions = {
/** Absolute path to the hono module entry */
honoPath: string;
/** Absolute path to `hono/route` from the same Hono copy as `honoPath` */
honoRoutePath: string;
/** Absolute path to the void/_env module */
runtimeEnvPath: string;
/** Absolute path to the project's `email/` directory; enables `__emailHandler`. */
Expand Down Expand Up @@ -154,7 +156,7 @@ export function compileRouteTable(
lines.push(`import { AsyncLocalStorage } from "node:async_hooks";`);
lines.push(`import { Hono } from ${JSON.stringify(options.honoPath)};`);
lines.push(
`import { createRoutingRuntime as __createRoutingRuntime, createHttpRouteHandler as __createHttpRouteHandler, createErrorRenderer as __createErrorRenderer, createPageGetHandler as __createPageGetHandler, createPageMutationHandler as __createPageMutationHandler, createPagesFallback as __createPagesFallback, createModuleLoader as __createModuleLoader, createClientAssetTags as __createClientAssetTags, createManifestLoader as __createManifestLoader, createSsrFallback as __createSsrFallback, createAssetNotFound as __createAssetNotFound } from ${JSON.stringify(ROUTING_RUNTIME_PATH)};`,
`import { createRoutingRuntime as __createRoutingRuntime, createHttpRouteHandler as __createHttpRouteHandler, createErrorRenderer as __createErrorRenderer, createPageGetHandler as __createPageGetHandler, createPageMutationHandler as __createPageMutationHandler, createNamedActionMarker as __createNamedActionMarker, markRouteHandler as __markRouteHandler, createPagesFallback as __createPagesFallback, createModuleLoader as __createModuleLoader, createClientAssetTags as __createClientAssetTags, createManifestLoader as __createManifestLoader, createSsrFallback as __createSsrFallback, createAssetNotFound as __createAssetNotFound } from ${JSON.stringify(ROUTING_RUNTIME_PATH)};`,
);
if (options.nodeTarget) {
lines.push(`const withRuntimeEnv = (_env, fn) => fn();`);
Expand Down Expand Up @@ -261,6 +263,15 @@ export function compileRouteTable(
compileHeaderMiddleware(lines, options.headerRules);
}

// After routing rules, which ignore the action key as platform dispatch does,
// and before auth and user middleware, so their c.redirect() drops it too.
if (pages && scan.pages.pages.some((page) => page.methods.includes('actions'))) {
lines.push(
`import { matchedRoutes as __matchedRoutes } from ${JSON.stringify(options.honoRoutePath)};`,
);
lines.push('app.use(__createNamedActionMarker(__matchedRoutes));');
}

if (auth) {
lines.push('app.use(async (c, next) => {');
lines.push(' return runVoidAuthMiddleware(c, next, __voidAuthOptions, __voidAuthDatabase);');
Expand Down Expand Up @@ -296,13 +307,13 @@ export function compileRouteTable(
}

if (auth) {
lines.push('app.all("/api/auth", async (c) => {');
lines.push('app.all("/api/auth", __markRouteHandler(async (c) => {');
lines.push(' return handleVoidAuthRequest(c, __voidAuthOptions, __voidAuthDatabase);');
lines.push('});');
lines.push('}));');
lines.push('');
lines.push('app.all("/api/auth/*", async (c) => {');
lines.push('app.all("/api/auth/*", __markRouteHandler(async (c) => {');
lines.push(' return handleVoidAuthRequest(c, __voidAuthOptions, __voidAuthDatabase);');
lines.push('});');
lines.push('}));');
lines.push('');
}

Expand Down Expand Up @@ -390,7 +401,7 @@ export function compileRouteTable(
// Pages handlers win for their methods; upgrades were forwarded above.
for (let i = 0; i < webSockets.length; i++) {
lines.push(
`app.all(${JSON.stringify(webSockets[i].pattern)}, (c) => __forwardWebSocketRequest(c.req.raw, c.env, __voidWebSocketRoutes[${i}]));`,
`app.all(${JSON.stringify(webSockets[i].pattern)}, __markRouteHandler((c) => __forwardWebSocketRequest(c.req.raw, c.env, __voidWebSocketRoutes[${i}])));`,
);
}
if (webSockets.length > 0) {
Expand Down Expand Up @@ -491,7 +502,7 @@ export function compileRouteTable(
lines.push(' await scheduled(controller, c.env, c.executionCtx);');
lines.push(' return c.json({ ok: true });');
lines.push('};');
lines.push('app.post("/__void/scheduled", __scheduledHandler);');
lines.push('app.post("/__void/scheduled", __markRouteHandler(__scheduledHandler));');
lines.push('');
}

Expand Down Expand Up @@ -559,7 +570,7 @@ export function compileRouteTable(
lines.push(' const result = await __processQueue(payload, c.env);');
lines.push(' return c.json(result);');
lines.push('};');
lines.push('app.post("/__void/queue", __queueHandler);');
lines.push('app.post("/__void/queue", __markRouteHandler(__queueHandler));');
lines.push('');
}

Expand Down Expand Up @@ -705,20 +716,20 @@ export function compileRouteTable(
lines.push(' return null;');
lines.push('}');
lines.push(
`app.post("/__void/migrate", (c) => __voidCreateMigrationRequestHandler(${auth ? 'true' : 'false'}, false)(c.req.raw));`,
`app.post("/__void/migrate", __markRouteHandler((c) => __voidCreateMigrationRequestHandler(${auth ? 'true' : 'false'}, false)(c.req.raw)));`,
);
lines.push(
`app.get("/__void/migrate/capabilities", (c) => __voidCreateMigrationRequestHandler(${auth ? 'true' : 'false'}, true)(c.req.raw));`,
`app.get("/__void/migrate/capabilities", __markRouteHandler((c) => __voidCreateMigrationRequestHandler(${auth ? 'true' : 'false'}, true)(c.req.raw)));`,
);
lines.push(
'app.post("/__void/migrate/app", (c) => __voidCreateMigrationRequestHandler(false, true)(c.req.raw));',
'app.post("/__void/migrate/app", __markRouteHandler((c) => __voidCreateMigrationRequestHandler(false, true)(c.req.raw)));',
);
lines.push(
'app.get("/__void/migrate/app/attempt/:attemptId", (c) => __voidCreateMigrationRequestHandler(false, true)(c.req.raw));',
'app.get("/__void/migrate/app/attempt/:attemptId", __markRouteHandler((c) => __voidCreateMigrationRequestHandler(false, true)(c.req.raw)));',
);
if (auth) {
lines.push(
'app.post("/__void/migrate/auth", (c) => __voidCreateMigrationRequestHandler(true, true)(c.req.raw));',
'app.post("/__void/migrate/auth", __markRouteHandler((c) => __voidCreateMigrationRequestHandler(true, true)(c.req.raw)));',
);
}
lines.push('');
Expand All @@ -733,14 +744,14 @@ export function compileRouteTable(
`import { createBindingHandler as __createBindingHandler } from ${JSON.stringify(options.bindingHandlerPath)};`,
);
lines.push('let __voidBindingApp;');
lines.push('app.all("/__void/*", async (c) => {');
lines.push('app.all("/__void/*", __markRouteHandler(async (c) => {');
lines.push(
' if (!__voidBindingApp) __voidBindingApp = __createBindingHandler(__getRawEnv());',
);
lines.push(' const url = new URL(c.req.url);');
lines.push(' url.pathname = url.pathname.slice(7);');
lines.push(' return __voidBindingApp.fetch(new Request(url.toString(), c.req.raw));');
lines.push('});');
lines.push('}));');
lines.push('');
}

Expand Down
54 changes: 41 additions & 13 deletions packages/void/src/runtime/routing-pages.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Context } from 'hono';
import type { Context, MiddlewareHandler } from 'hono';
import { markActionKey } from '../pages/action-key.ts';
import { handlePageGet, handlePageMutation } from '../pages/protocol.ts';
import type { ClientAssetTags } from './routing-assets.ts';
import type { ErrorRenderer } from './routing-errors.ts';
Expand All @@ -22,14 +23,39 @@ export type PageRouteOptions = {
renderOptions: PageGetArguments[7];
namedActions?: boolean;
};
// Requests whose first query key selected a named action. That key addresses
// the action, not the page, so c.redirect() must not carry it over.
const actionQueryRequests = new WeakSet<Request>();
export function hasActionQueryKey(request: Request): boolean {
return actionQueryRequests.has(request);
function actionNameOf(url: string): string | undefined {
return new URL(url).searchParams.keys().next().value;
}
const routeHandlers = new WeakSet<Function>();
const namedActionHandlers = new WeakSet<Function>();
/** Records a handler that answers a route, so the marker can tell it from middleware. */
export function markRouteHandler<T extends Function>(handler: T): T {
routeHandlers.add(handler);
return handler;
}
/**
* Runs before auth and user middleware, so a c.redirect() there leaves the
* action key out too. It marks the request only when a page with named actions
* is the route that will answer it, not when a broad page pattern also matches.
*
* `matchedRoutes` comes from 'hono/route' of the same Hono copy as the app: it
* reads the request through a Symbol that each copy defines for itself.
*/
export function createNamedActionMarker(
matchedRoutes: (c: Context) => Array<{ handler: Function }>,
): MiddlewareHandler {
return async (c, next) => {
const answer = matchedRoutes(c)
.slice(c.req.routeIndex + 1)
.find((route) => routeHandlers.has(route.handler));
Comment thread
Brooooooklyn marked this conversation as resolved.
if (answer && namedActionHandlers.has(answer.handler) && actionNameOf(c.req.url)) {
markActionKey(c.req.raw);
}
await next();
};
}
export function createPageGetHandler(options: PageRouteOptions): (c: Context) => Promise<Response> {
return async (context) => {
return markRouteHandler(async (context: Context) => {
const mod = options.load ? await options.load() : { head: options.head };
return handlePageGet(
mod.loader,
Expand All @@ -41,16 +67,14 @@ export function createPageGetHandler(options: PageRouteOptions): (c: Context) =>
options.headConfig ?? undefined,
options.renderOptions,
);
};
});
}
export function createPageMutationHandler(
options: PageRouteOptions,
): (c: Context) => Promise<Response> {
return async (context) => {
const mutationHandler = async (context: Context) => {
const mod = options.load ? await options.load() : {};
const actionName = options.namedActions
? new URL(context.req.url).searchParams.keys().next().value
: undefined;
const actionName = options.namedActions ? actionNameOf(context.req.url) : undefined;
// The name comes from the query string, so only own function properties
// count; `?constructor` or `?valueOf` must not reach Object.prototype.
const name = actionName || 'default';
Expand All @@ -67,7 +91,7 @@ export function createPageMutationHandler(
});
}
if (actionName) {
actionQueryRequests.add(context.req.raw);
markActionKey(context.req.raw);
}
return handlePageMutation(
handler,
Expand All @@ -81,6 +105,10 @@ export function createPageMutationHandler(
options.renderOptions,
);
};
if (options.namedActions) {
namedActionHandlers.add(mutationHandler);
}
return markRouteHandler(mutationHandler);
}

export function createPagesFallback(options: {
Expand Down
Loading
Loading