From d078143210b85e7a1b61e9a96b0e9c92da8a31c5 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Tue, 24 Mar 2026 23:21:14 +0100 Subject: [PATCH 1/2] feat(pat-inject): Canonical and base url updates on navigation changes. When the navigation URL changes on injections with the ``history:record`` setting, also update the canonical url (````) and the base url (``URL``). For the canonical and base tags, the logic is as follows: - If present in source and target, update it in the target. - If present in source but not in target, add it to the target. - If present in target but not in source, remove it from the target. It's better to have no canonical or base tag than an incorrect one. For the title tag the logic is different, as the title is never removed from the target. It's better to have any title than none. --- src/pat/inject/inject.js | 109 +++- src/pat/inject/inject.test.js | 1090 +++++++++++++++++++++++++++++---- 2 files changed, 1062 insertions(+), 137 deletions(-) diff --git a/src/pat/inject/inject.js b/src/pat/inject/inject.js index 0e75b6873..08f75f6ad 100644 --- a/src/pat/inject/inject.js +++ b/src/pat/inject/inject.js @@ -463,7 +463,7 @@ const inject = { return $target; }, - _performInjection(target, $el, $sources, cfg, trigger, $title) { + _performInjection(target, $el, $sources, cfg, trigger, ev) { /* Called after the XHR has succeeded and we have a new $sources * element to inject. */ @@ -472,7 +472,9 @@ const inject = { const method = cfg.sourceMod === "content" ? "innerHTML" : "outerHTML"; // There might be multiple sources, so we need to loop over them. // Access them with "innerHTML" or "outerHTML" depending on the sourceMod. - const sources_string = [...$sources].map(source => source[method]).join("\n"); + const sources_string = [...$sources] + .map((source) => source[method]) + .join("\n"); wrapper.innerHTML = sources_string; for (const img of wrapper.content.querySelectorAll("img")) { @@ -494,32 +496,88 @@ const inject = { // Now the injection actually happens. if (this._inject(trigger, source_nodes, target, cfg)) { // Update history - this._update_history(cfg, trigger, $title); + this._update_history(cfg, ev); // Post-injection this._afterInjection($el, cfg.$created_target || $(source_nodes), cfg); } }, - _update_history(cfg, trigger, $title) { + _update_history(cfg, ev) { // History support. if subform is submitted, append form params if (cfg.history !== "record" || !history?.pushState) { return; } + + // We have a URL changing injection. We also need to update other data: + // - title + // - canonical link + // - base url + + const html = ev?.jqxhr?.responseText; + + // Update the document's title. + const source_title = html?.match(/]*>(.*?)<\/title>/i)?.[0]; + const target_title = document.querySelector("head title"); + // Title: update/add but never remove + this._update_head(source_title, target_title, false); + + // Update the element. + const source_canonical = html?.match( + /]*\brel\s*=\s*["']canonical["'][^>]*>/i + )?.[0]; + const target_canonical = document.querySelector("head link[rel=canonical]"); + // Canonical: update/add/remove as needed + this._update_head(source_canonical, target_canonical, true); + + // Update the base tag. + const source_base = html?.match(/]*>/i)?.[0]; + const target_base = document.querySelector("head base"); + // Base: update/add/remove as needed + this._update_head(source_base, target_base, true); + + // At last position - other patterns can react on already changed title, + // canonical or base. let url = cfg.url; if (cfg.params) { const glue = url.indexOf("?") > -1 ? "&" : "?"; url = `${url}${glue}${cfg.params}`; } history.pushState({ url: url }, "", url); - // Also inject title element if we have one - if ($title?.length) { - const title_el = document.querySelector("title"); - if (title_el) { - this._inject(trigger, $title, title_el, { - action: "element", - }); + }, + + _update_head(source_string, target_el, delete_when_empty = true) { + if (!source_string) { + // No source element found in the HTML + if (target_el && delete_when_empty) { + // Source doesn't have the target element equivalent. Remove the + // target. This prevents incorrect canonical and base URLs after a + // navigation URL change. + target_el.remove(); + } + // (If delete_when_empty is false, keep the target - used for title) + // (If no target_el exists, nothing to do) + return; + } + + const parser = new DOMParser(); + const parsed = parser.parseFromString(source_string, "text/html"); + // Head elements (title, link, base) are placed in parsed.head. + const source_el = parsed.head.children[0]; + + if (source_el) { + // Source is present - update or add + if (target_el) { + // Replace existing target element + target_el.replaceWith(source_el); + } else { + // Add new element to head + document.head.prepend(source_el); } + } else if (target_el && delete_when_empty) { + // Source string exists but doesn't parse to an element. Remove target. + target_el.remove(); } + // (If source doesn't parse to element AND no target, do nothing) }, _afterInjection($el, $injected, cfg) { @@ -592,17 +650,6 @@ const inject = { data, ev, ]); - /* pick the title source for dedicated handling later - Title - if present - is always appended at the end. */ - let $title; - if ( - sources$ && - sources$[sources$.length - 1] && - sources$[sources$.length - 1][0] && - sources$[sources$.length - 1][0].nodeName === "TITLE" - ) { - $title = sources$[sources$.length - 1]; - } for (const [idx1, cfg] of cfgs.entries()) { const perform_inject = () => { @@ -614,7 +661,7 @@ const inject = { sources$[idx1], cfg, ev.target, - $title + ev ); } } @@ -834,19 +881,20 @@ const inject = { _sourcesFromHtml(html, url, sources) { const $html = this._parseRawHtml(html, url); - return sources.map((source) => { + const $sources = sources.map((source) => { + // Special case for body if (source === "body") { source = "#__original_body"; } + + // Special case for "none"; if (source === "none") { return $(""); } const $source = $html.find(source); if ($source.length === 0) { - if (source != "title") { - log.warn("No source elements for selector:", source, $html); - } + log.warn("No source elements for selector:", source, $html); } $source.find('a[href^="#"]').each((idx, el_) => { @@ -868,6 +916,7 @@ const inject = { }); return $source; }); + return $sources; }, _rebaseAttrs: { @@ -973,7 +1022,6 @@ const inject = { _parseRawHtml(html, url = "") { // remove script tags and head and replace body by a div - const title = html.match(/\(.*)\<\/title\>/); let clean_html = html .replace(/)<[^<]*)*<\/script>/gi, "") .replace(/)<[^<]*)*<\/head>/gi, "") @@ -982,14 +1030,12 @@ const inject = { .replace(/]*?)>/gi, '
') .replace(/<\/body([^>]*?)>/gi, "
"); - if (title && title.length == 2) { - clean_html = title[0] + clean_html; - } try { clean_html = this._rebaseHTML(url, clean_html); } catch (e) { log.error("Error rebasing urls", e); } + const $html = $("
").html(clean_html); if ($html.children().length === 0) { log.warn("Parsing html resulted in empty jquery object:", clean_html); @@ -1121,7 +1167,6 @@ const inject = { html: { sources(cfgs, data) { const sources = cfgs.map((cfg) => cfg.source); - sources.push("title"); const result = this._sourcesFromHtml(data, cfgs[0].url, sources); return result; }, diff --git a/src/pat/inject/inject.test.js b/src/pat/inject/inject.test.js index 83382a343..d72996334 100644 --- a/src/pat/inject/inject.test.js +++ b/src/pat/inject/inject.test.js @@ -1731,7 +1731,7 @@ describe("pat-inject", function () { }); }); - describe("9.4 - injecton of the title element.", function () { + describe("9.4 - history:record DOM updates.", function () { let spy_ajax; beforeEach(function () { @@ -1742,128 +1742,998 @@ describe("pat-inject", function () { spy_ajax.mockRestore(); }); - it("9.4.1 - Injects a title element with history:record", async function () { - document.head.innerHTML = ` - test - `; - document.body.innerHTML = ` - link - `; + describe("9.4.1 - title element updates", function () { + it("9.4.1.1 - Injects a title element with history:record", async function () { + document.head.innerHTML = ` + test + `; + document.body.innerHTML = ` + link + `; - answer(` - - - hello - - - OK - - - `); + answer(` + + + hello + + + OK + + + `); - const inject = document.querySelector(".pat-inject"); + const inject = document.querySelector(".pat-inject"); - pattern.init($(inject)); - await utils.timeout(1); // wait a tick for async to settle. + pattern.init($(inject)); + await utils.timeout(1); // wait a tick for async to settle. - inject.click(); + inject.click(); - await utils.timeout(1); // wait a tick for async to settle. + await utils.timeout(1); // wait a tick for async to settle. - expect(document.body.textContent.trim()).toBe("OK"); + expect(document.body.textContent.trim()).toBe("OK"); - const title = document.head.querySelector("title"); - expect(title).toBeTruthy(); - expect(title.textContent.trim()).toBe("hello"); + const title = document.head.querySelector("title"); + expect(title).toBeTruthy(); + expect(title.textContent.trim()).toBe("hello"); + }); + + it("9.4.1.2 - Does not inject a title element without history:record", async function () { + document.head.innerHTML = ` + test + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + hello + + + OK + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); // wait a tick for async to settle. + + inject.click(); + + await utils.timeout(1); // wait a tick for async to settle. + + expect(document.body.textContent.trim()).toBe("OK"); + + const title = document.head.querySelector("title"); + expect(title).toBeTruthy(); + expect(title.textContent.trim()).toBe("test"); // Old title + }); + + it("9.4.1.3 - Does not break, if no title is found in source", async function () { + document.head.innerHTML = ` + test + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + OK + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); // wait a tick for async to settle. + + inject.click(); + + await utils.timeout(1); // wait a tick for async to settle. + + expect(document.body.textContent.trim()).toBe("OK"); + + // Title in head target is not modified. + const title = document.head.querySelector("title"); + expect(title).toBeTruthy(); + expect(title.textContent.trim()).toBe("test"); // Old title + }); + + it("9.4.1.4 - Adds title when not present in target but present in source", async function () { + document.head.innerHTML = ""; + document.body.innerHTML = ` + link + `; + + answer(` + + + hello + + OK + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); // wait a tick for async to settle. + + inject.click(); + + await utils.timeout(1); // wait a tick for async to settle. + + expect(document.body.textContent.trim()).toBe("OK"); + + // Title should be added when source has one but target doesn't + const title = document.head.querySelector("title"); + expect(title).toBeTruthy(); + expect(title.textContent.trim()).toBe("hello"); + }); + + it("9.4.1.5 - Never removes title when source doesn't have one but target does", async function () { + document.head.innerHTML = ` + existing title + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + + + + Content without title + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe( + "Content without title" + ); + + // Title should be preserved even when source doesn't have one + const title = document.head.querySelector("title"); + expect(title).toBeTruthy(); + expect(title.textContent.trim()).toBe("existing title"); + }); }); - it("9.4.2 - Does not inject a title element without history:record", async function () { - document.head.innerHTML = ` - test - `; - document.body.innerHTML = ` - link - `; + describe("9.4.2 - canonical link updates", function () { + it("9.4.2.1 - Injects canonical link with history:record", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; - answer(` - - - hello - - - OK - - - `); + answer(` + + + new page + + + + New content + + + `); - const inject = document.querySelector(".pat-inject"); + const inject = document.querySelector(".pat-inject"); - pattern.init($(inject)); - await utils.timeout(1); // wait a tick for async to settle. + pattern.init($(inject)); + await utils.timeout(1); - inject.click(); + inject.click(); + await utils.timeout(1); - await utils.timeout(1); // wait a tick for async to settle. + expect(document.body.textContent.trim()).toBe("New content"); - expect(document.body.textContent.trim()).toBe("OK"); + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBeTruthy(); + expect(canonical.getAttribute("href")).toBe("/new-page"); + }); - const title = document.head.querySelector("title"); - expect(title).toBeTruthy(); - expect(title.textContent.trim()).toBe("test"); // Old title + it("9.4.2.2 - Does not inject canonical link without history:record", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + + New content + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe("New content"); + + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBeTruthy(); + expect(canonical.getAttribute("href")).toBe("/old-page"); // Old canonical preserved + }); + + it("9.4.2.3 - Does not break if no canonical link in source", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + New content + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe("New content"); + + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBe(null); // Canonical removed when not in source + }); + + it("9.4.2.4 - Does not break if no canonical link in target", async function () { + document.head.innerHTML = ` + test + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + + New content + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe("New content"); + + // Canonical link was added from source to target + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBeTruthy(); + expect(canonical.getAttribute("href")).toBe("/new-page"); + }); + + it("9.4.2.4 - Handles canonical link with different quote styles", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + + New content + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe("New content"); + + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBeTruthy(); + expect(canonical.getAttribute("href")).toBe("/new-page"); + }); + + it("9.4.2.5 - Handles mixed case and extra attributes in canonical link", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + + New content + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe("New content"); + + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBeTruthy(); + expect(canonical.getAttribute("href")).toBe("/new-page"); + }); + + it("9.4.2.6 - Removes canonical link when source doesn't have one but target does", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + New content without canonical + + + `); + + const inject = document.querySelector(".pat-inject"); + + // Verify canonical exists before injection + expect( + document.head.querySelector("link[rel=canonical]") + ).toBeTruthy(); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe( + "New content without canonical" + ); + + // Verify canonical is removed after injection + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBe(null); + }); + + it("9.4.2.7 - Adds canonical link when source has one but target doesn't", async function () { + document.head.innerHTML = ` + test + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + + New content with canonical + + + `); + + const inject = document.querySelector(".pat-inject"); + + // Verify canonical doesn't exist before injection + expect(document.head.querySelector("link[rel=canonical]")).toBe( + null + ); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe( + "New content with canonical" + ); + + // Verify canonical is added after injection + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBeTruthy(); + expect(canonical.getAttribute("href")).toBe("/new-canonical"); + }); + + it("9.4.2.8 - Full navigation sequence: remove then add canonical", async function () { + document.head.innerHTML = ` + initial + + `; + document.body.innerHTML = ` + + `; + + const inject_first = document.querySelector(".pat-inject.first"); + + // Step 1: Navigate to page without canonical + answer(` + + + first page + + + second link + + + `); + + pattern.init($(inject_first)); + await utils.timeout(1); + + inject_first.click(); + await utils.timeout(1); + + // Verify canonical was removed + expect(document.head.querySelector("link[rel=canonical]")).toBe( + null + ); + expect(document.head.querySelector("title").textContent.trim()).toBe( + "first page" + ); + + // Step 2: Navigate to page with canonical again + const inject_second = document.querySelector(".pat-inject.second"); + + // Reset deferred for second request + deferred = new $.Deferred(); + spy_ajax.mockImplementation(() => deferred); + + answer(` + + + second page + + + + Final content + + + `); + + pattern.init($(inject_second)); + await utils.timeout(1); + + inject_second.click(); + await utils.timeout(1); + + // Verify canonical was added back + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBeTruthy(); + expect(canonical.getAttribute("href")).toBe("/second-canonical"); + expect(document.head.querySelector("title").textContent.trim()).toBe( + "second page" + ); + expect(document.querySelector("#content").textContent.trim()).toBe( + "Final content" + ); + }); }); - it("9.4.3 - Does not break, if no title is found in source", async function () { - document.head.innerHTML = ` - test - `; - document.body.innerHTML = ` - link - `; + describe("9.4.3 - base tag updates", function () { + it("9.4.3.1 - Injects base tag with history:record", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; - answer(` - - - OK - - - `); + answer(` + + + new page + + + + New content + + + `); - const inject = document.querySelector(".pat-inject"); + const inject = document.querySelector(".pat-inject"); - pattern.init($(inject)); - await utils.timeout(1); // wait a tick for async to settle. + pattern.init($(inject)); + await utils.timeout(1); - inject.click(); + inject.click(); + await utils.timeout(1); - await utils.timeout(1); // wait a tick for async to settle. + expect(document.body.textContent.trim()).toBe("New content"); - expect(document.body.textContent.trim()).toBe("OK"); + const base = document.head.querySelector("base"); + expect(base).toBeTruthy(); + expect(base.getAttribute("href")).toBe("/new/"); + }); - // Title in head target is not modified. - const title = document.head.querySelector("title"); - expect(title).toBeTruthy(); - expect(title.textContent.trim()).toBe("test"); // Old title + it("9.4.3.2 - Does not inject base tag without history:record", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + + New content + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe("New content"); + + const base = document.head.querySelector("base"); + expect(base).toBeTruthy(); + expect(base.getAttribute("href")).toBe("/old/"); // Old base preserved + }); + + it("9.4.3.3 - Does not break if no base tag in source", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + New content + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe("New content"); + + const base = document.head.querySelector("base"); + expect(base).toBe(null); // Base removed when not in source + }); + + it("9.4.3.4 - Does not break if no base tag in target", async function () { + document.head.innerHTML = ` + test + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + + New content + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe("New content"); + + // Base tag was added from source to target + const base = document.head.querySelector("base"); + expect(base).toBeTruthy(); + expect(base.getAttribute("href")).toBe("/new/"); + }); + + it("9.4.3.5 - Removes base tag when source doesn't have one but target does", async function () { + document.head.innerHTML = ` + test + + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + New content without base + + + `); + + const inject = document.querySelector(".pat-inject"); + + // Verify base exists before injection + expect(document.head.querySelector("base")).toBeTruthy(); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe( + "New content without base" + ); + + // Verify base is removed after injection + const base = document.head.querySelector("base"); + expect(base).toBe(null); + }); + + it("9.4.3.6 - Adds base tag when source has one but target doesn't", async function () { + document.head.innerHTML = ` + test + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + + New content with base + + + `); + + const inject = document.querySelector(".pat-inject"); + + // Verify base doesn't exist before injection + expect(document.head.querySelector("base")).toBe(null); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + expect(document.body.textContent.trim()).toBe( + "New content with base" + ); + + // Verify base is added after injection + const base = document.head.querySelector("base"); + expect(base).toBeTruthy(); + expect(base.getAttribute("href")).toBe("/new-base/"); + }); + + it("9.4.3.7 - Full navigation sequence: remove then add base tag", async function () { + document.head.innerHTML = ` + initial + + `; + document.body.innerHTML = ` + + `; + + const inject_first = document.querySelector(".pat-inject.first"); + + // Step 1: Navigate to page without base + answer(` + + + first page + + + second link + + + `); + + pattern.init($(inject_first)); + await utils.timeout(1); + + inject_first.click(); + await utils.timeout(1); + + // Verify base was removed + expect(document.head.querySelector("base")).toBe(null); + expect(document.head.querySelector("title").textContent.trim()).toBe( + "first page" + ); + + // Step 2: Navigate to page with base again + const inject_second = document.querySelector(".pat-inject.second"); + + // Reset deferred for second request + deferred = new $.Deferred(); + spy_ajax.mockImplementation(() => deferred); + + answer(` + + + second page + + + + Final content + + + `); + + pattern.init($(inject_second)); + await utils.timeout(1); + + inject_second.click(); + await utils.timeout(1); + + // Verify base was added back + const base = document.head.querySelector("base"); + expect(base).toBeTruthy(); + expect(base.getAttribute("href")).toBe("/second-base/"); + expect(document.head.querySelector("title").textContent.trim()).toBe( + "second page" + ); + expect(document.querySelector("#content").textContent.trim()).toBe( + "Final content" + ); + }); }); - it("9.4.4 - Does not break, if no title is found in target", async function () { - document.head.innerHTML = ""; + it("9.4.4 - Injects all metadata (title, canonical, base) together", async function () { + document.head.innerHTML = ` + old title + + + `; document.body.innerHTML = ` - hello + new title + + + - OK + New content `); @@ -1887,19 +2760,26 @@ describe("pat-inject", function () { const inject = document.querySelector(".pat-inject"); pattern.init($(inject)); - await utils.timeout(1); // wait a tick for async to settle. + await utils.timeout(1); inject.click(); + await utils.timeout(1); - await utils.timeout(1); // wait a tick for async to settle. + expect(document.body.textContent.trim()).toBe("New content"); - expect(document.body.textContent.trim()).toBe("OK"); - - // There is no title to be updated in target. + // All metadata should be updated const title = document.head.querySelector("title"); - expect(title).toBeFalsy(); - }); + expect(title).toBeTruthy(); + expect(title.textContent.trim()).toBe("new title"); + const canonical = document.head.querySelector("link[rel=canonical]"); + expect(canonical).toBeTruthy(); + expect(canonical.getAttribute("href")).toBe("/new-page"); + + const base = document.head.querySelector("base"); + expect(base).toBeTruthy(); + expect(base.getAttribute("href")).toBe("/new/"); + }); }); describe("9.5 - support multiple source element matches.", function () { From bc4c34f861762a4a8a0c45c5067df48c9156898b Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Tue, 24 Mar 2026 00:48:30 +0100 Subject: [PATCH 2/2] feat(pat-inject): Throw an event before history updates. Throw an event before the history is updated and the URL bar changes and pass the original AJAX event along with it. This allows external code to do some updates, like changing data-base-url and data-view-url on the body, using the body information from the response for that. This is a use-case in Plone and allows JavaScript code to get information on the current context. --- src/pat/inject/documentation.md | 1 + src/pat/inject/inject.js | 4 ++ src/pat/inject/inject.test.js | 79 +++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/src/pat/inject/documentation.md b/src/pat/inject/documentation.md index d439ac896..8d93e1f85 100644 --- a/src/pat/inject/documentation.md +++ b/src/pat/inject/documentation.md @@ -387,5 +387,6 @@ pat-inject fires several JavaScript events which bubble up the DOM tree: | `pat-inject-content-loaded` | jQuery | images within injected content | true | Triggered on images within the injected content when those images are loaded. | | `pat-inject-missingSource` | jQuery | trigger which caused the injection | true | Triggered when no to-be-injected source could be found. | | `pat-inject-missingTarget` | jQuery | trigger which caused the injection | true | Triggered when no target could be found. | +| `pat-inject-before-history-update` | JavaScript | document | true | Trigger just before the history is update, if `history: record` is set. | Please note: `jQuery.trigger` events can be catched with jQuery only while JavaScript `dispatchEvent` events can be catched with bare JavaScript `addEventListener` and `jQuery.on`. diff --git a/src/pat/inject/inject.js b/src/pat/inject/inject.js index 08f75f6ad..91af8ed3a 100644 --- a/src/pat/inject/inject.js +++ b/src/pat/inject/inject.js @@ -535,6 +535,10 @@ const inject = { // Base: update/add/remove as needed this._update_head(source_base, target_base, true); + document.dispatchEvent( + new Event("pat-inject-before-history-update", { detail: { ajax_event: ev } }) + ); + // At last position - other patterns can react on already changed title, // canonical or base. let url = cfg.url; diff --git a/src/pat/inject/inject.test.js b/src/pat/inject/inject.test.js index d72996334..167028ba5 100644 --- a/src/pat/inject/inject.test.js +++ b/src/pat/inject/inject.test.js @@ -2780,6 +2780,85 @@ describe("pat-inject", function () { expect(base).toBeTruthy(); expect(base.getAttribute("href")).toBe("/new/"); }); + + it("9.4.5 - Dispatches pat-inject-before-history-update event after head modifications but before history.pushState", async function () { + // Mock history.pushState to track when it's called + const original_push_state = history.pushState; + let push_state_called = false; + let event_dispatched = false; + let head_modified_when_event_fired = false; + + history.pushState = function (...args) { + push_state_called = true; + return original_push_state.apply(this, args); + }; + + // Set up event listener to track when the event is dispatched + const event_handler = function (event) { + event_dispatched = true; + // Check if the head title has already been updated when the event fires + const title = document.head.querySelector("title"); + head_modified_when_event_fired = + title && title.textContent.trim() === "new page"; + // Ensure history.pushState hasn't been called yet + expect(push_state_called).toBe(false); + // Check that event is fired on document + expect(event.target).toBe(document); + expect(event.type).toBe("pat-inject-before-history-update"); + }; + document.addEventListener( + "pat-inject-before-history-update", + event_handler + ); + + document.head.innerHTML = ` + old title + `; + document.body.innerHTML = ` + link + `; + + answer(` + + + new page + + + New content + + + `); + + const inject = document.querySelector(".pat-inject"); + + pattern.init($(inject)); + await utils.timeout(1); + + inject.click(); + await utils.timeout(1); + + // Cleanup + document.removeEventListener( + "pat-inject-before-history-update", + event_handler + ); + history.pushState = original_push_state; + + // Verify the event was dispatched + expect(event_dispatched).toBe(true); + // Verify the head was modified before the event was fired + expect(head_modified_when_event_fired).toBe(true); + // Verify history.pushState was eventually called + expect(push_state_called).toBe(true); + // Verify the content was injected + expect(document.body.textContent.trim()).toBe("New content"); + }); }); describe("9.5 - support multiple source element matches.", function () {