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,11 +413,7 @@ export async function createVoidRouter(config: VoidRouterConfig): Promise<{ }, refresh(options = {}) { - return router.visit(window.location.pathname + window.location.search, { - preserveState: true, - preserveScroll: 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 75672de65..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 = { @@ -887,7 +889,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); } @@ -951,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 e06437ee9..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); }); @@ -1231,3 +1235,100 @@ 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('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 () => { + 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(); + }); +}); 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();