From 7bcdbdfcc44a7d1e8d2bbd812950c959de4d4df4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 23 Oct 2026 02:49:56 +0000 Subject: [PATCH] fix(pages): replace history for same-page action re-renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #527 ## Summary Named actions no longer pollute history when they re-render the current page. #530 already removed `?actionName` from the committed URL; #531 still used `pushState` for every mutation, so each `useForm('/db?create')` / `action()` submit added another back-stack entry at `/db`. - Same-document mutations use `replaceState` instead of `pushState`. - `mutationCommitUrl()` prefers the server `pageData.url` when the action updates query params on the same path (e.g. `POST /books?delete` → `/books?page=2` still pushes once). ## Tests Updated POST history expectations to `replaceState` when the browser URL already matches the committed page URL. Added coverage for URL-changing POSTs and repeated same-page named-action submits. `vp test run packages/void/test/unit/pages-client.test.ts` — 69/69 passed. Co-authored-by: froQ --- packages/void/src/pages/navigation-engine.ts | 25 ++++++++++- packages/void/test/unit/pages-client.test.ts | 44 +++++++++++++++++++- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/void/src/pages/navigation-engine.ts b/packages/void/src/pages/navigation-engine.ts index 4499cc572..c8c5161ea 100644 --- a/packages/void/src/pages/navigation-engine.ts +++ b/packages/void/src/pages/navigation-engine.ts @@ -26,6 +26,22 @@ function withoutActionKey(fetchUrl: string, pageUrl: string): string { : fetchUrl; } +/** URL to commit after a page mutation (strip action keys, then prefer the server page URL). */ +function mutationCommitUrl(fetchUrl: string, pageUrl?: string): string { + if (!pageUrl) { + return withoutActionKey(fetchUrl, ''); + } + const withoutKey = withoutActionKey(fetchUrl, pageUrl); + if (withoutKey !== fetchUrl) { + return withoutKey; + } + const fetchPathEnd = fetchUrl.indexOf('?'); + const fetchPath = fetchPathEnd === -1 ? fetchUrl : fetchUrl.slice(0, fetchPathEnd); + const pagePathEnd = pageUrl.indexOf('?'); + const pagePath = pagePathEnd === -1 ? pageUrl : pageUrl.slice(0, pagePathEnd); + return fetchPath === pagePath ? pageUrl : fetchUrl; +} + export type PageComponentModule = { default: unknown; }; @@ -835,7 +851,7 @@ export async function createPageNavigationEngine( if (method !== 'GET') { deferredRegistry.preserveValuesFrom(_committedDeferredRegistry, pageData); - commitTarget.fetchUrl = withoutActionKey(fetchUrl, pageData.url); + commitTarget.fetchUrl = mutationCommitUrl(fetchUrl, pageData.url); } const [component, layouts] = await Promise.race([ @@ -923,7 +939,12 @@ export async function createPageNavigationEngine( // preserveState only controls component state; back/forward traversal already moved history. if (!options._popstate) { const historyUrl = commitTarget.fetchUrl + commitTarget.hash; - scrollRestoration.writeHistory(historyUrl, !!options.replace); + const resource = getResource(); + const currentDocumentUrl = window.location.pathname + window.location.search; + const replaceHistory = + !!options.replace || + (resource.method !== 'GET' && historyUrl === currentDocumentUrl + commitTarget.hash); + scrollRestoration.writeHistory(historyUrl, replaceHistory); } scrollRestoration.commit(); diff --git a/packages/void/test/unit/pages-client.test.ts b/packages/void/test/unit/pages-client.test.ts index f9a159836..457934860 100644 --- a/packages/void/test/unit/pages-client.test.ts +++ b/packages/void/test/unit/pages-client.test.ts @@ -514,8 +514,11 @@ describe('createVoidRouter', () => { ['/books?delete', '/books', '/books'], ['/books?page=2', '/books?page=2', '/books?page=2'], ['/books?a&b', '/books?a&b', '/books?a&b'], - ])('writes POST %s with page URL %s to history as %s', async (url, pageUrl, historyUrl) => { + ])('replaces history for POST %s with page URL %s as %s', async (url, pageUrl, historyUrl) => { stubClientGlobals(); + const historyLocation = new URL(historyUrl, 'http://localhost'); + window.location.pathname = historyLocation.pathname; + window.location.search = historyLocation.search; vi.stubGlobal( 'fetch', vi.fn(async () => pageJson({ component: 'home', props: {}, url: pageUrl, errors: null })), @@ -524,7 +527,44 @@ describe('createVoidRouter', () => { await router.visit(url, { method: 'POST' }); - expect(history.pushState).toHaveBeenCalledWith(expect.anything(), '', historyUrl); + expect(history.replaceState).toHaveBeenCalledWith(expect.anything(), '', historyUrl); + expect(history.pushState).not.toHaveBeenCalled(); + }); + + it('pushes history when a POST changes the page URL', async () => { + stubClientGlobals(); + window.location.pathname = '/books'; + window.location.search = ''; + vi.stubGlobal( + 'fetch', + vi.fn(async () => + pageJson({ component: 'home', props: {}, url: '/books?page=2', errors: null }), + ), + ); + const { router } = await createTestRouter(); + + await router.visit('/books?delete', { method: 'POST' }); + + expect(history.pushState).toHaveBeenCalledWith(expect.anything(), '', '/books?page=2'); + expect(history.replaceState).not.toHaveBeenCalled(); + }); + + it('does not grow history on repeated same-page named action submits', async () => { + stubClientGlobals(); + window.location.pathname = '/db'; + window.location.search = ''; + vi.stubGlobal( + 'fetch', + vi.fn(async () => pageJson({ component: 'home', props: {}, url: '/db', errors: null })), + ); + const { router } = await createTestRouter(); + + await router.visit('/db?create', { method: 'POST' }); + await router.visit('/db?create', { method: 'POST' }); + + expect(history.pushState).not.toHaveBeenCalled(); + expect(history.replaceState).toHaveBeenCalledTimes(2); + expect(history.replaceState).toHaveBeenLastCalledWith(expect.anything(), '', '/db'); }); it('runs navigation listeners for same-page hash visits', async () => {