Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guide/pages-routing/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
7 changes: 2 additions & 5 deletions packages/react/src/runtime/pages-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
idleNavigationState,
isAbortError,
isCallSiteActionError,
refreshPage,
type PageNavigationResource,
type PageNavigationResult,
type NavigationState,
Expand Down Expand Up @@ -295,11 +296,7 @@ function createRouter(engine: Awaited<ReturnType<typeof createPageNavigationEngi
return scheduleVisit(normalizeUrl(url), options);
},
refresh(options = {}) {
return router.visit(window.location.pathname + window.location.search, {
preserveState: true,
preserveScroll: true,
...options,
});
return refreshPage(router, options);
},
prefetch(url, options = {}) {
return engine.prefetch(normalizeUrl(url), options);
Expand Down
21 changes: 16 additions & 5 deletions packages/void/src/pages/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,21 @@ export function createSsrRouter(url = '/', params: Record<string, string> = {}):

export const ssrProxy: VoidRouter = createSsrRouter();

/** Refetch the current page in place: same history entry, URL hash, state and scroll. */
export function refreshPage(
router: Pick<VoidRouter, 'visit'>,
options: VisitOptions = {},
): Promise<unknown> {
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,
Expand Down Expand Up @@ -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 = {}) {
Expand Down
13 changes: 6 additions & 7 deletions packages/void/src/pages/navigation-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion packages/void/src/pages/scroll-restoration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
105 changes: 103 additions & 2 deletions packages/void/test/unit/pages-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
});
Expand Down Expand Up @@ -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<typeof setup>['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();
});
});
28 changes: 28 additions & 0 deletions packages/void/test/unit/pages-scroll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading