From 32c47041257a6ec0555b2ecedf851a2dc2c924b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sun, 26 Apr 2026 10:03:59 +0200 Subject: [PATCH] fix: prevent back-button trap when partials navigate through redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When clicking a link that triggers a server redirect (e.g. /docs → /docs/syntax), both URLs were pushed to history, creating a back-button trap: pressing back would go to /docs, which immediately redirects back to /docs/syntax. Root cause: the click handler pushed the clicked URL before fetch, then fetchPartials pushed the redirect target after — two pushState calls for one navigation. Fix: pull history management out of fetchPartials (which now just returns the final URL) and let each caller handle history appropriately: - Click handler: pushState before fetch, replaceState after if redirected - Form handler: pushState after fetch with the final URL (preserves POST-redirect-GET) - Popstate handler: replaceState if redirected (prevents infinite loops on back/forward) - Button handler: no history changes Also fixes: - ?fresh-partial query param stripped from URLs before they enter history - updateLinks() now uses the post-redirect URL for correct active link highlighting - Form submissions now save scroll position before fetch (was previously missed) --- packages/fresh/src/runtime/client/partials.ts | 65 ++++++++++++------- 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/packages/fresh/src/runtime/client/partials.ts b/packages/fresh/src/runtime/client/partials.ts index ab7cbb35bb4..1821a08d91f 100644 --- a/packages/fresh/src/runtime/client/partials.ts +++ b/packages/fresh/src/runtime/client/partials.ts @@ -83,29 +83,38 @@ if (!history.state) { history.replaceState(state, document.title); } -function maybeUpdateHistory(nextUrl: URL) { +function saveScrollPosition() { + const state: FreshHistoryState = { + fClientNav: true, + index, + scrollX: globalThis.scrollX, + scrollY: globalThis.scrollY, + }; + history.replaceState(state, "", location.href); +} + +function maybePushHistory(nextUrl: URL) { // Only add history entry when URL is new. Still apply // the partials because sometimes users click a link to // "refresh" the current page. if (nextUrl.href !== globalThis.location.href) { + index++; const state: FreshHistoryState = { fClientNav: true, index, - scrollX: globalThis.scrollX, - scrollY: globalThis.scrollY, + scrollX: 0, + scrollY: 0, }; - - // Store current scroll position - history.replaceState({ ...state }, "", location.href); - - // Now store the new position - index++; - state.scrollX = 0; - state.scrollY = 0; history.pushState(state, "", nextUrl.href); } } +function maybeReplaceHistory(nextUrl: URL) { + if (nextUrl.href !== globalThis.location.href) { + history.replaceState(history.state, "", nextUrl.href); + } +} + document.addEventListener("click", async (e) => { let el = e.target; if (el && (el instanceof HTMLElement || el instanceof SVGElement)) { @@ -154,15 +163,20 @@ document.addEventListener("click", async (e) => { const nextUrl = new URL(el.href); try { - maybeUpdateHistory(nextUrl); + saveScrollPosition(); + maybePushHistory(nextUrl); const partialUrl = new URL( partial ? partial : nextUrl.href, location.href, ); + let finalUrl = nextUrl; await withViewTransition(async () => { - await fetchPartials(nextUrl, partialUrl, true); - updateLinks(nextUrl); + finalUrl = await fetchPartials(nextUrl, partialUrl); + if (finalUrl.href !== nextUrl.href) { + maybeReplaceHistory(finalUrl); + } + updateLinks(finalUrl); }); scrollTo({ left: 0, top: 0, behavior: "instant" }); } finally { @@ -185,7 +199,7 @@ document.addEventListener("click", async (e) => { partial, location.href, ); - await fetchPartials(partialUrl, partialUrl, false); + await fetchPartials(partialUrl, partialUrl); } } }); @@ -223,8 +237,11 @@ addEventListener("popstate", async (e) => { const url = new URL(location.href, location.origin); try { await withViewTransition(async () => { - await fetchPartials(url, url, true); - updateLinks(url); + const finalUrl = await fetchPartials(url, url); + if (finalUrl.href !== url.href) { + maybeReplaceHistory(finalUrl); + } + updateLinks(finalUrl); }); scrollTo({ left: state.scrollX ?? 0, @@ -304,8 +321,10 @@ document.addEventListener("submit", async (e) => { } try { + saveScrollPosition(); await withViewTransition(async () => { - await fetchPartials(actionUrl, partialUrl, true, init); + const finalUrl = await fetchPartials(actionUrl, partialUrl, init); + maybePushHistory(finalUrl); }); } finally { if (indicator !== undefined) { @@ -347,9 +366,8 @@ function updateLinks(url: URL) { async function fetchPartials( actualUrl: URL, partialUrl: URL, - shouldNavigate: boolean, init: RequestInit = {}, -) { +): Promise { init.redirect = "follow"; partialUrl = new URL(partialUrl); partialUrl.searchParams.set(PARTIAL_SEARCH_PARAM, "true"); @@ -361,6 +379,7 @@ async function fetchPartials( actualUrl = nextUrl; } } + actualUrl.searchParams.delete(PARTIAL_SEARCH_PARAM); try { await applyPartials(res); @@ -369,14 +388,12 @@ async function fetchPartials( // to a full page navigation instead of silently failing. if (err instanceof NoPartialsError && res.redirected) { location.href = actualUrl.href; - return; + return actualUrl; } throw err; } - if (shouldNavigate) { - maybeUpdateHistory(actualUrl); - } + return actualUrl; } interface PartialReviveCtx {