Fix Trusted Types navigation and fragment history loops
This commit is contained in:
1 parent
1179fc2810
commit
5eae6f3b3c
4 files changed
+62
-6
No files matched your search
@@ -372,7 +372,7 @@
|
|||||||
},
|
},
|
||||||
"packages/csr": {
|
"packages/csr": {
|
||||||
"name": "@wrnexus/csr",
|
"name": "@wrnexus/csr",
|
||||||
"version": "0.8.42",
|
"version": "0.8.43",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/core": "workspace:*",
|
"@wrnexus/core": "workspace:*",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/csr",
|
"name": "@wrnexus/csr",
|
||||||
"version": "0.8.42",
|
"version": "0.8.43",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -32,6 +32,21 @@ export const NAV_RUNTIME = String.raw`
|
|||||||
|
|
||||||
window.__wrnexusNavInstalled = true;
|
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 APP_ID = "app";
|
||||||
var inFlight = null;
|
var inFlight = null;
|
||||||
var memory = Object.create(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")) {
|
if (!script.hasAttribute("async")) {
|
||||||
element.async = false;
|
element.async = false;
|
||||||
}
|
}
|
||||||
@@ -518,7 +533,9 @@ export const NAV_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hardNavigate(url) {
|
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) {
|
function render(html, url, isPop) {
|
||||||
@@ -526,7 +543,7 @@ export const NAV_RUNTIME = String.raw`
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
doc = new DOMParser().parseFromString(
|
doc = new DOMParser().parseFromString(
|
||||||
html,
|
navigationPolicy ? navigationPolicy.createHTML(html) : html,
|
||||||
"text/html",
|
"text/html",
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} 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);
|
restorePage(url, isPop);
|
||||||
|
|
||||||
dispatchNavigationEvent(url);
|
dispatchNavigationEvent(url);
|
||||||
@@ -737,6 +756,10 @@ export const NAV_RUNTIME = String.raw`
|
|||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
"popstate",
|
"popstate",
|
||||||
function () {
|
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);
|
navigate(location.href, true);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ let win: any;
|
|||||||
let fetchCalls: { url: string; opts: any }[];
|
let fetchCalls: { url: string; opts: any }[];
|
||||||
let nextHtml: string;
|
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 = new Window({ url: "https://example.test/" });
|
||||||
win.document.body.innerHTML = bodyHtml;
|
win.document.body.innerHTML = bodyHtml;
|
||||||
fetchCalls = [];
|
fetchCalls = [];
|
||||||
@@ -32,6 +32,7 @@ function install(bodyHtml: string): void {
|
|||||||
text: () => Promise.resolve(nextHtml),
|
text: () => Promise.resolve(nextHtml),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
configure?.(win);
|
||||||
(0, eval)(NAV_RUNTIME);
|
(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");
|
expect(win.document.title).toBe("About");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("fragment history navigation preserves the page without fetching", async () => {
|
||||||
|
install('<div id="app"><input value="Unsaved draft"></div>');
|
||||||
|
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('<div id="app"><a href="/next" id="lnk">Next</a></div>', (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 = '<html><body><div id="app">Next page</div></body></html>';
|
||||||
|
win.document.getElementById("lnk").click();
|
||||||
|
await flush();
|
||||||
|
expect(parses).toBe(1);
|
||||||
|
expect(win.document.getElementById("app").textContent).toBe("Next page");
|
||||||
|
});
|
||||||
|
|
||||||
test("navigation replaces document control registries before hydration", async () => {
|
test("navigation replaces document control registries before hydration", async () => {
|
||||||
install('<div id="app"><a href="/next" id="lnk">Next</a></div><script type="application/json" data-wrn-controls id="old">[]</script>');
|
install('<div id="app"><a href="/next" id="lnk">Next</a></div><script type="application/json" data-wrn-controls id="old">[]</script>');
|
||||||
nextHtml = '<html><body><div id="app">Next</div><script type="application/json" data-wrn-controls id="new">[[{"cond":null,"body":"Ready"}]]</script></body></html>';
|
nextHtml = '<html><body><div id="app">Next</div><script type="application/json" data-wrn-controls id="new">[[{"cond":null,"body":"Ready"}]]</script></body></html>';
|
||||||
|
|||||||
Reference in new issue
Block a user