From a5311d5542c44d18bd25c39b4af9b6003fbd04f6 Mon Sep 17 00:00:00 2001 From: karthic Date: Thu, 12 Nov 2026 11:08:50 +0530 Subject: [PATCH 1/2] fix(pages): report the requested URL in page data after a rewrite A rewrite serves another route without changing the browser URL, but the page data's `url` was built from the rewritten request, so `useRouter()` reported the destination. Build it from `c.originalUrl()` when the request was rewritten, for loads and action re-renders alike. Closes #798 Co-Authored-By: Claude Opus 5.5 --- packages/void/src/pages/protocol.ts | 17 ++++++--- packages/void/test/unit/pages-client.test.ts | 2 +- .../void/test/unit/routing-runtime.test.ts | 37 +++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/packages/void/src/pages/protocol.ts b/packages/void/src/pages/protocol.ts index 912df0681..1d9354b00 100644 --- a/packages/void/src/pages/protocol.ts +++ b/packages/void/src/pages/protocol.ts @@ -17,6 +17,7 @@ import { withLoaderNotFoundBoundary } from './not-found-context.ts'; import { escapeScriptJson } from './script-json.ts'; import { serializePageData } from './serialize.ts'; import type { DevCssAsset, PageRenderMode } from './types.ts'; +import { decodePagePath } from './url.ts'; import { ValidationError } from './validation-error.ts'; export { ValidationError }; @@ -299,8 +300,16 @@ function pageJsonResponse(c: CloudContext, pageObj: PageObject): Response { }); } -function requestPathWithSearch(c: CloudContext): string { - return c.req.path + new URL(c.req.url).search; +/** + * The URL in the address bar, which a rewrite keeps while it serves another + * route. The path is decoded like c.req.path. + */ +function requestPathWithSearch(c: CloudContext, withoutActionKey = false): string { + // Build-time renders run without the routing middleware that adds originalUrl. + const original = c.originalUrl?.(); + const path = original ? decodePagePath(original.pathname) : c.req.path; + const search = (original ?? new URL(c.req.url)).search; + return path + (withoutActionKey ? searchWithoutActionKey(search) : search); } function requestParams(c: CloudContext): Record { @@ -908,9 +917,7 @@ async function runPageMutation( props, params: requestParams(c), shared, - url: hasActionKey(c.req.raw) - ? c.req.path + searchWithoutActionKey(new URL(c.req.url).search) - : requestPathWithSearch(c), + url: requestPathWithSearch(c, hasActionKey(c.req.raw)), errors, ...(head && { head }), }; diff --git a/packages/void/test/unit/pages-client.test.ts b/packages/void/test/unit/pages-client.test.ts index 83154ee2d..6322fc026 100644 --- a/packages/void/test/unit/pages-client.test.ts +++ b/packages/void/test/unit/pages-client.test.ts @@ -1439,7 +1439,7 @@ describe('createVoidRouter', () => { const response = await app.fetch(new Request(new URL(url, window.location.origin), init)); expect(response.status).toBe(200); expect(decodePageData(await response.clone().json())).toMatchObject({ - url: '/books?category=internal', + url: '/books?category=public', }); return response; }), diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 10d922081..7e6e3e748 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -1166,6 +1166,43 @@ describe('typed Pages and SSR execution', () => { expect((decodePageData(await response.json()) as { url: string }).url).toBe('/named?page=2'); }); }); + it.each([ + ['middleware', 'GET', '/app/account?tab=1', '/app/account?tab=1'], + ['rule', 'GET', '/app/account?tab=1', '/app/account?tab=1'], + ['middleware', 'GET', '/app/caf%C3%A9', '/app/café'], + ['middleware', 'POST', '/app/account?save&tab=1', '/app/account?tab=1'], + ['rule', 'POST', '/app/account?save&tab=1', '/app/account?tab=1'], + ])('reports the requested URL after a %s rewrite of %s %s', async (kind, method, path, url) => { + const app = new Hono(); + const runtime = createRoutingRuntime(app, { nodeTarget: true }); + app.use(runtime.contextMiddleware); + app.use(createNamedActionMarker(matchedRoutes)); + if (kind === 'middleware') { + app.use('/app/*', (c) => (c as RoutingContext).rewrite('/maintenance')); + } else { + app.use( + runtime.routingMiddleware([ + { source: '/app/*', destination: '/maintenance', status: 200, force: true }, + ]), + ); + } + app.get('/maintenance', createPageGetHandler(pageOptions)); + app.post( + '/maintenance', + createPageMutationHandler({ + ...pageOptions, + load: async () => ({ actions: { save: () => undefined } }), + namedActions: true, + }), + ); + const response = await app.fetch( + new Request('https://app.test' + path, { + method, + headers: { 'x-voidpages': 'true', 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION }, + }), + ); + expect((decodePageData(await response.json()) as { url: string }).url).toBe(url); + }); it('executes the real page loader and protocol', async () => { const app = new Hono(); app.get( From a32774feb386c87145ec9a9c0b7b420141bed02d Mon Sep 17 00:00:00 2001 From: karthic Date: Thu, 12 Nov 2026 12:54:46 +0530 Subject: [PATCH 2/2] Keep the original query when a rewrite adds the action key. The action key belongs to the query the rewritten request carries. Strip it from the original URL's query only when the rewrite kept that query, so a destination with its own query no longer drops the visitor's first parameter. Co-Authored-By: Claude Opus 5.5 --- packages/void/src/pages/protocol.ts | 15 +++- .../void/test/unit/routing-runtime.test.ts | 75 ++++++++++--------- 2 files changed, 52 insertions(+), 38 deletions(-) diff --git a/packages/void/src/pages/protocol.ts b/packages/void/src/pages/protocol.ts index 1d9354b00..4bfc945f6 100644 --- a/packages/void/src/pages/protocol.ts +++ b/packages/void/src/pages/protocol.ts @@ -307,9 +307,18 @@ function pageJsonResponse(c: CloudContext, pageObj: PageObject): Response { function requestPathWithSearch(c: CloudContext, withoutActionKey = false): string { // Build-time renders run without the routing middleware that adds originalUrl. const original = c.originalUrl?.(); - const path = original ? decodePagePath(original.pathname) : c.req.path; - const search = (original ?? new URL(c.req.url)).search; - return path + (withoutActionKey ? searchWithoutActionKey(search) : search); + const requestSearch = new URL(c.req.url).search; + if (!original) { + return c.req.path + (withoutActionKey ? searchWithoutActionKey(requestSearch) : requestSearch); + } + // The action key is in the query this request carries. It is also in the + // original query only when the rewrite kept that query; a destination with + // its own query leaves the original's parameters as they were. + const search = + withoutActionKey && original.search === requestSearch + ? searchWithoutActionKey(original.search) + : original.search; + return decodePagePath(original.pathname) + search; } function requestParams(c: CloudContext): Record { diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 7e6e3e748..d28f308d7 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -1167,42 +1167,47 @@ describe('typed Pages and SSR execution', () => { }); }); it.each([ - ['middleware', 'GET', '/app/account?tab=1', '/app/account?tab=1'], - ['rule', 'GET', '/app/account?tab=1', '/app/account?tab=1'], - ['middleware', 'GET', '/app/caf%C3%A9', '/app/café'], - ['middleware', 'POST', '/app/account?save&tab=1', '/app/account?tab=1'], - ['rule', 'POST', '/app/account?save&tab=1', '/app/account?tab=1'], - ])('reports the requested URL after a %s rewrite of %s %s', async (kind, method, path, url) => { - const app = new Hono(); - const runtime = createRoutingRuntime(app, { nodeTarget: true }); - app.use(runtime.contextMiddleware); - app.use(createNamedActionMarker(matchedRoutes)); - if (kind === 'middleware') { - app.use('/app/*', (c) => (c as RoutingContext).rewrite('/maintenance')); - } else { - app.use( - runtime.routingMiddleware([ - { source: '/app/*', destination: '/maintenance', status: 200, force: true }, - ]), + ['middleware', 'GET', '/app/account?tab=1', '/maintenance', '/app/account?tab=1'], + ['rule', 'GET', '/app/account?tab=1', '/maintenance', '/app/account?tab=1'], + ['middleware', 'GET', '/app/caf%C3%A9', '/maintenance', '/app/café'], + ['middleware', 'GET', '/app/account?tab=1', '/maintenance?mode=read', '/app/account?tab=1'], + ['middleware', 'POST', '/app/account?save&tab=1', '/maintenance', '/app/account?tab=1'], + ['rule', 'POST', '/app/account?save&tab=1', '/maintenance', '/app/account?tab=1'], + // The destination's query selects the action; the original query has no key. + ['middleware', 'POST', '/app/account?tab=1', '/maintenance?save&tab=1', '/app/account?tab=1'], + ['rule', 'POST', '/app/account?tab=1', '/maintenance?save&tab=1', '/app/account?tab=1'], + ])( + 'reports the requested URL after a %s rewrite of %s %s to %s', + async (kind, method, path, destination, url) => { + const app = new Hono(); + const runtime = createRoutingRuntime(app, { nodeTarget: true }); + app.use(runtime.contextMiddleware); + app.use(createNamedActionMarker(matchedRoutes)); + if (kind === 'middleware') { + app.use('/app/*', (c) => (c as RoutingContext).rewrite(destination)); + } else { + app.use( + runtime.routingMiddleware([{ source: '/app/*', destination, status: 200, force: true }]), + ); + } + app.get('/maintenance', createPageGetHandler(pageOptions)); + app.post( + '/maintenance', + createPageMutationHandler({ + ...pageOptions, + load: async () => ({ actions: { save: () => undefined } }), + namedActions: true, + }), ); - } - app.get('/maintenance', createPageGetHandler(pageOptions)); - app.post( - '/maintenance', - createPageMutationHandler({ - ...pageOptions, - load: async () => ({ actions: { save: () => undefined } }), - namedActions: true, - }), - ); - const response = await app.fetch( - new Request('https://app.test' + path, { - method, - headers: { 'x-voidpages': 'true', 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION }, - }), - ); - expect((decodePageData(await response.json()) as { url: string }).url).toBe(url); - }); + const response = await app.fetch( + new Request('https://app.test' + path, { + method, + headers: { 'x-voidpages': 'true', 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION }, + }), + ); + expect((decodePageData(await response.json()) as { url: string }).url).toBe(url); + }, + ); it('executes the real page loader and protocol', async () => { const app = new Hono(); app.get(