Skip to content

Commit 085e16b

Browse files
Brooooooklynclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 940b46f commit 085e16b

7 files changed

Lines changed: 68 additions & 22 deletions

File tree

‎docs/guide/pages-routing/overview.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ import { useRouter } from '@void/vue'; // or "@void/react", "@void/svelte", "@vo
208208

209209
const router = useRouter();
210210
router.visit('/users');
211-
router.refresh(); // re-fetch current page props
211+
router.refresh(); // re-fetch current page props, keeping the same history entry
212212
router.visit('/logout', { method: 'POST' }); // non-GET navigation
213213
```
214214

‎packages/react/src/runtime/pages-client.ts‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
idleNavigationState,
2020
isAbortError,
2121
isCallSiteActionError,
22+
refreshPage,
2223
type PageNavigationResource,
2324
type PageNavigationResult,
2425
type NavigationState,
@@ -295,11 +296,7 @@ function createRouter(engine: Awaited<ReturnType<typeof createPageNavigationEngi
295296
return scheduleVisit(normalizeUrl(url), options);
296297
},
297298
refresh(options = {}) {
298-
return router.visit(window.location.pathname + window.location.search, {
299-
preserveState: true,
300-
preserveScroll: true,
301-
...options,
302-
});
299+
return refreshPage(router, options);
303300
},
304301
prefetch(url, options = {}) {
305302
return engine.prefetch(normalizeUrl(url), options);

‎packages/void/src/pages/client.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,21 @@ export function createSsrRouter(url = '/', params: Record<string, string> = {}):
219219

220220
export const ssrProxy: VoidRouter = createSsrRouter();
221221

222+
/** Refetch the current page in place: same history entry, URL hash, state and scroll. */
223+
export function refreshPage(
224+
router: Pick<VoidRouter, 'visit'>,
225+
options: VisitOptions = {},
226+
): Promise<unknown> {
227+
const { pathname, search, hash } = window.location;
228+
return router.visit(pathname + search + hash, {
229+
preserveState: true,
230+
preserveScroll: true,
231+
replace: true,
232+
...options,
233+
_refresh: true,
234+
});
235+
}
236+
222237
export async function submitAction(
223238
router: VoidRouter,
224239
url: string,
@@ -398,12 +413,7 @@ export async function createVoidRouter(config: VoidRouterConfig): Promise<{
398413
},
399414

400415
refresh(options = {}) {
401-
return router.visit(window.location.pathname + window.location.search, {
402-
preserveState: true,
403-
preserveScroll: true,
404-
replace: true,
405-
...options,
406-
});
416+
return refreshPage(router, options);
407417
},
408418

409419
prefetch(url, options = {}) {

‎packages/void/src/pages/navigation-engine.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export type VisitOptions = {
3838
_scrollPosition?: { x: number; y: number } | null;
3939
/** @internal Used by popstate handler to avoid writing history while traversing */
4040
_popstate?: boolean;
41+
/** @internal Used by router.refresh() to refetch the current URL even when it has a hash */
42+
_refresh?: boolean;
4143
};
4244

4345
export type PrefetchOptions = {
@@ -952,18 +954,14 @@ export async function createPageNavigationEngine(
952954
_pendingResource = null;
953955

954956
const isSamePageHashNavigation =
957+
!options._refresh &&
955958
effectiveFetchUrl === hashNavigationBaseUrl &&
956959
method === 'GET' &&
957960
!options.data &&
958961
Boolean(hash || options._popstate);
959962

960963
if (isSamePageHashNavigation) {
961-
commitHashNavigation(
962-
effectiveFetchUrl,
963-
hash,
964-
options,
965-
!options._popstate && !options.preserveState,
966-
);
964+
commitHashNavigation(effectiveFetchUrl, hash, options, !options._popstate);
967965
return {
968966
id,
969967
url,

‎packages/void/src/pages/scroll-restoration.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ export function createScrollRestoration(): ScrollRestoration {
9494
save,
9595

9696
writeHistory(url: string, replace: boolean): void {
97-
renderedKey = newKey();
97+
// A replaced entry keeps its key: its position is saved again when it is left.
98+
renderedKey = (replace ? readKey(history.state) : null) ?? newKey();
9899
const state = { ...(replace ? history.state : {}), __voidScrollKey: renderedKey };
99100
if (replace) {
100101
history.replaceState(state, '', url);

‎packages/void/test/unit/pages-client.test.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -689,7 +689,7 @@ describe('createVoidRouter', () => {
689689
expect(window.scrollTo).toHaveBeenCalledWith(0, 42);
690690
});
691691

692-
it('does not write history for same-page hash visits when state is preserved', async () => {
692+
it('writes history for same-page hash visits when state is preserved', async () => {
693693
stubClientGlobals();
694694
window.location.pathname = '/docs';
695695
window.location.search = '?tab=api';
@@ -702,7 +702,11 @@ describe('createVoidRouter', () => {
702702

703703
expect(fetch).not.toHaveBeenCalled();
704704
expect(applyUpdate).not.toHaveBeenCalled();
705-
expect(history.pushState).not.toHaveBeenCalled();
705+
expect(history.pushState).toHaveBeenCalledWith(
706+
expect.objectContaining({ __voidScrollKey: expect.any(String) }),
707+
'',
708+
'/docs?tab=api#section',
709+
);
706710
expect(history.replaceState).not.toHaveBeenCalled();
707711
expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
708712
});
@@ -1299,14 +1303,22 @@ describe('preserveState history updates (#506)', () => {
12991303
);
13001304
});
13011305

1302-
it('does not add a history entry on router.refresh()', async () => {
1306+
it('refetches the current URL in place on router.refresh(), hash included', async () => {
13031307
const { applyUpdate } = setup();
1308+
window.location.search = '?tab=a';
1309+
window.location.hash = '#top';
13041310
const r = await router(applyUpdate);
13051311

13061312
await r.refresh();
13071313

1314+
expect(fetch).toHaveBeenCalledWith('/case?tab=a', expect.anything());
13081315
expect(applyUpdate.mock.calls[0][3]).toEqual({ preserveState: true });
13091316
expect(history.pushState).not.toHaveBeenCalled();
1317+
expect(history.replaceState).toHaveBeenCalledWith(
1318+
expect.objectContaining({ __voidScrollKey: expect.any(String) }),
1319+
'',
1320+
'/case?tab=a#top',
1321+
);
13101322
});
13111323

13121324
it('does not write history for back/forward navigation', async () => {

‎packages/void/test/unit/pages-scroll.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,34 @@ describe('Pages scroll restoration', () => {
193193
expect(returned.window.scrollTo).toHaveBeenCalledExactlyOnceWith(0, 3500);
194194
});
195195

196+
it('keeps one saved position for an entry that is replaced again and again', async () => {
197+
const page = await setup();
198+
page.engine.start();
199+
page.engine.commitInitialRender();
200+
const state = structuredClone(page.history.state);
201+
vi.stubGlobal(
202+
'fetch',
203+
vi.fn(async () =>
204+
Response.json({ component: 'home', props: {} }, { headers: { 'X-VoidPages': 'true' } }),
205+
),
206+
);
207+
for (const scrollY of [100, 200, 300]) {
208+
page.window.scrollY = scrollY;
209+
const visit = page.engine.prepareVisit('/', { replace: true, preserveState: true });
210+
await visit.shell;
211+
await visit.commit();
212+
}
213+
expect(page.history.state).toEqual(state);
214+
expect(page.storage.size).toBe(1);
215+
216+
page.window.scrollY = 900;
217+
page.window.dispatchEvent(new Event('pagehide'));
218+
const returned = await setup(state, page.storage);
219+
returned.engine.start();
220+
returned.engine.commitInitialRender();
221+
expect(returned.window.scrollTo).toHaveBeenCalledExactlyOnceWith(0, 900);
222+
});
223+
196224
it('uses the latest in-memory position when a storage write fails', async () => {
197225
const page = await setup();
198226
page.engine.start();

0 commit comments

Comments
 (0)