Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
`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.

```ts
import { defineHandler } from 'void';
Expand Down
9 changes: 9 additions & 0 deletions packages/void/src/runtime/routing-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ 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);
}
export function createPageGetHandler(options: PageRouteOptions): (c: Context) => Promise<Response> {
return async (context) => {
const mod = options.load ? await options.load() : { head: options.head };
Expand Down Expand Up @@ -60,6 +66,9 @@ export function createPageMutationHandler(
status: 404,
});
}
if (actionName) {
actionQueryRequests.add(context.req.raw);
}
return handlePageMutation(
handler,
mod.loader,
Expand Down
23 changes: 22 additions & 1 deletion packages/void/src/runtime/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { RedirectStatusCode } from 'hono/utils/http-status';
import { VoidAssetRewriteError } from './handler.ts';
import { convertReturnValue } from './response.ts';
import { ASSET_EXTENSIONS } from './routing-assets.ts';
import { hasActionQueryKey } from './routing-pages.ts';
export { createErrorRenderer } from './routing-errors.ts';

export type RoutingRule = {
Expand Down Expand Up @@ -175,7 +176,7 @@ export function createRoutingRuntime(
const redirect = c.redirect.bind(c);
c.redirect = (location, status) => {
if (typeof location === 'string' && location.startsWith('/') && !location.startsWith('//')) {
return redirect(mergeQueryIntoLocation(location, new URL(c.req.raw.url).search), status);
return redirect(mergeQueryIntoLocation(location, redirectSearch(c.req.raw)), status);
}
return redirect(location, status);
};
Expand Down Expand Up @@ -245,6 +246,26 @@ export function createRoutingRuntime(
};
}

function redirectSearch(request: Request): string {
const search = new URL(request.url).search;
if (!hasActionQueryKey(request)) {
return search;
}
// The action key is the first non-empty segment; keep the rest as sent.
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) : '';
}

export function markNoMatch(request: Request, response: Response): Response {
const url = new URL(request.url);
if (
Expand Down
33 changes: 32 additions & 1 deletion packages/void/test/unit/routing-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Hono } from 'hono';
import { Hono, type Context } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { describe, expect, it, vi } from 'vite-plus/test';
import { createClientAssetTags, createManifestLoader } from '../../src/runtime/routing-assets.ts';
Expand Down Expand Up @@ -456,6 +456,37 @@ describe('typed Pages and SSR execution', () => {
expect(response.status).toBe(status);
}
});
it.each([
['/named?delete', '/named'],
['/named?delete&page=2', '/named?page=2'],
['/named?delete=1&page=2', '/named?page=2'],
['/named?&delete&page=2', '/named?page=2'],
['/named?delete&delete=x&page=2', '/named?delete=x&page=2'],
['/named?delete&q=a%20b&flag', '/named?q=a%20b&flag'],
['/single?page=2', '/done?page=2'],
])('drops only the named action key when c.redirect() carries %s', async (path, location) => {
const app = new Hono();
const runtime = createRoutingRuntime(app, { nodeTarget: true });
app.use(runtime.contextMiddleware);
const redirectTo = (location: string) => (c: Context) => c.redirect(location);
app.post(
'/named',
createPageMutationHandler({
...pageOptions,
load: async () => ({ actions: { delete: redirectTo('/named') } }),
namedActions: true,
}),
);
app.post(
'/single',
createPageMutationHandler({
...pageOptions,
load: async () => ({ action: redirectTo('/done') }),
}),
);
const response = await app.fetch(new Request('https://app.test' + path, { method: 'POST' }));
expect(response.headers.get('location')).toBe(location);
});
it('executes the real page loader and protocol', async () => {
const app = new Hono();
app.get(
Expand Down
Loading