Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions frontend/src/lib/composables/createScrollAnchor.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
16 changes: 16 additions & 0 deletions frontend/src/lib/composables/createScrollAnchor.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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;
}

Expand Down