Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
228 changes: 228 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,226 @@ 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("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",
);
});
});
});
28 changes: 25 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,20 @@ 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();
navigateDocument(href, { replace: historyMode === "replace" });
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
Loading