diff --git a/packages/void/src/pages/protocol.ts b/packages/void/src/pages/protocol.ts index 912df0681..4bfc945f6 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,25 @@ 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 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 { @@ -908,9 +926,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..d28f308d7 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -1166,6 +1166,48 @@ 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', '/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, + }), + ); + 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(