Skip to content

Commit 32c4704

Browse files
committed
fix: prevent back-button trap when partials navigate through redirects
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)
1 parent cb6d02d commit 32c4704

1 file changed

Lines changed: 41 additions & 24 deletions

File tree

packages/fresh/src/runtime/client/partials.ts

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -83,29 +83,38 @@ if (!history.state) {
8383
history.replaceState(state, document.title);
8484
}
8585

86-
function maybeUpdateHistory(nextUrl: URL) {
86+
function saveScrollPosition() {
87+
const state: FreshHistoryState = {
88+
fClientNav: true,
89+
index,
90+
scrollX: globalThis.scrollX,
91+
scrollY: globalThis.scrollY,
92+
};
93+
history.replaceState(state, "", location.href);
94+
}
95+
96+
function maybePushHistory(nextUrl: URL) {
8797
// Only add history entry when URL is new. Still apply
8898
// the partials because sometimes users click a link to
8999
// "refresh" the current page.
90100
if (nextUrl.href !== globalThis.location.href) {
101+
index++;
91102
const state: FreshHistoryState = {
92103
fClientNav: true,
93104
index,
94-
scrollX: globalThis.scrollX,
95-
scrollY: globalThis.scrollY,
105+
scrollX: 0,
106+
scrollY: 0,
96107
};
97-
98-
// Store current scroll position
99-
history.replaceState({ ...state }, "", location.href);
100-
101-
// Now store the new position
102-
index++;
103-
state.scrollX = 0;
104-
state.scrollY = 0;
105108
history.pushState(state, "", nextUrl.href);
106109
}
107110
}
108111

112+
function maybeReplaceHistory(nextUrl: URL) {
113+
if (nextUrl.href !== globalThis.location.href) {
114+
history.replaceState(history.state, "", nextUrl.href);
115+
}
116+
}
117+
109118
document.addEventListener("click", async (e) => {
110119
let el = e.target;
111120
if (el && (el instanceof HTMLElement || el instanceof SVGElement)) {
@@ -154,15 +163,20 @@ document.addEventListener("click", async (e) => {
154163

155164
const nextUrl = new URL(el.href);
156165
try {
157-
maybeUpdateHistory(nextUrl);
166+
saveScrollPosition();
167+
maybePushHistory(nextUrl);
158168

159169
const partialUrl = new URL(
160170
partial ? partial : nextUrl.href,
161171
location.href,
162172
);
173+
let finalUrl = nextUrl;
163174
await withViewTransition(async () => {
164-
await fetchPartials(nextUrl, partialUrl, true);
165-
updateLinks(nextUrl);
175+
finalUrl = await fetchPartials(nextUrl, partialUrl);
176+
if (finalUrl.href !== nextUrl.href) {
177+
maybeReplaceHistory(finalUrl);
178+
}
179+
updateLinks(finalUrl);
166180
});
167181
scrollTo({ left: 0, top: 0, behavior: "instant" });
168182
} finally {
@@ -185,7 +199,7 @@ document.addEventListener("click", async (e) => {
185199
partial,
186200
location.href,
187201
);
188-
await fetchPartials(partialUrl, partialUrl, false);
202+
await fetchPartials(partialUrl, partialUrl);
189203
}
190204
}
191205
});
@@ -223,8 +237,11 @@ addEventListener("popstate", async (e) => {
223237
const url = new URL(location.href, location.origin);
224238
try {
225239
await withViewTransition(async () => {
226-
await fetchPartials(url, url, true);
227-
updateLinks(url);
240+
const finalUrl = await fetchPartials(url, url);
241+
if (finalUrl.href !== url.href) {
242+
maybeReplaceHistory(finalUrl);
243+
}
244+
updateLinks(finalUrl);
228245
});
229246
scrollTo({
230247
left: state.scrollX ?? 0,
@@ -304,8 +321,10 @@ document.addEventListener("submit", async (e) => {
304321
}
305322

306323
try {
324+
saveScrollPosition();
307325
await withViewTransition(async () => {
308-
await fetchPartials(actionUrl, partialUrl, true, init);
326+
const finalUrl = await fetchPartials(actionUrl, partialUrl, init);
327+
maybePushHistory(finalUrl);
309328
});
310329
} finally {
311330
if (indicator !== undefined) {
@@ -347,9 +366,8 @@ function updateLinks(url: URL) {
347366
async function fetchPartials(
348367
actualUrl: URL,
349368
partialUrl: URL,
350-
shouldNavigate: boolean,
351369
init: RequestInit = {},
352-
) {
370+
): Promise<URL> {
353371
init.redirect = "follow";
354372
partialUrl = new URL(partialUrl);
355373
partialUrl.searchParams.set(PARTIAL_SEARCH_PARAM, "true");
@@ -361,6 +379,7 @@ async function fetchPartials(
361379
actualUrl = nextUrl;
362380
}
363381
}
382+
actualUrl.searchParams.delete(PARTIAL_SEARCH_PARAM);
364383

365384
try {
366385
await applyPartials(res);
@@ -369,14 +388,12 @@ async function fetchPartials(
369388
// to a full page navigation instead of silently failing.
370389
if (err instanceof NoPartialsError && res.redirected) {
371390
location.href = actualUrl.href;
372-
return;
391+
return actualUrl;
373392
}
374393
throw err;
375394
}
376395

377-
if (shouldNavigate) {
378-
maybeUpdateHistory(actualUrl);
379-
}
396+
return actualUrl;
380397
}
381398

382399
interface PartialReviveCtx {

0 commit comments

Comments
 (0)