SPA Events
When the SPA router is enabled, two custom events are dispatched on window for every client-side navigation. They give widget scripts a reliable hook to react to page transitions without requiring any coupling to the router internals.
sf:pageunload
Fired just before the current page's body is replaced. The old DOM is still fully live at this point — all elements from the departing page are accessible.
window.addEventListener("sf:pageunload", (event: CustomEvent) => {
const { from } = event.detail; // pathname of the page being left
// Tear down anything that can't be cleaned up automatically:
// singleton instances, classes on <html>, third-party observers, etc.
}); | Detail field | Type | Description |
|---|---|---|
from | string | location.pathname of the page being left |
sf:pageload
Fired just after the new page's body is swapped in and hydratePage has started. The static HTML is in the DOM, but lazy widgets may still be loading.
window.addEventListener("sf:pageload", (event: CustomEvent) => {
const { pathname, from } = event.detail;
// Re-initialize page-level behaviour: analytics, scroll tracking,
// third-party embeds, etc.
}); | Detail field | Type | Description |
|---|---|---|
pathname | string | location.pathname of the page just loaded |
from | string | location.pathname of the page that was left |
Note:
sf:pageloadsignals that the static HTML is ready, not that all widgets have rendered. For widget-specific timing, use the script callback inside the widget's ownScriptblock.
These events fire only during SPA navigation
sf:pageload and sf:pageunload fire on client-side transitions only. They do not fire on the initial cold page load. For cold-load setup, use the normal browser events (DOMContentLoaded, load) or simply write your initialization at the top level of a Script block — addFunctionToDom already handles idempotency for you.
Event Listener Best Practice in SPA Scripts
The runtime automatically intercepts window.addEventListener and document.addEventListener calls made by Script blocks, tagging each registration with the current page generation. When the user navigates away, the previous page's listeners are torn down before the new page's scripts run — no manual cleanup needed for listeners registered the normal way inside a Script function.
However, if your code adds listeners outside of a Script block — for example, in a third-party library, or on an element that persists across navigations like <html> or a fixed header — the automatic cleanup won't cover them. For those cases, the safest pattern is to remove before adding:
// Inside a Script block or a sf:pageload handler:
function handleScroll() { /* ... */ }
// Remove any previous registration first, then add fresh.
// Safe to call even if the listener was never added.
window.removeEventListener("scroll", handleScroll);
window.addEventListener("scroll", handleScroll, { passive: true }); This is especially important for sf:pageload handlers themselves — they are added once on cold load and fire on every subsequent navigation, so no duplicate registration issue. But any listener you add inside a sf:pageload handler runs on every navigation and will accumulate unless you pair each add with a remove:
// WRONG — a new scroll listener is added on every navigation
window.addEventListener("sf:pageload", () => {
window.addEventListener("scroll", myScrollHandler, { passive: true });
});
// CORRECT — remove the previous one before adding
window.addEventListener("sf:pageload", () => {
window.removeEventListener("scroll", myScrollHandler);
window.addEventListener("scroll", myScrollHandler, { passive: true });
}); For listeners that are fully managed by Script blocks and addFunctionToDom, the runtime's per-generation cleanup handles this automatically — the remove-before-add pattern is only needed when you're outside that managed scope.
Example: Page Transition Analytics
window.addEventListener("sf:pageload", (event: CustomEvent) => {
const { pathname, from } = event.detail;
// Track navigation in your analytics tool
analytics.track("page_view", { page: pathname, referrer: from });
}); Example: Reinitializing a Third-Party Library
let observer: IntersectionObserver | null = null;
window.addEventListener("sf:pageunload", () => {
// Disconnect the old observer before the DOM it was watching disappears
observer?.disconnect();
observer = null;
});
window.addEventListener("sf:pageload", () => {
observer = new IntersectionObserver((entries) => {
entries.forEach((e) => { /* ... */ });
});
document.querySelectorAll("[data-animate]").forEach((el) => observer!.observe(el));
}); Example: Clearing a Body Class
window.addEventListener("sf:pageunload", () => {
// Remove a class the departing page's script added to <html>
document.documentElement.classList.remove("menu-open");
});