From ad0613d30981b477d0a97efcb1dc83ac7caf4f30 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Fri, 6 Nov 2026 21:15:29 +0100 Subject: [PATCH 01/22] feat(pat-inject): Add new trigger autoload-navigate which becomes active on navigate events. --- src/pat/inject/inject.js | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/pat/inject/inject.js b/src/pat/inject/inject.js index 0e75b6873..35c9ed803 100644 --- a/src/pat/inject/inject.js +++ b/src/pat/inject/inject.js @@ -24,6 +24,7 @@ parser.addArgument("source"); parser.addArgument("trigger", "default", [ "default", "autoload", + "autoload-navigate", "autoload-visible", "idle", ]); @@ -39,8 +40,9 @@ parser.addArgument("history", "none", ["none", "record"]); parser.addArgument("push-marker"); parser.addArgument("scroll"); -// Note: this should not be here but the parser would bail on unknown -// parameters and expand/collapsible need to pass the url to us. +// URL takes precedence over `href` or `action`. +// Use it only, if a semantically richer item like `` or `
` does not +// make sense. parser.addArgument("url"); const inject = { @@ -57,6 +59,8 @@ const inject = { // pat-validation's event handlers are initialized first. await utils.timeout(1); + this.uuid = create_uuid(); + const el = utils.jqToNode($el); const cfgs = this.extractConfig($el, opts); @@ -138,8 +142,7 @@ const inject = { this.onTrigger({ currentTarget: el }); } else { // generate UUID - const uuid = create_uuid(); - el.setAttribute("data-pat-inject-uuid", uuid); + el.setAttribute("data-pat-inject-uuid", this.uuid); // function to trigger the autoload and mark as triggered const delayed_trigger = (uuid_) => { @@ -158,7 +161,7 @@ const inject = { return true; }; window.setTimeout( - delayed_trigger.bind(null, uuid), + delayed_trigger.bind(null, this.uuid), cfgs[0].delay ); } @@ -166,6 +169,9 @@ const inject = { case "autoload-visible": this._initAutoloadVisible($el, cfgs); break; + case "autoload-navigate": + this._initAutoloadNavigate($el, cfgs); + break; case "idle": this._initIdleTrigger($el, cfgs[0].delay); break; @@ -472,7 +478,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")) { @@ -1049,6 +1057,17 @@ const inject = { observer.observe(el); }, + _initAutoloadNavigate($el, cfgs) { + const el = utils.jqToNode($el); + + events.add_event_listener( + window.navigation, + "navigate", + `pat-inject-autoload-navigate--${this.uuid}`, + this.onTrigger({ currentTarget: el }) + ); + }, + _initIdleTrigger($el, delay) { // TODO: handle item removed from DOM const timeout = parseInt(delay, 10); From 93d371987ce7701cabe9d4ce596d75bd3b8006c7 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Sun, 8 Nov 2026 14:13:42 +0100 Subject: [PATCH 02/22] feat(core polyfills): Fire navigatesuccess in the Navigation API polyfill. The basic Navigation API polyfill now also fires a `navigatesuccess` event after `pushState` and `replaceState`, in addition to `navigate`. Note: Unlike the native implementation, the polyfill fires `navigate` after the URL has changed. Listening to `navigatesuccess` gives consistent timing for native and polyfilled implementations. Ref: ADR 0003 Assisted-by: Claude Opus 5.5 --- src/core/polyfills.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/core/polyfills.js b/src/core/polyfills.js index 3209cf7ae..f3d3b114b 100644 --- a/src/core/polyfills.js +++ b/src/core/polyfills.js @@ -41,7 +41,10 @@ // Navigation polyfill for Firefox and Safari, as of 2024-01-04 // NOTE: this is a very basic polyfill, it only supports firing a `navigate` -// event on location change and even that without interception support, etc. +// and `navigatesuccess` event on pushState/replaceState and even that without +// interception support, etc. +// Note: Unlike the native implementation, `navigate` is fired after the URL +// has changed. !(function () { if (window.navigation == undefined) { @@ -69,6 +72,7 @@ window.history.pushState = function () { pushState.apply(window.history, arguments); window.navigation.dispatchEvent(create_event(arguments)); + window.navigation.dispatchEvent(new Event("navigatesuccess")); }; // Same with replaceState @@ -76,6 +80,7 @@ window.history.replaceState = function () { replaceState.apply(window.history, arguments); window.navigation.dispatchEvent(create_event(arguments)); + window.navigation.dispatchEvent(new Event("navigatesuccess")); }; } })(); From b7e94c736657e07376359ffbd6171aae64b07fe3 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Sun, 8 Nov 2026 14:14:37 +0100 Subject: [PATCH 03/22] fix(pat-inject): Do not store the UUID on the shared pattern object. pat-inject is a non-class based pattern - the registry calls `init` on the same plain object for every element. Storing the UUID on `this` meant every element initialization overwrote it. Create the UUID locally where it is needed instead: for the delayed autoload trigger and the autoload-navigate listener id. Assisted-by: Claude Opus 5.5 --- src/pat/inject/inject.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pat/inject/inject.js b/src/pat/inject/inject.js index 35c9ed803..d5034c8fe 100644 --- a/src/pat/inject/inject.js +++ b/src/pat/inject/inject.js @@ -59,8 +59,6 @@ const inject = { // pat-validation's event handlers are initialized first. await utils.timeout(1); - this.uuid = create_uuid(); - const el = utils.jqToNode($el); const cfgs = this.extractConfig($el, opts); @@ -142,7 +140,8 @@ const inject = { this.onTrigger({ currentTarget: el }); } else { // generate UUID - el.setAttribute("data-pat-inject-uuid", this.uuid); + const uuid = create_uuid(); + el.setAttribute("data-pat-inject-uuid", uuid); // function to trigger the autoload and mark as triggered const delayed_trigger = (uuid_) => { @@ -161,7 +160,7 @@ const inject = { return true; }; window.setTimeout( - delayed_trigger.bind(null, this.uuid), + delayed_trigger.bind(null, uuid), cfgs[0].delay ); } @@ -1059,11 +1058,12 @@ const inject = { _initAutoloadNavigate($el, cfgs) { const el = utils.jqToNode($el); + const uuid = create_uuid(); events.add_event_listener( window.navigation, "navigate", - `pat-inject-autoload-navigate--${this.uuid}`, + `pat-inject-autoload-navigate--${uuid}`, this.onTrigger({ currentTarget: el }) ); }, From e5ab3f767d7b57c525f323b9be4cb39123d38c38 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Sun, 8 Nov 2026 14:14:38 +0100 Subject: [PATCH 04/22] fix(pat-inject): Register the autoload-navigate handler instead of calling it. The autoload-navigate trigger called `onTrigger` immediately during initialization and passed its return value (undefined) as the event listener. Pass a callback instead. The navigate event is deliberately not passed to `onTrigger`, which would call `preventDefault` on it and cancel the navigation. Assisted-by: Claude Opus 5.5 --- src/pat/inject/inject.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pat/inject/inject.js b/src/pat/inject/inject.js index d5034c8fe..132964507 100644 --- a/src/pat/inject/inject.js +++ b/src/pat/inject/inject.js @@ -1064,7 +1064,7 @@ const inject = { window.navigation, "navigate", `pat-inject-autoload-navigate--${uuid}`, - this.onTrigger({ currentTarget: el }) + () => this.onTrigger({ currentTarget: el }) ); }, From 5cf29cf594cf10c11de0d62b693d592159da8810 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Sun, 8 Nov 2026 14:14:39 +0100 Subject: [PATCH 05/22] feat(pat-inject): Trigger autoload-navigate on navigatesuccess for URL changes only. Listen to `navigatesuccess` instead of `navigate`. `navigate` is fired before the navigation is committed - the URL is not yet updated and the navigation might still be cancelled or unload the page. Only re-trigger when the URL without the hash has changed. This ignores anchor jumps and the same-URL `replaceState` calls which pat-inject uses to mark history entries ("pageload", "anchor"). Do not initialize if the Navigation API is not available. Ref: ADR 0003 Assisted-by: Claude Opus 5.5 --- src/pat/inject/inject.js | 35 ++++++++++++++++---- src/pat/inject/inject.test.js | 60 +++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/pat/inject/inject.js b/src/pat/inject/inject.js index 132964507..4de95869a 100644 --- a/src/pat/inject/inject.js +++ b/src/pat/inject/inject.js @@ -1057,15 +1057,38 @@ const inject = { }, _initAutoloadNavigate($el, cfgs) { + /* Re-trigger the injection after each successful same-document + * navigation which changed the URL. + * + * We listen to `navigatesuccess` instead of `navigate`, as the latter + * is fired before the navigation is committed - the URL is not yet + * updated and the navigation might still be cancelled or unload the + * page. + */ const el = utils.jqToNode($el); + + if (!window.navigation) { + log.warn("Navigation API not available. Not initializing.", el); + return; + } + const uuid = create_uuid(); + const event_id = `pat-inject--autoload-navigate--${uuid}`; - events.add_event_listener( - window.navigation, - "navigate", - `pat-inject-autoload-navigate--${uuid}`, - () => this.onTrigger({ currentTarget: el }) - ); + // Only re-trigger on actual URL changes, ignoring hash changes and + // replaceState calls on the same URL, like the "pageload" and + // "anchor" history state markers set by pat-inject. + const strip_hash = (url) => url.split("#")[0]; + let last_url = strip_hash(window.location.href); + + events.add_event_listener(window.navigation, "navigatesuccess", event_id, () => { + const current_url = strip_hash(window.location.href); + if (current_url === last_url) { + return; + } + last_url = current_url; + this.onTrigger({ currentTarget: el }); + }); }, _initIdleTrigger($el, delay) { diff --git a/src/pat/inject/inject.test.js b/src/pat/inject/inject.test.js index 9850a8821..0d5389c7a 100644 --- a/src/pat/inject/inject.test.js +++ b/src/pat/inject/inject.test.js @@ -4,6 +4,7 @@ import events from "../../core/events"; import registry from "../../core/registry"; import utils from "../../core/utils"; import { jest } from "@jest/globals"; +import "../../core/polyfills"; // window.navigation for jsdom const mockFetch = (text = "") => @@ -2475,6 +2476,65 @@ describe("pat-inject", function () { }); }); + describe("11b - Test trigger: autoload-navigate", function () { + let spy_trigger; + let initial_url; + + beforeEach(() => { + initial_url = window.location.href; + spy_trigger = jest.spyOn(pattern, "onTrigger").mockImplementation(() => {}); + }); + + afterEach(() => { + spy_trigger.mockRestore(); + events.remove_event_listener( + window.navigation, + "pat-inject--autoload-navigate--*" + ); + history.replaceState(null, "", initial_url); + document.body.innerHTML = ""; + }); + + const setup = async (config = "") => { + document.body.innerHTML = ` + toolbar + `; + const el = document.querySelector(".pat-inject"); + pattern.init($(el)); + await utils.timeout(1); + return el; + }; + + it("11b.1 - does not trigger on initialization", async () => { + await setup(); + expect(spy_trigger).not.toHaveBeenCalled(); + }); + + it("11b.2 - triggers on URL changes", async () => { + const el = await setup(); + + history.pushState(null, "", "/page-1"); + await utils.timeout(1); + expect(spy_trigger).toHaveBeenCalledTimes(1); + expect(spy_trigger.mock.calls[0][0].currentTarget).toBe(el); + + history.pushState(null, "", "/page-2"); + await utils.timeout(1); + expect(spy_trigger).toHaveBeenCalledTimes(2); + }); + + it("11b.3 - ignores hash changes and same-URL replaceState", async () => { + await setup(); + + history.pushState(null, "", "#anchor"); + history.replaceState("anchor", "", window.location.href); + await utils.timeout(1); + expect(spy_trigger).not.toHaveBeenCalled(); + }); + }); + describe("12 - caching", () => { afterEach(function () { document.body.innerHTML = ""; From 4c9af90347da8a2af77d64af5632e210af698aaf Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Sun, 8 Nov 2026 14:14:40 +0100 Subject: [PATCH 06/22] feat(pat-inject): Do not allow autoload-navigate together with history: record. Recording history pushes a new history entry, which fires a navigation event which would re-trigger the injection again - an infinite loop. Log an error and do not initialize the trigger in that case. Assisted-by: Claude Opus 5.5 --- src/pat/inject/inject.js | 11 +++++++++++ src/pat/inject/inject.test.js | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/src/pat/inject/inject.js b/src/pat/inject/inject.js index 4de95869a..b055c688f 100644 --- a/src/pat/inject/inject.js +++ b/src/pat/inject/inject.js @@ -1067,6 +1067,17 @@ const inject = { */ const el = utils.jqToNode($el); + if (cfgs.some((cfg) => cfg.history === "record")) { + // Recording history would push a new history entry, which in turn + // would fire a navigation event and re-trigger this injection + // over and over again. + log.error( + "`trigger: autoload-navigate` cannot be combined with `history: record`. Not initializing.", + el + ); + return; + } + if (!window.navigation) { log.warn("Navigation API not available. Not initializing.", el); return; diff --git a/src/pat/inject/inject.test.js b/src/pat/inject/inject.test.js index 0d5389c7a..3f9c868fd 100644 --- a/src/pat/inject/inject.test.js +++ b/src/pat/inject/inject.test.js @@ -2533,6 +2533,14 @@ describe("pat-inject", function () { await utils.timeout(1); expect(spy_trigger).not.toHaveBeenCalled(); }); + + it("11b.7 - is not initialized together with history: record", async () => { + await setup("history: record"); + + history.pushState(null, "", "/page-1"); + await utils.timeout(1); + expect(spy_trigger).not.toHaveBeenCalled(); + }); }); describe("12 - caching", () => { From 666e161f89f5e8a93c1386c3ba7d77d8cfa6afa8 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Sun, 8 Nov 2026 14:14:41 +0100 Subject: [PATCH 07/22] fix(pat-inject): Clean up the autoload-navigate listener for removed elements. When the element was removed from the DOM - e.g. replaced by an injection - remove the navigation event listener instead of triggering an injection on a detached element. Assisted-by: Claude Opus 5.5 --- src/pat/inject/inject.js | 11 ++++++++++- src/pat/inject/inject.test.js | 13 +++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/pat/inject/inject.js b/src/pat/inject/inject.js index b055c688f..9c8b1804d 100644 --- a/src/pat/inject/inject.js +++ b/src/pat/inject/inject.js @@ -1092,13 +1092,22 @@ const inject = { const strip_hash = (url) => url.split("#")[0]; let last_url = strip_hash(window.location.href); + const trigger = () => { + if (!el.isConnected) { + // The element was removed from the DOM, e.g. replaced by an + // injection. Clean up. + events.remove_event_listener(window.navigation, event_id); + return; + } + this.onTrigger({ currentTarget: el }); + }; events.add_event_listener(window.navigation, "navigatesuccess", event_id, () => { const current_url = strip_hash(window.location.href); if (current_url === last_url) { return; } last_url = current_url; - this.onTrigger({ currentTarget: el }); + trigger(); }); }, diff --git a/src/pat/inject/inject.test.js b/src/pat/inject/inject.test.js index 3f9c868fd..70e9b5914 100644 --- a/src/pat/inject/inject.test.js +++ b/src/pat/inject/inject.test.js @@ -2534,6 +2534,19 @@ describe("pat-inject", function () { expect(spy_trigger).not.toHaveBeenCalled(); }); + it("11b.5 - unregisters when the element is removed", async () => { + const el = await setup(); + el.remove(); + + history.pushState(null, "", "/page-1"); + await utils.timeout(1); + expect(spy_trigger).not.toHaveBeenCalled(); + + history.pushState(null, "", "/page-2"); + await utils.timeout(1); + expect(spy_trigger).not.toHaveBeenCalled(); + }); + it("11b.7 - is not initialized together with history: record", async () => { await setup("history: record"); From b65d89d3d0af4fdf27bac71746a5350c3a9a4379 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Sun, 8 Nov 2026 14:14:42 +0100 Subject: [PATCH 08/22] feat(pat-inject): Debounce autoload-navigate and re-trigger after running injections. Rapid successive navigations are debounced using the `delay` option (default: 0). pat-inject drops triggers while an injection is in progress. For autoload-navigate this would leave the content in the state of an outdated URL. Instead, remember a pending trigger and run it once after the running injection has finished. Assisted-by: Claude Opus 5.5 --- src/pat/inject/inject.js | 24 +++++++++++++++++++++++- src/pat/inject/inject.test.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/pat/inject/inject.js b/src/pat/inject/inject.js index 9c8b1804d..ff760128c 100644 --- a/src/pat/inject/inject.js +++ b/src/pat/inject/inject.js @@ -1092,6 +1092,7 @@ const inject = { const strip_hash = (url) => url.split("#")[0]; let last_url = strip_hash(window.location.href); + let pending = false; const trigger = () => { if (!el.isConnected) { // The element was removed from the DOM, e.g. replaced by an @@ -1099,15 +1100,36 @@ const inject = { events.remove_event_listener(window.navigation, event_id); return; } + if ($el.data("pat-inject-triggered")) { + // An injection is in progress. Re-trigger after it is done, + // so that the latest navigation state is reflected. + if (!pending) { + pending = true; + $el.one( + "pat-ajax-success.pat-inject-navigate pat-ajax-error.pat-inject-navigate", + async () => { + // Wait for the inject's own handlers to finish. + await utils.timeout(1); + pending = false; + trigger(); + } + ); + } + return; + } this.onTrigger({ currentTarget: el }); }; + // Debounce, to not fire one request per navigation on rapid + // successive navigations. + const debounced_trigger = utils.debounce(trigger, cfgs[0].delay || 0); + events.add_event_listener(window.navigation, "navigatesuccess", event_id, () => { const current_url = strip_hash(window.location.href); if (current_url === last_url) { return; } last_url = current_url; - trigger(); + debounced_trigger(); }); }, diff --git a/src/pat/inject/inject.test.js b/src/pat/inject/inject.test.js index 70e9b5914..68adb6b96 100644 --- a/src/pat/inject/inject.test.js +++ b/src/pat/inject/inject.test.js @@ -2534,6 +2534,18 @@ describe("pat-inject", function () { expect(spy_trigger).not.toHaveBeenCalled(); }); + it("11b.4 - debounces rapid navigations with delay", async () => { + await setup("delay: 20ms"); + + history.pushState(null, "", "/page-1"); + history.pushState(null, "", "/page-2"); + history.pushState(null, "", "/page-3"); + await utils.timeout(1); + expect(spy_trigger).not.toHaveBeenCalled(); + await utils.timeout(30); + expect(spy_trigger).toHaveBeenCalledTimes(1); + }); + it("11b.5 - unregisters when the element is removed", async () => { const el = await setup(); el.remove(); @@ -2547,6 +2559,22 @@ describe("pat-inject", function () { expect(spy_trigger).not.toHaveBeenCalled(); }); + it("11b.6 - re-triggers once after an in-progress injection", async () => { + const el = await setup(); + $(el).data("pat-inject-triggered", true); // Injection in progress. + + history.pushState(null, "", "/page-1"); + await utils.timeout(1); + history.pushState(null, "", "/page-2"); + await utils.timeout(1); + expect(spy_trigger).not.toHaveBeenCalled(); + + $(el).removeData("pat-inject-triggered"); + $(el).trigger("pat-ajax-success"); + await utils.timeout(2); + expect(spy_trigger).toHaveBeenCalledTimes(1); + }); + it("11b.7 - is not initialized together with history: record", async () => { await setup("history: record"); From 7d61328cd200c3b0ef59025b173c49efe34a82be Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Mon, 9 Nov 2026 02:56:13 +0100 Subject: [PATCH 09/22] maint(pat-inject): Remove the autoload-navigate trigger again. Reloading content on navigation or other events will be implemented in a dedicated pattern `pat-reload`, which uses pat-inject for the injection. Doing it in pat-inject would have required changing pat-inject's general contract - resolving the `url` relative to the current location and allowing other elements than `` and `` - for one use case, while the "when and which URL to reload" logic is not about injection. The polyfill's `navigatesuccess` event is kept. Ref: ADR 0001 Assisted-by: Claude Opus 5.5 --- src/pat/inject/inject.js | 81 ------------------------- src/pat/inject/inject.test.js | 109 ---------------------------------- 2 files changed, 190 deletions(-) diff --git a/src/pat/inject/inject.js b/src/pat/inject/inject.js index ff760128c..838d8e98b 100644 --- a/src/pat/inject/inject.js +++ b/src/pat/inject/inject.js @@ -24,7 +24,6 @@ parser.addArgument("source"); parser.addArgument("trigger", "default", [ "default", "autoload", - "autoload-navigate", "autoload-visible", "idle", ]); @@ -168,9 +167,6 @@ const inject = { case "autoload-visible": this._initAutoloadVisible($el, cfgs); break; - case "autoload-navigate": - this._initAutoloadNavigate($el, cfgs); - break; case "idle": this._initIdleTrigger($el, cfgs[0].delay); break; @@ -1056,83 +1052,6 @@ const inject = { observer.observe(el); }, - _initAutoloadNavigate($el, cfgs) { - /* Re-trigger the injection after each successful same-document - * navigation which changed the URL. - * - * We listen to `navigatesuccess` instead of `navigate`, as the latter - * is fired before the navigation is committed - the URL is not yet - * updated and the navigation might still be cancelled or unload the - * page. - */ - const el = utils.jqToNode($el); - - if (cfgs.some((cfg) => cfg.history === "record")) { - // Recording history would push a new history entry, which in turn - // would fire a navigation event and re-trigger this injection - // over and over again. - log.error( - "`trigger: autoload-navigate` cannot be combined with `history: record`. Not initializing.", - el - ); - return; - } - - if (!window.navigation) { - log.warn("Navigation API not available. Not initializing.", el); - return; - } - - const uuid = create_uuid(); - const event_id = `pat-inject--autoload-navigate--${uuid}`; - - // Only re-trigger on actual URL changes, ignoring hash changes and - // replaceState calls on the same URL, like the "pageload" and - // "anchor" history state markers set by pat-inject. - const strip_hash = (url) => url.split("#")[0]; - let last_url = strip_hash(window.location.href); - - let pending = false; - const trigger = () => { - if (!el.isConnected) { - // The element was removed from the DOM, e.g. replaced by an - // injection. Clean up. - events.remove_event_listener(window.navigation, event_id); - return; - } - if ($el.data("pat-inject-triggered")) { - // An injection is in progress. Re-trigger after it is done, - // so that the latest navigation state is reflected. - if (!pending) { - pending = true; - $el.one( - "pat-ajax-success.pat-inject-navigate pat-ajax-error.pat-inject-navigate", - async () => { - // Wait for the inject's own handlers to finish. - await utils.timeout(1); - pending = false; - trigger(); - } - ); - } - return; - } - this.onTrigger({ currentTarget: el }); - }; - // Debounce, to not fire one request per navigation on rapid - // successive navigations. - const debounced_trigger = utils.debounce(trigger, cfgs[0].delay || 0); - - events.add_event_listener(window.navigation, "navigatesuccess", event_id, () => { - const current_url = strip_hash(window.location.href); - if (current_url === last_url) { - return; - } - last_url = current_url; - debounced_trigger(); - }); - }, - _initIdleTrigger($el, delay) { // TODO: handle item removed from DOM const timeout = parseInt(delay, 10); diff --git a/src/pat/inject/inject.test.js b/src/pat/inject/inject.test.js index 68adb6b96..9850a8821 100644 --- a/src/pat/inject/inject.test.js +++ b/src/pat/inject/inject.test.js @@ -4,7 +4,6 @@ import events from "../../core/events"; import registry from "../../core/registry"; import utils from "../../core/utils"; import { jest } from "@jest/globals"; -import "../../core/polyfills"; // window.navigation for jsdom const mockFetch = (text = "") => @@ -2476,114 +2475,6 @@ describe("pat-inject", function () { }); }); - describe("11b - Test trigger: autoload-navigate", function () { - let spy_trigger; - let initial_url; - - beforeEach(() => { - initial_url = window.location.href; - spy_trigger = jest.spyOn(pattern, "onTrigger").mockImplementation(() => {}); - }); - - afterEach(() => { - spy_trigger.mockRestore(); - events.remove_event_listener( - window.navigation, - "pat-inject--autoload-navigate--*" - ); - history.replaceState(null, "", initial_url); - document.body.innerHTML = ""; - }); - - const setup = async (config = "") => { - document.body.innerHTML = ` - toolbar - `; - const el = document.querySelector(".pat-inject"); - pattern.init($(el)); - await utils.timeout(1); - return el; - }; - - it("11b.1 - does not trigger on initialization", async () => { - await setup(); - expect(spy_trigger).not.toHaveBeenCalled(); - }); - - it("11b.2 - triggers on URL changes", async () => { - const el = await setup(); - - history.pushState(null, "", "/page-1"); - await utils.timeout(1); - expect(spy_trigger).toHaveBeenCalledTimes(1); - expect(spy_trigger.mock.calls[0][0].currentTarget).toBe(el); - - history.pushState(null, "", "/page-2"); - await utils.timeout(1); - expect(spy_trigger).toHaveBeenCalledTimes(2); - }); - - it("11b.3 - ignores hash changes and same-URL replaceState", async () => { - await setup(); - - history.pushState(null, "", "#anchor"); - history.replaceState("anchor", "", window.location.href); - await utils.timeout(1); - expect(spy_trigger).not.toHaveBeenCalled(); - }); - - it("11b.4 - debounces rapid navigations with delay", async () => { - await setup("delay: 20ms"); - - history.pushState(null, "", "/page-1"); - history.pushState(null, "", "/page-2"); - history.pushState(null, "", "/page-3"); - await utils.timeout(1); - expect(spy_trigger).not.toHaveBeenCalled(); - await utils.timeout(30); - expect(spy_trigger).toHaveBeenCalledTimes(1); - }); - - it("11b.5 - unregisters when the element is removed", async () => { - const el = await setup(); - el.remove(); - - history.pushState(null, "", "/page-1"); - await utils.timeout(1); - expect(spy_trigger).not.toHaveBeenCalled(); - - history.pushState(null, "", "/page-2"); - await utils.timeout(1); - expect(spy_trigger).not.toHaveBeenCalled(); - }); - - it("11b.6 - re-triggers once after an in-progress injection", async () => { - const el = await setup(); - $(el).data("pat-inject-triggered", true); // Injection in progress. - - history.pushState(null, "", "/page-1"); - await utils.timeout(1); - history.pushState(null, "", "/page-2"); - await utils.timeout(1); - expect(spy_trigger).not.toHaveBeenCalled(); - - $(el).removeData("pat-inject-triggered"); - $(el).trigger("pat-ajax-success"); - await utils.timeout(2); - expect(spy_trigger).toHaveBeenCalledTimes(1); - }); - - it("11b.7 - is not initialized together with history: record", async () => { - await setup("history: record"); - - history.pushState(null, "", "/page-1"); - await utils.timeout(1); - expect(spy_trigger).not.toHaveBeenCalled(); - }); - }); - describe("12 - caching", () => { afterEach(function () { document.body.innerHTML = ""; From 4db0d07869db1b6881dd1486379ef42d2e99f09f Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Mon, 9 Nov 2026 02:58:57 +0100 Subject: [PATCH 10/22] feat(pat-reload): Add pat-reload to reload content on events. pat-reload reloads the content of an element when an event is fired - by default `navigatesuccess`, after same-document navigations which changed the URL. This is useful for toolbars, breadcrumbs or sidebars which depend on the current location. pat-reload defines when and from which URL to reload. The injection itself is delegated to pat-inject and can be configured via `data-pat-inject` on the same element. By default, the content is injected into the element itself. Options: - `url`: Required. Relative URLs are appended to the current URL (without query string and hash), absolute URLs are used as-is. - `trigger-event`: Default `navigatesuccess`. Navigation API events are listened to on `window.navigation`, all others on `document`. - `delay`: Debounce time for successive events. Hash changes and same-URL replaceState calls do not trigger a reload. Events during a running reload trigger one more reload afterwards. Listeners are removed when the element was removed from the DOM. The base URL for relative URLs is the current location for now. A configurable base URL - e.g. for Plone default pages - will follow. Ref: ADR 0001 Ref: ADR 0002 Ref: ADR 0003 Assisted-by: Claude Opus 5.5 --- src/pat/reload/reload.js | 177 ++++++++++++++++++++++++++++ src/pat/reload/reload.test.js | 210 ++++++++++++++++++++++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 src/pat/reload/reload.js create mode 100644 src/pat/reload/reload.test.js diff --git a/src/pat/reload/reload.js b/src/pat/reload/reload.js new file mode 100644 index 000000000..bb4ba7191 --- /dev/null +++ b/src/pat/reload/reload.js @@ -0,0 +1,177 @@ +/** + * pat-reload - Reload the content of an element on an event. + * + * The "when" and "from which URL" is defined by pat-reload, the "what" and + * "where" is delegated to pat-inject. + */ +import $ from "jquery"; +import { BasePattern } from "../../core/basepattern"; +import events from "../../core/events"; +import inject from "../inject/inject"; +import logging from "../../core/logging"; +import Parser from "../../core/parser"; +import registry from "../../core/registry"; +import utils from "../../core/utils"; + +const log = logging.getLogger("pat-reload"); + +export const parser = new Parser("reload"); +// URL to reload from. Relative URLs are resolved against the base URL. +parser.addArgument("url", null); +// Event which triggers the reload. +parser.addArgument("trigger-event", "navigatesuccess"); +// Debounce time for successive events. +parser.addArgument("delay", 0); + +// Events which are dispatched on `window.navigation`. +const NAVIGATION_EVENTS = [ + "navigate", + "navigatesuccess", + "navigateerror", + "currententrychange", +]; + +/** + * Strip query string and hash from an URL. + * + * @param {String} url - The URL. + * @returns {String} - The URL without query string and hash. + */ +const strip_url = (url) => url.split(/[?#]/)[0]; + +class Pattern extends BasePattern { + static name = "reload"; + static trigger = ".pat-reload"; + static parser = parser; + + parser_group_options = false; + + async init() { + if (!this.options.url) { + log.error("No `url` defined. Not initializing.", this.el); + return; + } + + this.is_navigation_event = NAVIGATION_EVENTS.includes( + this.options["trigger-event"] + ); + this.event_source = this.is_navigation_event ? window.navigation : document; + if (!this.event_source) { + log.warn("Navigation API not available. Not initializing.", this.el); + return; + } + + try { + this.delay = utils.parseTime(this.options.delay) || 0; + } catch { + log.warn("Invalid delay value: ", this.options.delay); + this.delay = 0; + } + + this.event_id = `pat-reload--${this.uuid}`; + this.last_url = strip_url(window.location.href); + this.pending = false; + + const debounced_reload = utils.debounce(() => this.reload(), this.delay); + events.add_event_listener( + this.event_source, + this.options["trigger-event"], + this.event_id, + () => { + if (!this.el.isConnected) { + // The element was removed from the DOM. Clean up. + this.destroy(); + return; + } + if (this.is_navigation_event) { + // Only reload on actual URL changes. Ignore hash changes + // and same-URL `replaceState` calls. + const current_url = strip_url(window.location.href); + if (current_url === this.last_url) { + return; + } + this.last_url = current_url; + } + debounced_reload(); + } + ); + } + + /** + * Get the base URL against which relative URLs are resolved. + * + * TODO: Allow configuring the base URL (e.g. via a CSS selector for + * `[data-base-url]` or `link[rel=canonical]`). Note: those are not + * updated on same-document navigation, while `window.location` is. + * + * @returns {String} - The base URL, ending with a slash. + */ + get_base_url() { + return strip_url(window.location.href).replace(/\/?$/, "/"); + } + + /** + * Get the URL to reload from. + * + * Absolute URLs (with scheme, starting with `//` or `/`) are used as-is. + * Relative URLs are appended to the base URL. + * + * @returns {String} - The reload URL. + */ + get_url() { + return new URL(this.options.url, this.get_base_url()).href; + } + + /** + * Reload the content via pat-inject. + */ + reload() { + if (!this.el.isConnected) { + this.destroy(); + return; + } + + const $el = $(this.el); + if ($el.data("pat-inject-triggered")) { + // An injection is in progress, which would drop this reload. + // Re-trigger after it is done, so that the latest state is + // reflected. + if (!this.pending) { + this.pending = true; + $el.one( + "pat-ajax-success.pat-reload pat-ajax-error.pat-reload", + async () => { + // Wait for pat-inject's own handlers to finish. + await utils.timeout(1); + this.pending = false; + this.reload(); + } + ); + } + return; + } + + const cfgs = inject.extractConfig($el, { url: this.get_url() }); + for (const cfg of cfgs) { + // Inject into the element itself by default instead of into the + // body - which is pat-inject's default. + cfg.target = cfg.target || "self"; + } + log.debug("Reloading from", cfgs[0].url); + inject.execute(cfgs, $el); + } + + destroy() { + if (this.event_source && this.event_id) { + // Guard: Without element and id, `remove_event_listener` would + // remove all registered event listeners. + events.remove_event_listener(this.event_source, this.event_id); + } + $(this.el).off(".pat-reload"); + super.destroy(); + } +} + +registry.register(Pattern); +export default Pattern; +export { Pattern }; diff --git a/src/pat/reload/reload.test.js b/src/pat/reload/reload.test.js new file mode 100644 index 000000000..aa28c1f6c --- /dev/null +++ b/src/pat/reload/reload.test.js @@ -0,0 +1,210 @@ +import $ from "jquery"; +import events from "../../core/events"; +import inject from "../inject/inject"; +import Pattern from "./reload"; +import utils from "../../core/utils"; +import { jest } from "@jest/globals"; + +describe("pat-reload", () => { + let initial_url; + let spy_ajax; + let ajax_urls; + + beforeEach(() => { + initial_url = window.location.href; + ajax_urls = []; + spy_ajax = jest.spyOn($, "ajax").mockImplementation((args) => { + ajax_urls.push(args.url); + const deferred = $.Deferred(); + const html = `
reloaded ${ajax_urls.length}
`; + deferred.resolve(html, "ok", { responseText: html }); + return deferred; + }); + }); + + afterEach(() => { + spy_ajax.mockRestore(); + events.remove_event_listener(undefined, "pat-reload--*"); + history.replaceState(null, "", initial_url); + document.body.innerHTML = ""; + }); + + const setup = async (reload_config, inject_config = "") => { + history.replaceState(null, "", "/folder/page"); + document.body.innerHTML = ` +
initial
+ `; + const el = document.querySelector(".pat-reload"); + const instance = new Pattern(el); + await events.await_pattern_init(instance); + return { el, instance }; + }; + + describe("1 - Initialization", () => { + it("1.1 - does not reload on initialization", async () => { + await setup("url: @@render-toolbar#toolbar"); + await utils.timeout(1); + expect(spy_ajax).not.toHaveBeenCalled(); + }); + + it("1.2 - does not initialize without url", async () => { + const { instance } = await setup(""); + expect(instance.event_id).toBeUndefined(); + + history.pushState(null, "", "/folder/other"); + await utils.timeout(1); + expect(spy_ajax).not.toHaveBeenCalled(); + }); + + it("1.3 - destroy does not remove other event listeners", async () => { + const cb = jest.fn(); + events.add_event_listener(document, "test", "pat-reload-test", cb); + + const { instance } = await setup(""); + instance.destroy(); + + document.dispatchEvent(new Event("test")); + expect(cb).toHaveBeenCalled(); + events.remove_event_listener(document, "pat-reload-test"); + }); + }); + + describe("2 - Reload on navigation", () => { + it("2.1 - reloads into itself on URL changes", async () => { + const { el } = await setup("url: @@render-toolbar#toolbar"); + + history.pushState(null, "", "/folder/other"); + await utils.timeout(1); + + expect(ajax_urls).toEqual([ + `${window.location.origin}/folder/other/@@render-toolbar`, + ]); + expect(el.textContent).toBe("reloaded 1"); + + history.pushState(null, "", "/another/page?x=1#y"); + await utils.timeout(1); + + expect(ajax_urls[1]).toBe( + `${window.location.origin}/another/page/@@render-toolbar` + ); + expect(el.textContent).toBe("reloaded 2"); + }); + + it("2.2 - ignores hash changes and same-URL replaceState", async () => { + await setup("url: @@render-toolbar#toolbar"); + + history.pushState(null, "", "#anchor"); + history.replaceState("anchor", "", window.location.href); + await utils.timeout(1); + expect(spy_ajax).not.toHaveBeenCalled(); + }); + + it("2.3 - uses absolute URLs as-is", async () => { + await setup("url: /@@render-toolbar#toolbar"); + + history.pushState(null, "", "/folder/other"); + await utils.timeout(1); + expect(ajax_urls).toEqual([`${window.location.origin}/@@render-toolbar`]); + }); + + it("2.4 - uses the pat-inject configuration", async () => { + document.body.innerHTML = `
other
`; + const { el } = await setup( + "url: @@render-toolbar", + "source: #toolbar; target: #other" + ); + // setup replaces the body - re-add the target. + el.insertAdjacentHTML("afterend", `
other
`); + + history.pushState(null, "", "/folder/other"); + await utils.timeout(1); + + expect(el.textContent).toBe("initial"); + expect(document.querySelector("#other").textContent).toBe("reloaded 1"); + }); + + it("2.5 - debounces rapid navigations with delay", async () => { + await setup("url: @@render-toolbar#toolbar; delay: 20ms"); + + history.pushState(null, "", "/page-1"); + history.pushState(null, "", "/page-2"); + history.pushState(null, "", "/page-3"); + await utils.timeout(1); + expect(spy_ajax).not.toHaveBeenCalled(); + + await utils.timeout(30); + expect(ajax_urls).toEqual([ + `${window.location.origin}/page-3/@@render-toolbar`, + ]); + }); + + it("2.6 - re-triggers once after an in-progress injection", async () => { + const { el } = await setup("url: @@render-toolbar#toolbar"); + $(el).data("pat-inject-triggered", true); // Injection in progress. + + history.pushState(null, "", "/page-1"); + await utils.timeout(1); + history.pushState(null, "", "/page-2"); + await utils.timeout(1); + expect(spy_ajax).not.toHaveBeenCalled(); + + $(el).removeData("pat-inject-triggered"); + $(el).trigger("pat-ajax-success"); + await utils.timeout(5); + expect(ajax_urls).toEqual([ + `${window.location.origin}/page-2/@@render-toolbar`, + ]); + }); + + it("2.7 - unregisters when the element is removed", async () => { + const { el, instance } = await setup("url: @@render-toolbar#toolbar"); + const spy_destroy = jest.spyOn(instance, "destroy"); + el.remove(); + + history.pushState(null, "", "/page-1"); + await utils.timeout(1); + expect(spy_ajax).not.toHaveBeenCalled(); + expect(spy_destroy).toHaveBeenCalledTimes(1); + + history.pushState(null, "", "/page-2"); + await utils.timeout(1); + expect(spy_destroy).toHaveBeenCalledTimes(1); + }); + }); + + describe("3 - Reload on custom events", () => { + it("3.1 - reloads on any event bubbling up to the document", async () => { + const { el } = await setup( + "url: @@render-toolbar#toolbar; trigger-event: my-reload" + ); + + // A navigation does not trigger the reload. + history.pushState(null, "", "/folder/other"); + await utils.timeout(1); + expect(spy_ajax).not.toHaveBeenCalled(); + + document.body.dispatchEvent(new Event("my-reload", { bubbles: true })); + await utils.timeout(1); + expect(el.textContent).toBe("reloaded 1"); + + // Custom events also reload without URL changes. + document.dispatchEvent(new Event("my-reload")); + await utils.timeout(1); + expect(el.textContent).toBe("reloaded 2"); + }); + }); + + it("4 - uses pat-inject for the injection", async () => { + const spy_execute = jest.spyOn(inject, "execute"); + await setup("url: @@render-toolbar#toolbar"); + + history.pushState(null, "", "/folder/other"); + await utils.timeout(1); + expect(spy_execute).toHaveBeenCalledTimes(1); + spy_execute.mockRestore(); + }); +}); From 25a4734abb933d769e8e9e123612ab113bc07d02 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Mon, 9 Nov 2026 02:58:57 +0100 Subject: [PATCH 11/22] feat(pat-reload): Add documentation and demo. Ref: ADR 0001 Ref: ADR 0002 Assisted-by: Claude Opus 5.5 --- index.html | 6 ++ src/pat/reload/documentation.md | 94 ++++++++++++++++++++++++++++++ src/pat/reload/index.html | 50 ++++++++++++++++ src/pat/reload/reload-content.html | 13 +++++ 4 files changed, 163 insertions(+) create mode 100644 src/pat/reload/documentation.md create mode 100644 src/pat/reload/index.html create mode 100644 src/pat/reload/reload-content.html diff --git a/index.html b/index.html index 80ece4eeb..af739ae7c 100644 --- a/index.html +++ b/index.html @@ -71,6 +71,12 @@

Behavioural

title="Push" >Pushβ + Reloadβ