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 deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@
"test:all-runtimes": "deno task test:unit && deno task test:node && deno task test:bun",
"test:e2e": "deno task test:e2e:playwright",
"test:e2e:playwright": "PW_DISABLE_TS_ESM=1 npx playwright test --config=tests/e2e/playwright.config.cjs",
"test:e2e:rsc-browser": "deno task generate && DENO_TESTING=1 VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --no-check --allow-all tests/e2e/regressions/rsc-proxy-hydration.test.ts tests/e2e/regressions/2026-07-27-legacy-router-hydration.test.ts tests/e2e/regressions/2026-07-27-release-asset-page-island-hydration.test.ts --unstable-worker-options --unstable-net",
"test:e2e:rsc-browser": "deno task generate && DENO_TESTING=1 VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --no-check --allow-all tests/e2e/regressions/rsc-proxy-hydration.test.ts tests/e2e/regressions/2026-07-27-legacy-router-hydration.test.ts tests/e2e/regressions/2026-07-27-release-asset-page-island-hydration.test.ts tests/e2e/regressions/2026-08-14-server-layout-spa-fallback.test.ts --unstable-worker-options --unstable-net",
"test:e2e:binary": "deno task generate && deno test --allow-all tests/integration/compiled-binary-e2e.test.ts",
"test:e2e:binary:fresh": "deno task generate && VERYFRONT_BINARY_FRESH=1 deno test --allow-all tests/integration/compiled-binary-e2e.test.ts",
"test:e2e:templates": "deno run --allow-all scripts/test/template-runtime-e2e.ts",
Expand Down

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/html/hydration-script-builder/runtime/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export interface RuntimeLocation {
href: string;
reload(): void;
assign?(url: string): void;
replace?(url: string): void;
}

