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
14 changes: 14 additions & 0 deletions packages/react/src/runtime/pages-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,11 +487,25 @@ function App({
dispatch({ type: 'finish', resource, location: currentUrl() });
})
.catch((error) => {
// The history write failed, so the address bar still shows the committed page.
// End this visit and show that page again; a newer visit keeps its own state.
const resolveViewTransition = viewTransitionWaiters.get(resource.id);
if (resolveViewTransition) {
viewTransitionWaiters.delete(resource.id);
resolveViewTransition();
}
const visitSettler = pendingVisitSettlers.get(resource.id);
if (visitSettler) {
pendingVisitSettlers.delete(resource.id);
visitSettler.reject(error);
} else {
// A form visit has already settled with its action result.
reportError(error);
}
if (activePendingNavigationId === resource.id) {
activePendingNavigationId = null;
}
dispatch({ type: 'fail', resource, location: currentUrl() });
});
},
[],
Expand Down
54 changes: 53 additions & 1 deletion packages/react/test/integration/react-pages-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { execSync, type ChildProcess } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { type Browser, type Page } from 'playwright';
import { describe, expect, it, beforeAll, afterAll } from 'vite-plus/test';
import { describe, expect, it, beforeAll, afterAll, vi } from 'vite-plus/test';
import {
killChild,
spawnPlaygroundServer,
Expand Down Expand Up @@ -594,6 +594,58 @@ function registerClientTests(getBase: () => string, coverage: 'smoke' | 'full')
await page.context().close();
}
});
// The next pushState or replaceState call throws, like a browser that rate-limits history.
async function failNextHistoryWrite(page: Page, method: 'pushState' | 'replaceState') {
await page.evaluate((name) => {
const original = History.prototype[name];
History.prototype[name] = function () {
History.prototype[name] = original;
throw new DOMException('History blocked by test', 'SecurityError');
};
}, method);
}

async function waitForIdle(page: Page) {
await page.waitForFunction(
() => document.querySelector('[data-testid="navigation-state"]')?.textContent === 'idle',
);
}

for (const [link, method] of [
['onclick-link', 'pushState'],
['replace-link', 'replaceState'],
] as const) {
it(`${link} keeps the current page when ${method} fails`, async () => {
const page = await newPage();
const errors: Array<string> = [];
page.on('pageerror', (error) => {
errors.push(error.message);
});

try {
await page.goto(BASE + '/link-behavior');
await waitForHydration(page);
const heading = await page.textContent('h1');
await failNextHistoryWrite(page, method);

await page.click(`[data-testid="${link}"]`);
await vi.waitFor(() => expect(errors).toContain('History blocked by test'), {
timeout: 10_000,
});
await waitForIdle(page);
expect(await page.textContent('h1')).toBe(heading);
expect(page.url()).toBe(BASE + '/link-behavior');

await page.click(`[data-testid="${link}"]`);
await page.waitForURL(BASE + '/users');
await page.waitForFunction(() =>
document.querySelector('h1')?.textContent?.includes('Users'),
);
} finally {
await page.context().close();
}
});
}
}

