From b617c1f88e635279abda377f8c5b6f03dfe7c151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 14:04:31 +0100 Subject: [PATCH] fix(shell): stop restoring the list's offset once the person scrolls On Back, the shell set the list's saved offset again on every frame while the list was still shorter than that offset. Someone who started scrolling in that time was pulled back, and a list whose data took more than a second ended up where the skeleton allowed. The restore now stops at the first wheel, touch, key or pointer input and keeps trying for up to three seconds. --- .../auth-shell-scroll.browser.test.tsx | 30 +++++++++++++++++++ src/components/layout/auth-shell.tsx | 13 ++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/components/layout/__tests__/auth-shell-scroll.browser.test.tsx b/src/components/layout/__tests__/auth-shell-scroll.browser.test.tsx index ec8a64d4b..70760cefa 100644 --- a/src/components/layout/__tests__/auth-shell-scroll.browser.test.tsx +++ b/src/components/layout/__tests__/auth-shell-scroll.browser.test.tsx @@ -113,4 +113,34 @@ describe("AuthShell scroll position", () => { .toBeTruthy(); await expect.poll(() => main().scrollTop).toBe(1500); }); + + it("stops pulling back once the person scrolls on a list that is still loading", async () => { + stubFetch(); + window.history.replaceState(null, "", "/labs"); + await renderWithApp( + + + , + { user: { id: "signed-in" } }, + ); + await expect + .poll(() => document.querySelector("[data-loaded]")) + .toBeTruthy(); + main().scrollTop = 1500; + await nextFrames(); + await openPage("/labs/biomarker-1"); + + window.history.back(); + await expect + .poll(() => document.querySelector('[data-path="/labs"]')) + .toBeTruthy(); + // While the list is still its short skeleton, the person scrolls. + window.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 })); + await expect + .poll(() => document.querySelector("[data-loaded]")) + .toBeTruthy(); + await nextFrames(); + + expect(main().scrollTop).toBe(0); + }); }); diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index a53ea8b43..df9bc0f95 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -129,17 +129,24 @@ function useShellScrollMemory(pathname: string): void { } if (!main) return; // A list still painting its skeleton is shorter than the offset; keep - // trying until it has the height, for at most a second. - const until = performance.now() + 1000; + // trying until it has the height, for at most three seconds, and stop as + // soon as the person scrolls themselves. + const until = performance.now() + 3000; let frame = 0; + const stop = () => cancelAnimationFrame(frame); + const inputs = ["wheel", "touchstart", "keydown", "pointerdown"]; const apply = () => { main.scrollTop = target.top; if (main.scrollTop < target.top - 1 && performance.now() < until) { frame = requestAnimationFrame(apply); } }; + for (const type of inputs) window.addEventListener(type, stop, true); apply(); - return () => cancelAnimationFrame(frame); + return () => { + stop(); + for (const type of inputs) window.removeEventListener(type, stop, true); + }; }, [pathname]); }