From 940b46f89136087dea6e3ae8da595fa9474b36d4 Mon Sep 17 00:00:00 2001 From: 0froq Date: Wed, 21 Oct 2026 13:01:12 +0800 Subject: [PATCH 1/2] fix(pages): commit the destination URL when navigating with preserveState preserveState now only controls component state preservation. Normal visits always write history (push, or replace with replace: true); back/forward traversal still skips history writes and router.refresh() replaces the current entry instead of adding one. Fixes #506 --- packages/void/src/pages/client.ts | 1 + packages/void/src/pages/navigation-engine.ts | 3 +- packages/void/test/unit/pages-client.test.ts | 89 ++++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/packages/void/src/pages/client.ts b/packages/void/src/pages/client.ts index 3f054abff..4ec355021 100644 --- a/packages/void/src/pages/client.ts +++ b/packages/void/src/pages/client.ts @@ -401,6 +401,7 @@ export async function createVoidRouter(config: VoidRouterConfig): Promise<{ return router.visit(window.location.pathname + window.location.search, { preserveState: true, preserveScroll: true, + replace: true, ...options, }); }, diff --git a/packages/void/src/pages/navigation-engine.ts b/packages/void/src/pages/navigation-engine.ts index 75672de65..0e2e1c4f5 100644 --- a/packages/void/src/pages/navigation-engine.ts +++ b/packages/void/src/pages/navigation-engine.ts @@ -887,7 +887,8 @@ export async function createPageNavigationEngine( applyClientHead(result.pageData.head as HeadDescriptor | undefined); _currentFetchUrl = commitTarget.fetchUrl; - if (!options.preserveState) { + // 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); } diff --git a/packages/void/test/unit/pages-client.test.ts b/packages/void/test/unit/pages-client.test.ts index e06437ee9..29c2a7ee6 100644 --- a/packages/void/test/unit/pages-client.test.ts +++ b/packages/void/test/unit/pages-client.test.ts @@ -1231,3 +1231,92 @@ describe('createVoidRouter other-origin visits', () => { expect(window.location.href).toBeUndefined(); }); }); + +describe('preserveState history updates (#506)', () => { + function setup() { + stubClientGlobals(); + window.location.pathname = '/case'; + const applyUpdate = vi.fn(); + vi.stubGlobal( + 'fetch', + vi.fn((url: string) => + Promise.resolve( + pageJson({ + component: url.startsWith('/settings') ? 'settings' : 'home', + props: {}, + url, + }), + ), + ), + ); + return { applyUpdate }; + } + + async function router(applyUpdate: ReturnType['applyUpdate']) { + const { router } = await createTestRouter({ + adapter: { applyUpdate }, + components: { home: loadComponent, settings: loadComponent }, + matchRoute: (url) => (url.startsWith('/settings') ? 'settings' : 'home'), + }); + vi.mocked(history.pushState).mockClear(); + vi.mocked(history.replaceState).mockClear(); + return router; + } + + it('pushes the destination URL while preserving state', async () => { + const { applyUpdate } = setup(); + const r = await router(applyUpdate); + + await r.visit('/settings', { preserveState: true }); + + expect(applyUpdate).toHaveBeenCalledWith( + expect.objectContaining({ component: 'settings' }), + expect.anything(), + expect.anything(), + { preserveState: true }, + ); + expect(history.pushState).toHaveBeenCalledTimes(1); + expect(history.pushState).toHaveBeenCalledWith( + expect.objectContaining({ __voidScrollKey: expect.any(String) }), + '', + '/settings', + ); + expect(history.replaceState).not.toHaveBeenCalled(); + }); + + it('replaces the current entry with replace: true while preserving state', async () => { + const { applyUpdate } = setup(); + const r = await router(applyUpdate); + + await r.visit('/settings?tab=a#top', { preserveState: true, replace: true }); + + expect(applyUpdate.mock.calls[0][3]).toEqual({ preserveState: true }); + expect(history.pushState).not.toHaveBeenCalled(); + expect(history.replaceState).toHaveBeenCalledWith( + expect.objectContaining({ __voidScrollKey: expect.any(String) }), + '', + '/settings?tab=a#top', + ); + }); + + it('does not add a history entry on router.refresh()', async () => { + const { applyUpdate } = setup(); + const r = await router(applyUpdate); + + await r.refresh(); + + expect(applyUpdate.mock.calls[0][3]).toEqual({ preserveState: true }); + expect(history.pushState).not.toHaveBeenCalled(); + }); + + it('does not write history for back/forward navigation', async () => { + const { applyUpdate } = setup(); + const r = await router(applyUpdate); + + await r.visit('/settings', { preserveState: true, _popstate: true, _scrollPosition: null }); + + expect(applyUpdate.mock.calls[0][3]).toEqual({ preserveState: true }); + expect(history.pushState).not.toHaveBeenCalled(); + expect(history.replaceState).not.toHaveBeenCalled(); + }); +}); From 085e16b3657cc33629aa21cc65621cf012bcbc70 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 21 Oct 2026 20:49:25 +0800 Subject: [PATCH 2/2] fix(pages): keep refresh in place and write preserveState hash visits - router.refresh() goes through one shared helper used by the core and React routers. React's copy still pushed a new history entry. - refresh() keeps the URL hash; it now replaces the entry, which dropped it. An internal _refresh flag stops it from being taken as a hash-only visit, so the page is still fetched. - Same-page hash visits write history with preserveState too, like other visits. - A replaced history entry keeps its scroll key, so repeated refreshes no longer add a saved position each time. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/pages-routing/overview.md | 2 +- packages/react/src/runtime/pages-client.ts | 7 ++--- packages/void/src/pages/client.ts | 22 +++++++++++---- packages/void/src/pages/navigation-engine.ts | 10 +++---- packages/void/src/pages/scroll-restoration.ts | 3 +- packages/void/test/unit/pages-client.test.ts | 18 ++++++++++-- packages/void/test/unit/pages-scroll.test.ts | 28 +++++++++++++++++++ 7 files changed, 68 insertions(+), 22 deletions(-) diff --git a/docs/guide/pages-routing/overview.md b/docs/guide/pages-routing/overview.md index 9dc1c7228..01fab3c9c 100644 --- a/docs/guide/pages-routing/overview.md +++ b/docs/guide/pages-routing/overview.md @@ -208,7 +208,7 @@ import { useRouter } from '@void/vue'; // or "@void/react", "@void/svelte", "@vo const router = useRouter(); router.visit('/users'); -router.refresh(); // re-fetch current page props +router.refresh(); // re-fetch current page props, keeping the same history entry router.visit('/logout', { method: 'POST' }); // non-GET navigation ``` diff --git a/packages/react/src/runtime/pages-client.ts b/packages/react/src/runtime/pages-client.ts index c93792a9b..97688b3a5 100644 --- a/packages/react/src/runtime/pages-client.ts +++ b/packages/react/src/runtime/pages-client.ts @@ -19,6 +19,7 @@ import { idleNavigationState, isAbortError, isCallSiteActionError, + refreshPage, type PageNavigationResource, type PageNavigationResult, type NavigationState, @@ -295,11 +296,7 @@ function createRouter(engine: Awaited = {}): export const ssrProxy: VoidRouter = createSsrRouter(); +/** Refetch the current page in place: same history entry, URL hash, state and scroll. */ +export function refreshPage( + router: Pick, + options: VisitOptions = {}, +): Promise { + const { pathname, search, hash } = window.location; + return router.visit(pathname + search + hash, { + preserveState: true, + preserveScroll: true, + replace: true, + ...options, + _refresh: true, + }); +} + export async function submitAction( router: VoidRouter, url: string, @@ -398,12 +413,7 @@ export async function createVoidRouter(config: VoidRouterConfig): Promise<{ }, refresh(options = {}) { - return router.visit(window.location.pathname + window.location.search, { - preserveState: true, - preserveScroll: true, - replace: true, - ...options, - }); + return refreshPage(router, options); }, prefetch(url, options = {}) { diff --git a/packages/void/src/pages/navigation-engine.ts b/packages/void/src/pages/navigation-engine.ts index 0e2e1c4f5..1640b3ac7 100644 --- a/packages/void/src/pages/navigation-engine.ts +++ b/packages/void/src/pages/navigation-engine.ts @@ -38,6 +38,8 @@ export type VisitOptions = { _scrollPosition?: { x: number; y: number } | null; /** @internal Used by popstate handler to avoid writing history while traversing */ _popstate?: boolean; + /** @internal Used by router.refresh() to refetch the current URL even when it has a hash */ + _refresh?: boolean; }; export type PrefetchOptions = { @@ -952,18 +954,14 @@ export async function createPageNavigationEngine( _pendingResource = null; const isSamePageHashNavigation = + !options._refresh && effectiveFetchUrl === hashNavigationBaseUrl && method === 'GET' && !options.data && Boolean(hash || options._popstate); if (isSamePageHashNavigation) { - commitHashNavigation( - effectiveFetchUrl, - hash, - options, - !options._popstate && !options.preserveState, - ); + commitHashNavigation(effectiveFetchUrl, hash, options, !options._popstate); return { id, url, diff --git a/packages/void/src/pages/scroll-restoration.ts b/packages/void/src/pages/scroll-restoration.ts index d124a115b..158814cd5 100644 --- a/packages/void/src/pages/scroll-restoration.ts +++ b/packages/void/src/pages/scroll-restoration.ts @@ -94,7 +94,8 @@ export function createScrollRestoration(): ScrollRestoration { save, writeHistory(url: string, replace: boolean): void { - renderedKey = newKey(); + // A replaced entry keeps its key: its position is saved again when it is left. + renderedKey = (replace ? readKey(history.state) : null) ?? newKey(); const state = { ...(replace ? history.state : {}), __voidScrollKey: renderedKey }; if (replace) { history.replaceState(state, '', url); diff --git a/packages/void/test/unit/pages-client.test.ts b/packages/void/test/unit/pages-client.test.ts index 29c2a7ee6..78cefb431 100644 --- a/packages/void/test/unit/pages-client.test.ts +++ b/packages/void/test/unit/pages-client.test.ts @@ -689,7 +689,7 @@ describe('createVoidRouter', () => { expect(window.scrollTo).toHaveBeenCalledWith(0, 42); }); - it('does not write history for same-page hash visits when state is preserved', async () => { + it('writes history for same-page hash visits when state is preserved', async () => { stubClientGlobals(); window.location.pathname = '/docs'; window.location.search = '?tab=api'; @@ -702,7 +702,11 @@ describe('createVoidRouter', () => { expect(fetch).not.toHaveBeenCalled(); expect(applyUpdate).not.toHaveBeenCalled(); - expect(history.pushState).not.toHaveBeenCalled(); + expect(history.pushState).toHaveBeenCalledWith( + expect.objectContaining({ __voidScrollKey: expect.any(String) }), + '', + '/docs?tab=api#section', + ); expect(history.replaceState).not.toHaveBeenCalled(); expect(window.scrollTo).toHaveBeenCalledWith(0, 0); }); @@ -1299,14 +1303,22 @@ describe('preserveState history updates (#506)', () => { ); }); - it('does not add a history entry on router.refresh()', async () => { + it('refetches the current URL in place on router.refresh(), hash included', async () => { const { applyUpdate } = setup(); + window.location.search = '?tab=a'; + window.location.hash = '#top'; const r = await router(applyUpdate); await r.refresh(); + expect(fetch).toHaveBeenCalledWith('/case?tab=a', expect.anything()); expect(applyUpdate.mock.calls[0][3]).toEqual({ preserveState: true }); expect(history.pushState).not.toHaveBeenCalled(); + expect(history.replaceState).toHaveBeenCalledWith( + expect.objectContaining({ __voidScrollKey: expect.any(String) }), + '', + '/case?tab=a#top', + ); }); it('does not write history for back/forward navigation', async () => { diff --git a/packages/void/test/unit/pages-scroll.test.ts b/packages/void/test/unit/pages-scroll.test.ts index ec05b4a59..b73fc3063 100644 --- a/packages/void/test/unit/pages-scroll.test.ts +++ b/packages/void/test/unit/pages-scroll.test.ts @@ -193,6 +193,34 @@ describe('Pages scroll restoration', () => { expect(returned.window.scrollTo).toHaveBeenCalledExactlyOnceWith(0, 3500); }); + it('keeps one saved position for an entry that is replaced again and again', async () => { + const page = await setup(); + page.engine.start(); + page.engine.commitInitialRender(); + const state = structuredClone(page.history.state); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ component: 'home', props: {} }, { headers: { 'X-VoidPages': 'true' } }), + ), + ); + for (const scrollY of [100, 200, 300]) { + page.window.scrollY = scrollY; + const visit = page.engine.prepareVisit('/', { replace: true, preserveState: true }); + await visit.shell; + await visit.commit(); + } + expect(page.history.state).toEqual(state); + expect(page.storage.size).toBe(1); + + page.window.scrollY = 900; + page.window.dispatchEvent(new Event('pagehide')); + const returned = await setup(state, page.storage); + returned.engine.start(); + returned.engine.commitInitialRender(); + expect(returned.window.scrollTo).toHaveBeenCalledExactlyOnceWith(0, 900); + }); + it('uses the latest in-memory position when a storage write fails', async () => { const page = await setup(); page.engine.start();