From 83cb4c262b82a39ab224e792503b0108c6affd4d Mon Sep 17 00:00:00 2001 From: Da-Jin Chu Date: Fri, 18 Sep 2026 12:25:46 -0400 Subject: [PATCH] Fix history nav breaking after entering on a native page Snapshots clone the live head, which still holds the inline Turbolinks stub from the page the session started on. Only wrapped hydration marked existing head scripts as already run, so after a native entry the first restore visit re-executed the stub and reset window.Turbolinks. The next history navigation then failed with `historyPoppedToLocationWithRestorationIdentifier is not a function`. - Record existing head scripts on native hydration too. - Keep the tracked-script baseline separate from script recording, so a native page's first merge into a tracked wrapped page still never reloads. - Guard the inline stub so it never replaces a started Turbolinks. - Add e2e: native -> wrapped -> wrapped -> back -> forward -> back. Co-Authored-By: Claude Fable 5.1 --- bifrost/lib/turbolinks/mergeHead.ts | 16 +++++--- .../renderer/bifrost/onBeforeRenderClient.ts | 8 +++- bifrost/renderer/headHtmlBegin.ts | 2 +- .../renderer/wrapped/onBeforeRenderClient.ts | 2 + tests/e2e/specs/e2e.spec.ts | 37 +++++++++++++++++++ 5 files changed, 57 insertions(+), 8 deletions(-) diff --git a/bifrost/lib/turbolinks/mergeHead.ts b/bifrost/lib/turbolinks/mergeHead.ts index 8e7c685..01af97c 100644 --- a/bifrost/lib/turbolinks/mergeHead.ts +++ b/bifrost/lib/turbolinks/mergeHead.ts @@ -8,18 +8,22 @@ const allHeadScriptsEverRun: { [outerHTML: string]: ElementDetails } = {}; let firstMerge = true; let lastTrackedScriptSignature: string; -export const recordExistingHeadScripts = instrument("recordExistingHeadScripts", function recordExistingHeadScripts(categorizedHead?: CategorizedHead) { - categorizedHead ||= categorizeHead(document.head); - // record all existing head scripts as having been run, because they were run by browser, not mergeHead - for (const element of categorizedHead.scripts) { +// Scripts already in the document were run by the browser, so mergeHead must never run them again (e.g. when restoring a snapshot) +export const recordExistingHeadScripts = instrument("recordExistingHeadScripts", function recordExistingHeadScripts() { + for (const element of categorizeHead(document.head).scripts) { allHeadScriptsEverRun[element.outerHTML] = { tracked: elementIsTracked(element), }; } +}); + +// Only a wrapped SSR page defines the tracked-element baseline. A vite page has none, so its first merge into a wrapped page must never reload. +export function recordTrackedElementBaseline() { lastTrackedScriptSignature = - lastTrackedScriptSignature || trackedElementSignature(categorizedHead); + lastTrackedScriptSignature || + trackedElementSignature(categorizeHead(document.head)); firstMerge = false; -}); +} // Returns function which resolves when all new blocking head scripts have loaded export function mergeHead(head: HTMLHeadElement) { diff --git a/bifrost/renderer/bifrost/onBeforeRenderClient.ts b/bifrost/renderer/bifrost/onBeforeRenderClient.ts index c51cb8d..07a75e4 100644 --- a/bifrost/renderer/bifrost/onBeforeRenderClient.ts +++ b/bifrost/renderer/bifrost/onBeforeRenderClient.ts @@ -1,12 +1,18 @@ import { PageContextClient } from "vike/types"; import { Turbolinks } from "../../lib/turbolinks"; import { setBodyAttributes } from "../../lib/elementUtils"; +import { recordExistingHeadScripts } from "../../lib/turbolinks/mergeHead"; import { instrument } from "../../lib/diagnostic.client"; export default instrument("bifrostOnBeforeRenderClient", async function bifrostOnBeforeRenderClient( pageContext: PageContextClient ) { - if (!pageContext.isHydration && !pageContext.errorWhileRendering) { + if (pageContext.isHydration) { + // Snapshots clone the whole head, so anything already here (including the inline Turbolinks stub) would re-run on restore + recordExistingHeadScripts(); + return; + } + if (!pageContext.errorWhileRendering) { pageContext._shouldEmitBeforeRender = true; await Turbolinks._vikeBeforeRender(pageContext._turbolinksVisit); diff --git a/bifrost/renderer/headHtmlBegin.ts b/bifrost/renderer/headHtmlBegin.ts index 5013550..833b01b 100644 --- a/bifrost/renderer/headHtmlBegin.ts +++ b/bifrost/renderer/headHtmlBegin.ts @@ -2,7 +2,7 @@ // Register turbolinks global for ios to hook into // Also allows Turbolinks.visit / Turbolinks.controller.viewWillRender to be called before Turbolinks is started (Vike loads async) -const turbolinksIOSCompat = `window.Turbolinks = {controller:{restorationIdentifier: '',started:false,viewWillRender:(...a)=>{addEventListener("turbolinks:start", () => {window.Turbolinks.controller.viewWillRender(...a)})}},visit:(...a)=>{addEventListener("turbolinks:start", () => {window.Turbolinks.visit(...a)})}};`; +const turbolinksIOSCompat = `if(!(window.Turbolinks&&window.Turbolinks.controller&&window.Turbolinks.controller.started)){window.Turbolinks = {controller:{restorationIdentifier: '',started:false,viewWillRender:(...a)=>{addEventListener("turbolinks:start", () => {window.Turbolinks.controller.viewWillRender(...a)})}},visit:(...a)=>{addEventListener("turbolinks:start", () => {window.Turbolinks.visit(...a)})}}};`; /// emit turbolinks:load on DOMContentLoaded const turbolinksLoadEvent = `addEventListener("DOMContentLoaded", () => { diff --git a/bifrost/renderer/wrapped/onBeforeRenderClient.ts b/bifrost/renderer/wrapped/onBeforeRenderClient.ts index 3d1c2ca..7b328ff 100644 --- a/bifrost/renderer/wrapped/onBeforeRenderClient.ts +++ b/bifrost/renderer/wrapped/onBeforeRenderClient.ts @@ -4,6 +4,7 @@ import { Turbolinks } from "../../lib/turbolinks"; import { mergeHead, recordExistingHeadScripts, + recordTrackedElementBaseline, } from "../../lib/turbolinks/mergeHead"; import { setBodyAttributes, @@ -37,6 +38,7 @@ export default instrument("wrappedOnBeforeRenderClient", async function wrappedO proxyLayoutInfo: pageContext.proxyLayoutInfo, }); recordExistingHeadScripts(); + recordTrackedElementBaseline(); return; } diff --git a/tests/e2e/specs/e2e.spec.ts b/tests/e2e/specs/e2e.spec.ts index 0de8c6e..2df0a59 100644 --- a/tests/e2e/specs/e2e.spec.ts +++ b/tests/e2e/specs/e2e.spec.ts @@ -1136,6 +1136,43 @@ test.describe("back button restoration", () => { await customProxy.goForward(); }); + test("history nav keeps working after entering on a vite page", async ({ + page, + }) => { + ensureAllNetworkSucceeds(page); + const first = { + title: "first page", + content: "first page body content", + links: [{ title: "second page", content: "second page body content" }], + }; + const customProxy = new CustomProxyPage(page, first); + + await page.goto("./vite-page", { waitUntil: "networkidle" }); + await waitForTurbolinksInit(page); + await ensureNoBrowserNavigation(page, async () => { + const loaded = waitForConsoleLog(page, (m) => m.text() === T.load); + await page.evaluate( + (path) => (window as any).Turbolinks.visit(path), + toPath(first) + ); + await loaded; + }); + customProxy.pageData = first; + await expect(page).toHaveTitle("first page"); + + await customProxy.clickLink("second page"); + + await ensureNoBrowserNavigation(page, async () => { + await customProxy.goBack(); + // restoring the snapshot must not re-run the vite page's inline Turbolinks stub + expect( + await page.evaluate("window.Turbolinks.controller.started") + ).toBe(true); + await customProxy.goForward(); + await customProxy.goBack(); + }); + }); + test("restores layout", async ({ page, context }) => { const customProxy = new CustomProxyPage(page, { title: "first page",