diff --git a/bun.lock b/bun.lock index 036efa1d..85c4171e 100644 --- a/bun.lock +++ b/bun.lock @@ -372,7 +372,7 @@ }, "packages/csr": { "name": "@wrnexus/csr", - "version": "0.8.42", + "version": "0.8.43", "dependencies": { "@wrnexus/core": "workspace:*", }, diff --git a/packages/csr/package.json b/packages/csr/package.json index a7f9fe53..4d455bb2 100644 --- a/packages/csr/package.json +++ b/packages/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.8.42", + "version": "0.8.43", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/src/nav-runtime.ts b/packages/csr/src/nav-runtime.ts index c89c1318..f43e5436 100644 --- a/packages/csr/src/nav-runtime.ts +++ b/packages/csr/src/nav-runtime.ts @@ -32,6 +32,21 @@ export const NAV_RUNTIME = String.raw` window.__wrnexusNavInstalled = true; + var navigationPolicy = null; + if (window.trustedTypes) { + try { + navigationPolicy = window.trustedTypes.createPolicy("wrnexus-navigation", { + createHTML: function (value) { return value; }, + createScriptURL: function (value) { return value; }, + }); + } catch (_) { + // A restrictive policy may disallow client navigation. Keep the native + // document navigation fallback without weakening the site's CSP. + } + } + + var renderedRoute = location.pathname + location.search; + var APP_ID = "app"; var inFlight = null; var memory = Object.create(null); @@ -341,7 +356,7 @@ export const NAV_RUNTIME = String.raw` ); }, ); - element.src = src; + element.src = navigationPolicy ? navigationPolicy.createScriptURL(src) : src; if (!script.hasAttribute("async")) { element.async = false; } @@ -518,7 +533,9 @@ export const NAV_RUNTIME = String.raw` } function hardNavigate(url) { - location.href = url; + var destination = new URL(url, location.href); + if (destination.href === location.href) location.reload(); + else location.href = destination.href; } function render(html, url, isPop) { @@ -526,7 +543,7 @@ export const NAV_RUNTIME = String.raw` try { doc = new DOMParser().parseFromString( - html, + navigationPolicy ? navigationPolicy.createHTML(html) : html, "text/html", ); } catch (_) { @@ -627,6 +644,8 @@ export const NAV_RUNTIME = String.raw` } + var renderedUrl = new URL(url, location.href); + renderedRoute = renderedUrl.pathname + renderedUrl.search; restorePage(url, isPop); dispatchNavigationEvent(url); @@ -737,6 +756,10 @@ export const NAV_RUNTIME = String.raw` window.addEventListener( "popstate", function () { + // Native fragment navigation also emits popstate. The document is + // already rendered: fetching it again loses form edits and can loop + // when a rejected render falls back to the same fragment URL. + if (location.pathname + location.search === renderedRoute) return; navigate(location.href, true); }, ); diff --git a/packages/csr/test/nav.test.ts b/packages/csr/test/nav.test.ts index 0c9c366f..d3a940ac 100644 --- a/packages/csr/test/nav.test.ts +++ b/packages/csr/test/nav.test.ts @@ -7,7 +7,7 @@ let win: any; let fetchCalls: { url: string; opts: any }[]; let nextHtml: string; -function install(bodyHtml: string): void { +function install(bodyHtml: string, configure?: (window: any) => void): void { win = new Window({ url: "https://example.test/" }); win.document.body.innerHTML = bodyHtml; fetchCalls = []; @@ -32,6 +32,7 @@ function install(bodyHtml: string): void { text: () => Promise.resolve(nextHtml), }); }; + configure?.(win); (0, eval)(NAV_RUNTIME); } @@ -71,6 +72,38 @@ test("intercepts an internal link click and swaps #app in place", async () => { expect(win.document.title).toBe("About"); }); +test("fragment history navigation preserves the page without fetching", async () => { + install('
'); + win.history.pushState({}, "", "/#setup-commands"); + win.dispatchEvent(new win.PopStateEvent("popstate")); + await flush(); + expect(fetchCalls).toHaveLength(0); + expect(win.document.querySelector("input").value).toBe("Unsaved draft"); +}); + +test("navigation supplies TrustedHTML to the document parser", async () => { + let parses = 0; + install('', (window) => { + window.trustedTypes = { createPolicy: (_name: string, rules: any) => ({ + createHTML: (value: string) => ({ trusted: true, value: rules.createHTML(value) }), + createScriptURL: rules.createScriptURL, + }) }; + const Parser = window.DOMParser; + (globalThis as any).DOMParser = class { + parseFromString(value: any, type: string) { + if (!value?.trusted) throw new TypeError("TrustedHTML required"); + parses++; + return new Parser().parseFromString(value.value, type); + } + }; + }); + nextHtml = '