describe('react-pages client (dev)', () => {
Expand Down
71 changes: 63 additions & 8 deletions packages/void/src/pages/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
createPageNavigationEngine,
type PageData,
type PageNavigationEngineConfig,
type PageNavigationResult,
type VisitOptions,
type VoidRouterAdapter as PageNavigationAdapter,
type PrefetchOptions,
Expand Down Expand Up @@ -335,6 +336,18 @@ export async function createVoidRouter(config: VoidRouterConfig): Promise<{
let routeUrl = currentUrl();
let routeParams = engine.initialPageData.params ?? {};
let activeNavigationId: number | null = null;
// The page in the address bar, and the page on screen. They differ while a visit has
// rendered but not committed, and after its commit fails.
type RenderedPage = Pick<PageNavigationResult, 'pageData' | 'component' | 'layouts'>;
let committedPage: RenderedPage = {
pageData: engine.initialPageData,
component: engine.initialComponent,
layouts: engine.initialLayouts,
};
let renderedPage = committedPage;
// Set when the page on screen is one whose commit failed; the last visit to settle puts
// the committed page back.
let failedRender: { preserveState: boolean } | null = null;
const setRouteState = (url: string, params: Record<string, string> = routeParams) => {
routeUrl = url;
routeParams = params;
Expand All @@ -358,10 +371,14 @@ export async function createVoidRouter(config: VoidRouterConfig): Promise<{
result.pageData.params ?? {},
);

const applyUpdate = () =>
config.adapter.applyUpdate(result.pageData, result.component, result.layouts, {
preserveState: resource.options.preserveState ?? resource.method !== 'GET',
const preserveState = resource.options.preserveState ?? resource.method !== 'GET';
const applyUpdate = () => {
renderedPage = result;
failedRender = null;
return config.adapter.applyUpdate(result.pageData, result.component, result.layouts, {
preserveState,
});
};

let applied = true;
const useTransition = resource.options.viewTransition ?? config.viewTransitions;
Expand All @@ -380,9 +397,24 @@ export async function createVoidRouter(config: VoidRouterConfig): Promise<{
}

// A newer visit can commit while this page is being applied; its URL stays.
if (!applied || !(await resource.commit())) {
let committed = false;
if (applied) {
try {
committed = await resource.commit();
} catch (error) {
if (renderedPage === result) {
failedRender = { preserveState };
}
throw error;
}
}
if (!committed) {
throw new DOMException('Navigation aborted', 'AbortError');
}
committedPage = result;
if (renderedPage === result) {
failedRender = null;
}
if (activeNavigationId === resource.id) {
setRouteState(currentUrl(), result.pageData.params ?? {});
}
Expand All @@ -397,10 +429,33 @@ export async function createVoidRouter(config: VoidRouterConfig): Promise<{
}
throw error;
} finally {
if (activeNavigationId === resource.id) {
activeNavigationId = null;
setRouteState(currentUrl());
setNavigation(idleNavigationState());
// A same-page hash visit can settle before this one, so no visit may be active.
const ownsNavigation = activeNavigationId === resource.id;
if (ownsNavigation || activeNavigationId === null) {
let restored = false;
if (failedRender && renderedPage !== committedPage) {
const { preserveState } = failedRender;
failedRender = null;
renderedPage = committedPage;
restored = true;
const page = committedPage;
try {
await config.adapter.applyUpdate(page.pageData, page.component, page.layouts, {
preserveState,
});
} catch (error) {
// The visit already rejects with the history error; keep that one.
reportError(error);
}
}
// A newer visit may have started while the committed page was applied.
if (ownsNavigation && activeNavigationId === resource.id) {
activeNavigationId = null;
setRouteState(currentUrl(), restored ? (committedPage.pageData.params ?? {}) : undefined);
setNavigation(idleNavigationState());
} else if (restored && activeNavigationId === null) {
setRouteState(currentUrl(), committedPage.pageData.params ?? {});
}
}
}
}
Expand Down
43 changes: 29 additions & 14 deletions packages/void/src/pages/navigation-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,10 +598,10 @@ export async function createPageNavigationEngine(
options: VisitOptions,
writeHistory: boolean,
): void {
_currentFetchUrl = effectiveFetchUrl;
if (writeHistory) {
scrollRestoration.writeHistory(effectiveFetchUrl + hash, !!options.replace);
}
_currentFetchUrl = effectiveFetchUrl;
scrollRestoration.commit();
restoreScroll(options._scrollPosition, hash);
}
Expand Down Expand Up @@ -864,6 +864,26 @@ export async function createPageNavigationEngine(
if (resourceId < _urlNavigationId) {
return false;
}
// Write history before anything else changes. Disposing the previous page and handing
// over its Deferred values cannot be undone, so a failed write must find them untouched.
// preserveState only controls component state; back/forward traversal already moved history.
if (!options._popstate) {
const historyUrl = commitTarget.fetchUrl + commitTarget.hash;
const resource = getResource();
const currentDocumentUrl = window.location.pathname + window.location.search;
const replaceHistory =
!!options.replace ||
(resource.method !== 'GET' && historyUrl === currentDocumentUrl + commitTarget.hash);
try {
scrollRestoration.writeHistory(historyUrl, replaceHistory);
} catch (error) {
if (_pendingResource?.id === resourceId) {
_pendingResource = null;
}
resource.dispose();
throw error;
}
}
_urlNavigationId = resourceId;
if (_pendingResource?.id === resourceId) {
_pendingResource = null;
Expand Down Expand Up @@ -910,17 +930,6 @@ export async function createPageNavigationEngine(
}
applyClientHead(result.pageData.head as HeadDescriptor | undefined);
_currentFetchUrl = commitTarget.fetchUrl;

// preserveState only controls component state; back/forward traversal already moved history.
if (!options._popstate) {
const historyUrl = commitTarget.fetchUrl + commitTarget.hash;
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();

requestAnimationFrame(() => {
Expand Down Expand Up @@ -989,7 +998,13 @@ export async function createPageNavigationEngine(
Boolean(hash || options._popstate);

if (isSamePageHashNavigation) {
commitHashNavigation(effectiveFetchUrl, hash, options, !options._popstate);
// A failed history write ends this visit like any other failed visit: through its shell.
let shell: Promise<undefined> = Promise.resolve(undefined);
try {
commitHashNavigation(effectiveFetchUrl, hash, options, !options._popstate);
} catch (error) {
shell = Promise.reject(error);
}
return {
id,
url,
Expand All @@ -998,7 +1013,7 @@ export async function createPageNavigationEngine(
method,
options,
immediate: true,
shell: Promise.resolve(undefined),
shell,
abort() {},
// Same-page hash visits commit right away. They do not stop an older visit
// whose page is already on screen from committing its URL.
Expand Down
6 changes: 4 additions & 2 deletions packages/void/src/pages/scroll-restoration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,15 @@ export function createScrollRestoration(): ScrollRestoration {

writeHistory(url: string, replace: boolean): void {
// 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 };
const key = (replace ? readKey(history.state) : null) ?? newKey();
const state = { ...(replace ? history.state : {}), __voidScrollKey: key };
if (replace) {
history.replaceState(state, '', url);
} else {
history.pushState(state, '', url);
}
// Only once the entry exists: if the write throws, positions keep the current entry's key.
renderedKey = key;
},

commit(): void {
Expand Down
Loading
Loading