export interface RuntimeHistory {
Expand Down
249 changes: 249 additions & 0 deletions src/html/hydration-script-builder/runtime/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ interface RouterHarness {
document: RuntimeDocument;
headElements: RuntimeElement[];
setNextPageData(data: PageDataPayload): void;
/** URLs handed to location.replace (replacing document navigations). */
replacedHrefs(): string[];
/** router.params at the moment RouterProvider was built — what the new page renders with. */
renderedRouterParams(): Record<string, string> | null;
/** The `params` prop handed to the page component; must be normalized. */
Expand Down Expand Up @@ -222,6 +224,7 @@ function createRouterHarness(options: HarnessOptions = {}): RouterHarness {

let assignedHref: string | undefined;
let reloadCount = 0;
const replacedHrefs: string[] = [];
const historyCalls: HistoryCall[] = [];

const window = {
Expand All @@ -238,6 +241,9 @@ function createRouterHarness(options: HarnessOptions = {}): RouterHarness {
reload() {
reloadCount++;
},
replace(url: string) {
replacedHrefs.push(url);
},
},
history: {
pushState(_state: unknown, _unused: string, href: string) {
Expand Down Expand Up @@ -375,6 +381,7 @@ function createRouterHarness(options: HarnessOptions = {}): RouterHarness {
setNextPageData: (data) => {
nextPageData = data;
},
replacedHrefs: () => [...replacedHrefs],
renderedRouterParams: () => renderedRouterParams,
renderedPageParams: () => renderedPageParams,
reloads: () => reloadCount,
Expand Down Expand Up @@ -931,5 +938,247 @@ describe("hydration-script-builder/runtime/router", () => {

assertEquals(harness.window.location.href, "https://veryfront.test/server-only");
});

it("treats the fallback as designed behaviour, not a console error", async () => {
const errorLogs: unknown[][] = [];
const originalConsoleError = console.error;
console.error = (...args: unknown[]) => {
errorLogs.push(args);
};

try {
const harness = createRouterHarness();
harness.window.__veryfrontHydrationComplete?.();
harness.setNextPageData({ pagePath: "page", requiresFullDocumentNavigation: true });

await harness.runtime.navigateSPA("/server-only");

assertEquals(
harness.window.location.href,
"https://veryfront.test/server-only",
"fallback must still hand the route to the document loader",
);
assertEquals(errorLogs, [], "the designed fallback must not log a console error");
} finally {
console.error = originalConsoleError;
}
});

it("leaves the history entry to the document loader instead of pushing one first", async () => {
const harness = createRouterHarness();
harness.window.__veryfrontHydrationComplete?.();
harness.setNextPageData({ pagePath: "page", requiresFullDocumentNavigation: true });

await harness.runtime.navigateSPA("/server-only");

assertEquals(
harness.historyCalls,
[],
"pushState before a document navigation duplicates the history entry",
);
});

it("honours replace semantics when the fallback leaves the document", async () => {
const harness = createRouterHarness();
harness.window.__veryfrontHydrationComplete?.();
harness.setNextPageData({ pagePath: "page", requiresFullDocumentNavigation: true });

await harness.runtime.navigateSPA("/server-only", "replace");

assertEquals(
harness.replacedHrefs(),
["https://veryfront.test/server-only"],
"replace-mode navigation must replace the current history entry",
);
assertEquals(
harness.window.location.href,
"https://veryfront.test/",
"an href assignment would add a Back-reachable entry for the replaced page",
);
});

it("replaces the document when a no-state popstate lands on a server-layout route", async () => {
const harness = createRouterHarness({ pathname: "/server-only", search: "?tab=x" });
harness.window.__veryfrontHydrationComplete?.();
harness.setNextPageData({ pagePath: "page", requiresFullDocumentNavigation: true });

const popstate = harness.listeners.popstate?.[0];
if (!popstate) throw new Error("popstate handler was not registered");
await popstate({ state: null } as unknown as RuntimeEvent);

assertEquals(
harness.replacedHrefs(),
["https://veryfront.test/server-only"],
"history traversal is already on the target entry; the fallback must replace it",
);
assertEquals(
harness.window.location.href,
"https://veryfront.test/server-only?tab=x",
"an href assignment during popstate would push an extra history entry",
);
});

it("clears the navigation progress state before handing over to the document loader", async () => {
const harness = createRouterHarness();
harness.window.__veryfrontHydrationComplete?.();
harness.setNextPageData({ pagePath: "page", requiresFullDocumentNavigation: true });

await harness.runtime.navigateSPA("/server-only");

assertEquals(
harness.document.body.getAttribute("aria-busy"),
null,
"a cancelled unload (beforeunload) must not leave the page stuck aria-busy",
);
});

it("does not refetch a cached server-layout route while leaving the document", async () => {
const harness = createRouterHarness();
harness.window.__veryfrontHydrationComplete?.();
harness.setNextPageData({ pagePath: "page", requiresFullDocumentNavigation: true });

harness.router.prefetch("/server-only");
await flushUntil(() => harness.fetchCalls.length === 1);
// The fetch is recorded synchronously, but the cache is only populated
// once the stubbed response resolves — drain the microtask queue so the
// navigation below really starts from a cached payload.
for (let i = 0; i < 20; i++) await flushMicrotasks();

await harness.runtime.navigateSPA("/server-only");

assertEquals(
harness.window.location.href,
"https://veryfront.test/server-only",
"cached flag must still hand the route to the document loader",
);
assertEquals(
harness.fetchCalls.length,
1,
"a background refresh of a route we are leaving the document for is wasted work",
);
});
});

// Cross-cutting invariants of the navigation lifecycle: soft navigations stay
// inside the document and history mutates exactly once per navigation, while
// every path that leaves the SPA (redirects, server layouts) hands the
// history entry to the browser's document loader untouched.
describe("navigation contract", () => {
it("completes a soft navigation inside the current document without console errors", async () => {
const errorLogs: unknown[][] = [];
const originalConsoleError = console.error;
console.error = (...args: unknown[]) => {
errorLogs.push(args);
};

try {
const harness = createRouterHarness();
harness.window.__veryfrontHydrationComplete?.();
harness.setNextPageData({ pagePath: "page", params: {} });

await harness.runtime.navigateSPA("/next");

assertEquals(harness.router.pathname, "/next", "the SPA router must own the route");
assertEquals(
harness.historyCalls,
[{ method: "push", href: "/next" }],
"one soft navigation must mutate history exactly once",
);
assertEquals(harness.reloads(), 0, "a soft navigation must not reload the document");
assertEquals(
harness.window.location.href,
"https://veryfront.test/",
"a soft navigation must not tear down the document",
);
assertEquals(errorLogs, [], "a successful navigation must stay silent on console.error");
} finally {
console.error = originalConsoleError;
}
});

it("records a replace instead of a push when requested", async () => {
const harness = createRouterHarness();
harness.window.__veryfrontHydrationComplete?.();
harness.setNextPageData({ pagePath: "page", params: {} });

await harness.runtime.navigateSPA("/swapped", "replace");

assertEquals(
harness.historyCalls,
[{ method: "replace", href: "/swapped" }],
"replace-style navigation must not grow the history stack",
);
});

it("renders from prefetched page data even when the network refresh hangs", async () => {
const harness = createRouterHarness({
fetchImpl: (_url, options) => {
if (options.headers?.["X-Veryfront-Prefetch"] === "1") {
return Promise.resolve(pageDataResponse("prefetched"));
}
// The stale-while-revalidate refresh never answers; navigation must
// not depend on it.
return new Promise<RuntimeResponse>(() => {});
},
});
harness.window.__veryfrontHydrationComplete?.();

harness.router.prefetch("/prefetched");
await flushUntil(() => harness.fetchCalls.length === 1);
for (let i = 0; i < 20; i++) await flushMicrotasks();

await harness.runtime.navigateSPA("/prefetched");

assertEquals(
harness.router.pathname,
"/prefetched",
"the cached payload alone must complete the navigation",
);
assertEquals(harness.reloads(), 0, "a cache-served navigation must not reload");
});

it("leaves history untouched when a redirect hands over to the document loader", async () => {
const harness = createRouterHarness();
harness.window.__veryfrontHydrationComplete?.();
harness.setNextPageData({ redirect: { destination: "/moved" } });

await harness.runtime.navigateSPA("/from");

assertEquals(harness.window.location.href, "https://veryfront.test/moved");
assertEquals(
harness.historyCalls,
[],
"the document loader owns the history entry for a redirect",
);
assertEquals(
harness.fetchCalls.length,
1,
"a redirect must resolve from a single page-data request",
);
});

it("restores a page from history state on popstate without a network request", async () => {
const harness = createRouterHarness({
pathname: "/posts/42",
hydrationParams: { id: "42" },
});
harness.window.__veryfrontHydrationComplete?.();

harness.window.location.pathname = "/posts/7";
const popstate = harness.listeners.popstate?.[0];
if (!popstate) throw new Error("popstate handler was not registered");
await popstate(
{
state: { pageData: { pagePath: "page", params: { id: "7" } } },
} as unknown as RuntimeEvent,
);

assertEquals(harness.router.params, { id: "7" });
assertEquals(
harness.fetchCalls,
[],
"history state already carries the page data; popstate must not refetch",
);
});
});
});
31 changes: 28 additions & 3 deletions src/html/hydration-script-builder/runtime/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,14 @@ export function createRouterRuntime(deps: RouterRuntimeDeps): RouterRuntime {
* navigation, reloads the current route instead — the user still escapes the
* broken SPA state, without the runtime executing a URL it could not vet.
*/
function navigateDocument(target: string): void {
function navigateDocument(target: string, options: { replace?: boolean } = {}): void {
const safeUrl = resolveDocumentNavigationUrl(target, window.location.origin);
if (safeUrl) {
window.location.href = safeUrl;
if (options.replace && window.location.replace) {
window.location.replace(safeUrl);
} else {
window.location.href = safeUrl;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return;
}

Expand Down Expand Up @@ -468,7 +472,11 @@ export function createRouterRuntime(deps: RouterRuntimeDeps): RouterRuntime {
const cached = getCachedPageData(path);
if (cached) {
log("Using cached page data:", path);
refreshPageDataInBackground(path);
// A route that leaves the SPA never renders this payload client-side,
// so refreshing it in the background is wasted work.
if (!cached.requiresFullDocumentNavigation) {
refreshPageDataInBackground(path);
}
emitRouteTiming("page-data", path, startedAt, { source: "cache" });
return cached;
}
Expand Down Expand Up @@ -562,6 +570,23 @@ export function createRouterRuntime(deps: RouterRuntimeDeps): RouterRuntime {
}
}

// A server-owned layout only exists in the document render, so the SPA
// cannot rebuild this route client-side. Handing it to the browser's
// document loader is the designed path for these routes, not a failure —
// and the loader owns the history entry, so nothing is pushed here.
if (pageData.requiresFullDocumentNavigation) {
log("Server layout requires a full document navigation:", href);
// Progress state is restored first: if the unload is cancelled (a
// beforeunload guard on the current page), the document stays alive
// and must not remain aria-busy behind a stuck progress bar.
hideNavigationProgress();
// Only an explicit push may grow the history stack. "replace" must
// replace, and "none" (popstate) is already on the target entry — an
// href assignment there would push a duplicate.
navigateDocument(href, { replace: historyMode !== "push" });
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

if (historyMode === "push") {
window.history.pushState({ pageData, scrollY: 0 }, "", href);
} else if (historyMode === "replace") {
Expand Down
Loading