diff --git a/frontend/src/lib/composables/createScrollAnchor.svelte.test.ts b/frontend/src/lib/composables/createScrollAnchor.svelte.test.ts index 60aa3a08..daf9835d 100644 --- a/frontend/src/lib/composables/createScrollAnchor.svelte.test.ts +++ b/frontend/src/lib/composables/createScrollAnchor.svelte.test.ts @@ -587,4 +587,81 @@ describe("createScrollAnchor final content", () => { expect(scrollAnchor.showIndicator).toBe(true); scrollAnchor.cleanup(); }); + + it("respects a no-intent scroll-away following a no-op send scroll", async () => { + vi.useFakeTimers(); + const scrollContainer = document.createElement("div"); + setScrollGeometry(scrollContainer, 0, 500, 500); + const scrollToSpy = vi.spyOn(scrollContainer, "scrollTo"); + const scrollAnchor = createScrollAnchor({ indicatorDelayMs: 150 }); + scrollAnchor.attach(scrollContainer); + + await scrollAnchor.scrollOnce(); + scrollToSpy.mockClear(); + + setScrollGeometry(scrollContainer, 200, 1_000, 500); + scrollAnchor.onUserScroll(); + + await scrollAnchor.revealFinalContentIfFollowing(); + vi.advanceTimersByTime(150); + + expect(scrollToSpy).not.toHaveBeenCalled(); + expect(scrollAnchor.unseenCount).toBe(1); + expect(scrollAnchor.showIndicator).toBe(true); + scrollAnchor.cleanup(); + }); + + describe("movedDuringScroll accumulator (multi-pass)", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("pass-0 no-op then pass-1 real move keeps the flag raised (shields in-flight scroll)", async () => { + vi.useFakeTimers(); + const el = document.createElement("div"); + let calls = 0; + Object.defineProperties(el, { + scrollTop: { configurable: true, value: 0 }, + clientHeight: { configurable: true, value: 500 }, + scrollHeight: { configurable: true, get: () => (calls++ === 0 ? 500 : 1000) }, + }); + const spy = vi.spyOn(el, "scrollTo"); + const a = createScrollAnchor({ indicatorDelayMs: 150 }); + a.attach(el); + await a.scrollOnce(); + spy.mockClear(); + Object.defineProperty(el, "scrollTop", { configurable: true, value: 200 }); + a.onUserScroll(); + await a.revealFinalContentIfFollowing(); + vi.advanceTimersByTime(150); + expect(spy).toHaveBeenCalled(); + a.cleanup(); + }); + + it("every-pass no-op clears the flag so a later no-intent scroll disengages follow", async () => { + vi.useFakeTimers(); + const el = document.createElement("div"); + Object.defineProperties(el, { + scrollTop: { configurable: true, value: 0 }, + scrollHeight: { configurable: true, value: 500 }, + clientHeight: { configurable: true, value: 500 }, + }); + const spy = vi.spyOn(el, "scrollTo"); + const a = createScrollAnchor({ indicatorDelayMs: 150 }); + a.attach(el); + await a.scrollOnce(); + spy.mockClear(); + Object.defineProperties(el, { + scrollTop: { configurable: true, value: 200 }, + scrollHeight: { configurable: true, value: 1000 }, + }); + a.onUserScroll(); + await a.revealFinalContentIfFollowing(); + vi.advanceTimersByTime(150); + expect(spy).not.toHaveBeenCalled(); + expect(a.unseenCount).toBe(1); + a.cleanup(); + }); + }); }); diff --git a/frontend/src/lib/composables/createScrollAnchor.svelte.ts b/frontend/src/lib/composables/createScrollAnchor.svelte.ts index 1a190fa5..e1289742 100644 --- a/frontend/src/lib/composables/createScrollAnchor.svelte.ts +++ b/frontend/src/lib/composables/createScrollAnchor.svelte.ts @@ -274,6 +274,16 @@ export function createScrollAnchor(options: ScrollAnchorOptions = {}) { const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; let performedScroll = false; let previousScrollHeight = -1; + // Sticky across passes: tracks whether *any* pass will actually move the + // container. A `scrollTo` whose target clamps to the current position is a + // no-op and fires no `scroll`/`scrollend` event, so nothing routes into + // `onUserScroll`'s near-bottom branch to clear `programmaticScrollActive` — + // the flag would stay stuck and later gate off the no-intent scroll-away + // branch (e.g. keyboard Tab focus into an off-screen link). Only keep the + // flag raised when this pass will really move; once any pass moves, leave + // the flag raised so the smooth scroll's in-flight events stay shielded + // (the near-bottom branch clears it once they settle). + let movedDuringScroll = false; for (let pass = 0; pass <= MAX_FINAL_REVEAL_RECONCILIATION_PASSES; pass++) { await tick(); if ( @@ -289,12 +299,18 @@ export function createScrollAnchor(options: ScrollAnchorOptions = {}) { } previousScrollHeight = currentScrollHeight; performedScroll = true; + if (scrollContainer.scrollTop + scrollContainer.clientHeight < currentScrollHeight) { + movedDuringScroll = true; + } programmaticScrollActive = true; scrollContainer.scrollTo({ top: currentScrollHeight, behavior: prefersReducedMotion ? "auto" : "smooth", }); } + if (!movedDuringScroll) { + programmaticScrollActive = false; + } return performedScroll; }