From 9ae87090ede6620d091aa4e45fd3be398cc8e5db Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 24 Oct 2026 04:33:49 +0200 Subject: [PATCH 1/4] fix: preserve reading position through delayed feed media --- KNOWLEDGE_BASE.md | 2 +- resources/js/pages/log/show.tsx | 175 ++++++++++++++++++++----------- tests/Unit/LogInsertion.test.tsx | 152 +++++++++++++++++++++++---- 3 files changed, 244 insertions(+), 85 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index d7b5e774e..3c8421cdc 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -153,7 +153,7 @@ Sources: [follow import](app/Actions/CreateUserGitHubFollows.php), [suggestions] Logs are the app's timelines. **Following** contains your commits and those of developers you follow, newest first; it requires sign-in. **Latest** contains the newest public commits across the app. **Trending** favors recent public commits that attract engagement from distinct other developers across the thread, with replies and quotes weighing more than stars and diminishing returns from additional engagement. Pastéis don't affect Trending. On the web, Trending reshuffles each time you open it: higher-scoring commits are more likely to appear first, but the order varies between visits and the same draw is used while you scroll. The signed API does not redraw on each visit. -Logs list top-level commits, including quotes, rather than every reply, 20 at a time as you scroll, and show your own pending commits to you. Following and Latest check for new commits every 30 seconds and mark where you left off. Following and Latest load new commits automatically. At the top, they animate into view without a notification. If you have scrolled down, your reading position stays still and a **New commits** control appears only after new commits are loaded above you. It previews up to three distinct authors from the new commits waiting above you, with a +N circle for additional authors. Each check removes deleted or no-longer-visible arrivals from the announcement and loaded feed; the control disappears if none remain. Select it to scroll to the new commits; it disappears when you reach them. On touch screens, swipe sideways to move between logs. An empty Following log suggests three recent commits from people to follow. Muted and blocked people are filtered for signed-in viewers. The last log you opened is remembered. +Logs list top-level commits, including quotes, rather than every reply, 20 at a time as you scroll, and show your own pending commits to you. Following and Latest check for new commits every 30 seconds and mark where you left off. Following and Latest load new commits automatically. New arrivals stay above the content you are reading, even when you start at the top or their images and link previews finish loading later. A **New commits** control appears after they load above you. It previews up to three distinct authors from the new commits waiting above you, with a +N circle for additional authors. Each check removes deleted or no-longer-visible arrivals from the announcement and loaded feed; the control disappears if none remain. Select it to scroll to the new commits; it disappears when you reach them. On touch screens, swipe sideways to move between logs. An empty Following log suggests three recent commits from people to follow. Muted and blocked people are filtered for signed-in viewers. The last log you opened is remembered. Browser Back and Forward restore expanded replies and loaded thread continuations. Offscreen commits are measured before scroll restoration so returning to a log or discussion keeps the content in place. diff --git a/resources/js/pages/log/show.tsx b/resources/js/pages/log/show.tsx index fed5ade38..072811d65 100644 --- a/resources/js/pages/log/show.tsx +++ b/resources/js/pages/log/show.tsx @@ -64,11 +64,20 @@ export default function Show({ const tabsRef = useRef(null); const [tabsHeight, setTabsHeight] = useState(0); const feedRef = useRef(null); - const followAnchor = useRef<{ row: HTMLElement; top: number } | null>(null); + const followAnchor = useRef<{ + row: HTMLElement; + top: number; + documentTop: number; + scrollY: number; + } | null>(null); const [composer, setComposer] = useState(null); useLaneSwipe(feedRef, log); useComposerVisibility(composer, tabsHeight); + useLayoutEffect(() => { + followAnchor.current = null; + }, [log]); + useEffect(() => { const tabs = tabsRef.current; @@ -86,21 +95,104 @@ export default function Show({ return () => observer.disconnect(); }, []); - const preserveReadingPosition = useCallback((ids: Set) => { - const headerBottom = - tabsRef.current?.getBoundingClientRect().bottom ?? 0; - const rows = - feedRef.current?.querySelectorAll('[data-commit]') ?? - []; - const row = Array.from(rows).find( - (row) => - ids.has(row.dataset.commit ?? '') && - row.getBoundingClientRect().bottom > headerBottom, - ); - followAnchor.current = row - ? { row, top: row.getBoundingClientRect().top } - : null; - }, []); + const preserveReadingPosition = useCallback( + (ids: Set) => { + const headerBottom = + tabsRef.current?.getBoundingClientRect().bottom ?? 0; + const focused = document.activeElement?.closest( + 'input, textarea, [contenteditable="true"]', + ); + const rows = + feedRef.current?.querySelectorAll( + '[data-commit]', + ) ?? []; + const row = + focused && + composer?.contains(focused) && + focused.getBoundingClientRect().bottom > headerBottom && + focused.getBoundingClientRect().top < window.innerHeight + ? focused + : Array.from(rows).find( + (row) => + ids.has(row.dataset.commit ?? '') && + row.getBoundingClientRect().bottom > headerBottom, + ); + followAnchor.current = row + ? { + row, + top: row.getBoundingClientRect().top, + documentTop: + row.getBoundingClientRect().top + window.scrollY, + scrollY: window.scrollY, + } + : null; + }, + [composer], + ); + + const restoreReadingPosition = useCallback(() => { + const anchor = followAnchor.current; + if (!anchor?.row.isConnected) return; + + const top = anchor.row.getBoundingClientRect().top; + const documentTop = top + window.scrollY; + + // A scroll moves the viewport; loading above the row moves its document position. + if ( + Math.abs(documentTop - anchor.documentTop) < 0.01 && + window.scrollY !== anchor.scrollY + ) { + preserveReadingPosition( + new Set(commits.data.map((commit) => commit.id)), + ); + return; + } + + if (top !== anchor.top) window.scrollBy(0, top - anchor.top); + anchor.documentTop = + anchor.row.getBoundingClientRect().top + window.scrollY; + anchor.scrollY = window.scrollY; + }, [commits, preserveReadingPosition]); + + useLayoutEffect(() => { + const feed = feedRef.current; + if (!feed || isRanked) return; + + // Keep one owner of scroll compensation while previews and offscreen cards settle. + const style = document.documentElement.style; + const previous = style.overflowAnchor; + style.overflowAnchor = 'none'; + const observer = new ResizeObserver(restoreReadingPosition); + observer.observe(feed); + const mutations = new MutationObserver(restoreReadingPosition); + mutations.observe(feed, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + attributeFilter: ['style'], + }); + const updateFocus = () => { + if (followAnchor.current) + preserveReadingPosition( + new Set(commits.data.map((commit) => commit.id)), + ); + }; + window.addEventListener('focusin', updateFocus); + window.addEventListener('focusout', updateFocus); + window.addEventListener('scroll', restoreReadingPosition, { + passive: true, + }); + + return () => { + observer.disconnect(); + mutations.disconnect(); + window.removeEventListener('focusin', updateFocus); + window.removeEventListener('focusout', updateFocus); + window.removeEventListener('scroll', restoreReadingPosition); + style.overflowAnchor = previous; + }; + }, [commits, isRanked, preserveReadingPosition, restoreReadingPosition]); useEffect( () => @@ -114,7 +206,6 @@ export default function Show({ } const incoming = page.props.commits as CursorPaginated; - followAnchor.current = null; if (incoming === commits) { return; @@ -128,11 +219,6 @@ export default function Show({ .map((commit) => commit.id) .filter((id) => !known.has(id)), ]); - - // Someone reading further down keeps their place, while someone at the top watches the commits arrive. - if (window.scrollY <= 0) { - return; - } } preserveReadingPosition( @@ -142,17 +228,7 @@ export default function Show({ [log, commits, preserveReadingPosition], ); - useLayoutEffect(() => { - const anchor = followAnchor.current; - followAnchor.current = null; - - if (anchor?.row.isConnected) { - window.scrollBy( - 0, - anchor.row.getBoundingClientRect().top - anchor.top, - ); - } - }, [commits]); + useLayoutEffect(restoreReadingPosition, [restoreReadingPosition]); useLayoutEffect(() => { const ids = freshIds.current; @@ -165,36 +241,9 @@ export default function Show({ feedRef.current?.querySelectorAll('[data-commit]') ?? [], ).filter((row) => ids.has(row.dataset.commit ?? '')); - const reduced = window.matchMedia( - '(prefers-reduced-motion: reduce)', - ).matches; - - rows.filter((row) => row.dataset.fresh === undefined).forEach( - (row, index) => { - row.dataset.fresh = ''; - - if (!reduced && window.scrollY <= 0) { - row.animate( - [ - { - opacity: 0, - transform: 'translateY(-12px)', - }, - { - opacity: 1, - transform: 'none', - }, - ], - { - duration: 520, - delay: index * 140, - easing: 'cubic-bezier(0.22, 1, 0.36, 1)', - fill: 'backwards', - }, - ); - } - }, - ); + rows.filter((row) => row.dataset.fresh === undefined).forEach((row) => { + row.dataset.fresh = ''; + }); // They stay lit until they are on screen, then while nobody is touching the page, and fade soon after the pointer // starts moving. Commits that arrived while the reader was further down wait for them to scroll back up. diff --git a/tests/Unit/LogInsertion.test.tsx b/tests/Unit/LogInsertion.test.tsx index a26c1ada5..afc08d957 100644 --- a/tests/Unit/LogInsertion.test.tsx +++ b/tests/Unit/LogInsertion.test.tsx @@ -131,7 +131,9 @@ function insertAt( authors: User[] = [], log: Log = 'latest', total = 3, + focused = false, ) { + if (focused) viewer = { id: 'reader' } as User; slots = []; listeners.clear(); reload.mockClear(); @@ -139,6 +141,15 @@ function insertAt( let scrollY = offset; let rowPosition = offset + 80; const animate = vi.fn(); + const input = { + isConnected: true, + getBoundingClientRect: () => ({ + top: 60 - scrollY, + bottom: 100 - scrollY, + }), + closest: () => input, + }; + const composer = { contains: (element: unknown) => element === input }; const existing = { dataset: { commit: 'existing' }, isConnected: true, @@ -159,12 +170,16 @@ function insertAt( })); let rows: unknown[] = [existing]; const scrollListeners: (() => void)[] = []; + const focusListeners: (() => void)[] = []; + const resizeListeners: (() => void)[] = []; + const mutationListeners: (() => void)[] = []; const intersections: { callback: (entries: unknown[]) => void; disconnected: boolean; observed: Set; }[] = []; vi.stubGlobal('window', { + innerHeight: 900, get scrollY() { return scrollY; }, @@ -177,12 +192,36 @@ function insertAt( matchMedia: () => ({ matches: reduced }), addEventListener: (name: string, listener: () => void) => { if (name === 'scroll') scrollListeners.push(listener); + if (name === 'focusin' || name === 'focusout') + focusListeners.push(listener); }, - removeEventListener: vi.fn(), + removeEventListener: (name: string, listener: () => void) => { + const callbacks = + name === 'scroll' ? scrollListeners : focusListeners; + const index = callbacks.indexOf(listener); + if (index !== -1) callbacks.splice(index, 1); + }, + }); + vi.stubGlobal('document', { + activeElement: focused ? input : null, + documentElement: { style: { overflowAnchor: '' } }, }); + vi.stubGlobal( + 'MutationObserver', + class { + constructor(callback: () => void) { + mutationListeners.push(callback); + } + observe() {} + disconnect() {} + }, + ); vi.stubGlobal( 'ResizeObserver', class { + constructor(callback: () => void) { + resizeListeners.push(callback); + } observe() {} disconnect() {} }, @@ -226,6 +265,10 @@ function insertAt( }), ); for (const node of nodes) { + if (typeof node.props.ref === 'function') { + node.props.ref(composer); + continue; + } if (typeof node.props.className !== 'string' || !node.props.ref) continue; const ref = node.props.ref as React.RefObject; @@ -295,6 +338,32 @@ function insertAt( data: () => current.data, request: () => pollOptions(), readingTop: () => existing.getBoundingClientRect().top, + composerTop: () => input.getBoundingClientRect().top, + blur: () => { + Object.defineProperty(document, 'activeElement', { + value: null, + configurable: true, + }); + focusListeners.forEach((listener) => listener()); + }, + refreshCounts: () => + listeners.get('beforeUpdate')!({ + detail: { + page: { + component: 'log/show', + props: { commits: current }, + scrollProps: { commits: { reset: true } }, + }, + }, + }), + mutateAbove: (height: number) => { + rowPosition += height; + mutationListeners.forEach((listener) => listener()); + }, + resizeAbove: (height: number) => { + rowPosition += height; + resizeListeners.forEach((listener) => listener()); + }, render: () => render(current), paginate: () => { current = { @@ -339,24 +408,14 @@ for (const offset of [1, 20, 39, 40, 80, 500]) { }); } -test('animates arriving commits at the top without scrolling the page', () => { - const result = insertAt(0); - - assert.equal(result.scrollY, 0); - assert.equal(result.animate.mock.calls.length, 3); - for (const [frames] of result.animate.mock.calls) { - assert.ok( - frames.every((frame: Keyframe) => frame.height === undefined), - ); - } -}); - -test('respects reduced motion at the top', () => { - const result = insertAt(0, true); - - assert.equal(result.scrollY, 0); - assert.equal(result.animate.mock.calls.length, 0); -}); +for (const reduced of [false, true]) { + test(`preserves the reading row at the top with reduced motion ${reduced}`, () => { + const result = insertAt(0, reduced); + assert.equal(result.top, 80); + assert.equal(result.scrollY, 360); + assert.equal(result.animate.mock.calls.length, 0); + }); +} test('keeps the loaded control while new commits are above a shallowly scrolled reader', () => { const result = insertAt(20); @@ -387,11 +446,11 @@ for (const log of ['following', 'latest'] as const) { (button.props.onClick as () => void)(); assert.equal(reload.mock.calls.length, requests); }); - test(`does not show a prompt at the top of ${log}`, () => { + test(`offers the arrivals inserted above the reader who started at the top of ${log}`, () => { const result = insertAt(0, false, [], log); assert.equal( result.finish().some((node) => node.type === 'button'), - false, + true, ); }); test(`does not announce an unsuccessful ${log} reload`, () => { @@ -647,3 +706,54 @@ test('reconciles removals and new arrivals in the same polling cycle', () => { 'new-2', ]); }); + +for (const log of ['latest', 'following'] as const) { + test(`keeps the reading row still after delayed preview and image resizing on ${log}`, () => { + const result = insertAt(80, false, [], log); + result.finish(); + result.refreshCounts(); + result.mutateAbove(20.75); + assert.equal(result.readingTop(), 80); + result.resizeAbove(256); + assert.equal(result.readingTop(), 80); + result.resizeAbove(-100); + assert.equal(result.readingTop(), 80); + }); + + test(`follows deliberate scrolling before preserving later media resizing on ${log}`, () => { + const result = insertAt(80, false, [], log); + result.finish(); + result.scrollTo(300); + const top = result.readingTop(); + result.resizeAbove(60); + assert.equal(result.readingTop(), top); + result.scrollTo(420); + const next = result.readingTop(); + result.resizeAbove(90); + assert.equal(result.readingTop(), next); + }); +} + +test('keeps a visible focused composer in place while arrivals and previews load below it', () => { + const result = insertAt(0, false, [], 'latest', 3, true); + result.finish(); + assert.equal(result.composerTop(), 60); + result.mutateAbove(80); + assert.equal(result.composerTop(), 60); +}); + +test('anchors the reading row after a focused composer has scrolled out of view', () => { + const result = insertAt(500, false, [], 'latest', 3, true); + result.finish(); + result.resizeAbove(60); + assert.equal(result.readingTop(), 80); +}); + +test('returns the anchor to the feed when the reader leaves the composer', () => { + const result = insertAt(0, false, [], 'latest', 3, true); + result.finish(); + result.blur(); + const top = result.readingTop(); + result.mutateAbove(80); + assert.equal(result.readingTop(), top); +}); From 74bffc66a68646a75c460d41c87d151ebab3ff6a Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 24 Oct 2026 04:39:45 +0200 Subject: [PATCH 2/4] fix: retain unseen commit notices near the top --- KNOWLEDGE_BASE.md | 2 +- resources/js/pages/log/show.tsx | 40 +++++++++++--- tests/Unit/LogInsertion.test.tsx | 90 +++++++++++++++++++++++++++++++- 3 files changed, 121 insertions(+), 11 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 3c8421cdc..bdecdd45d 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -153,7 +153,7 @@ Sources: [follow import](app/Actions/CreateUserGitHubFollows.php), [suggestions] Logs are the app's timelines. **Following** contains your commits and those of developers you follow, newest first; it requires sign-in. **Latest** contains the newest public commits across the app. **Trending** favors recent public commits that attract engagement from distinct other developers across the thread, with replies and quotes weighing more than stars and diminishing returns from additional engagement. Pastéis don't affect Trending. On the web, Trending reshuffles each time you open it: higher-scoring commits are more likely to appear first, but the order varies between visits and the same draw is used while you scroll. The signed API does not redraw on each visit. -Logs list top-level commits, including quotes, rather than every reply, 20 at a time as you scroll, and show your own pending commits to you. Following and Latest check for new commits every 30 seconds and mark where you left off. Following and Latest load new commits automatically. New arrivals stay above the content you are reading, even when you start at the top or their images and link previews finish loading later. A **New commits** control appears after they load above you. It previews up to three distinct authors from the new commits waiting above you, with a +N circle for additional authors. Each check removes deleted or no-longer-visible arrivals from the announcement and loaded feed; the control disappears if none remain. Select it to scroll to the new commits; it disappears when you reach them. On touch screens, swipe sideways to move between logs. An empty Following log suggests three recent commits from people to follow. Muted and blocked people are filtered for signed-in viewers. The last log you opened is remembered. +Logs list top-level commits, including quotes, rather than every reply, 20 at a time as you scroll, and show your own pending commits to you. Following and Latest check for new commits every 30 seconds and mark where you left off. Following and Latest load new commits automatically. New arrivals stay above the content you are reading, even when you start at the top or their images and link previews finish loading later. A **New commits** control appears after they load above you and stays until you return to the top or see the newest arrival below the sticky tabs. Switching away from the tab does not mark arrivals as seen. It previews up to three distinct authors from the new commits waiting above you, with a +N circle for additional authors. Each check removes deleted or no-longer-visible arrivals from the announcement and loaded feed; the control disappears if none remain. Select it to scroll to the new commits; it disappears when you reach them. On touch screens, swipe sideways to move between logs. An empty Following log suggests three recent commits from people to follow. Muted and blocked people are filtered for signed-in viewers. The last log you opened is remembered. Browser Back and Forward restore expanded replies and loaded thread continuations. Offscreen commits are measured before scroll restoration so returning to a log or discussion keeps the content in place. diff --git a/resources/js/pages/log/show.tsx b/resources/js/pages/log/show.tsx index 072811d65..58a126a9f 100644 --- a/resources/js/pages/log/show.tsx +++ b/resources/js/pages/log/show.tsx @@ -329,7 +329,12 @@ export default function Show({ !known.has(commit.id) && commit.user_id !== user?.id, ); - if (window.scrollY <= 0 || arrivals.length === 0) return; + if ( + arrivals.length === 0 || + (document.visibilityState === 'visible' && + window.scrollY <= 0) + ) + return; setNotice((current) => ({ commits: [ @@ -437,15 +442,29 @@ export default function Show({ const observer = new IntersectionObserver( (entries) => { - if (entries.some((entry) => entry.isIntersecting)) + if ( + document.visibilityState === 'visible' && + entries.some((entry) => entry.intersectionRatio >= 0.6) + ) dismissNotice(); }, - { threshold: 0.6 }, + { + rootMargin: `-${tabsHeight}px 0px 0px 0px`, + threshold: 0.6, + }, ); - observer.observe(row); + const update = () => { + observer.disconnect(); + if (document.visibilityState === 'visible') observer.observe(row); + }; + update(); + document.addEventListener('visibilitychange', update); - return () => observer.disconnect(); - }, [noticeNewestId, commits, dismissNotice]); + return () => { + observer.disconnect(); + document.removeEventListener('visibilitychange', update); + }; + }, [noticeNewestId, commits, tabsHeight, dismissNotice]); useEffect( () => () => { @@ -456,11 +475,16 @@ export default function Show({ useEffect(() => { const update = () => { - if (window.scrollY <= 0) dismissNotice(); + if (document.visibilityState === 'visible' && window.scrollY <= 0) + dismissNotice(); }; window.addEventListener('scroll', update, { passive: true }); + document.addEventListener('visibilitychange', update); - return () => window.removeEventListener('scroll', update); + return () => { + window.removeEventListener('scroll', update); + document.removeEventListener('visibilitychange', update); + }; }, [dismissNotice]); const authors = [ diff --git a/tests/Unit/LogInsertion.test.tsx b/tests/Unit/LogInsertion.test.tsx index afc08d957..c755d6da4 100644 --- a/tests/Unit/LogInsertion.test.tsx +++ b/tests/Unit/LogInsertion.test.tsx @@ -173,6 +173,7 @@ function insertAt( const focusListeners: (() => void)[] = []; const resizeListeners: (() => void)[] = []; const mutationListeners: (() => void)[] = []; + const visibilityListeners: (() => void)[] = []; const intersections: { callback: (entries: unknown[]) => void; disconnected: boolean; @@ -204,6 +205,13 @@ function insertAt( }); vi.stubGlobal('document', { activeElement: focused ? input : null, + visibilityState: 'visible', + addEventListener: (_name: string, listener: () => void) => + visibilityListeners.push(listener), + removeEventListener: (_name: string, listener: () => void) => { + const i = visibilityListeners.indexOf(listener); + if (i !== -1) visibilityListeners.splice(i, 1); + }, documentElement: { style: { overflowAnchor: '' } }, }); vi.stubGlobal( @@ -243,10 +251,12 @@ function insertAt( intersections.push(this.state); } observe(row: unknown) { + this.state.disconnected = false; this.state.observed.add(row); } disconnect() { this.state.disconnected = true; + this.state.observed.clear(); } }, ); @@ -377,14 +387,26 @@ function insertAt( scrollListeners.forEach((listener) => listener()); render(current); }, - sight: (index: number) => { + visibility: (state: 'hidden' | 'visible') => { + Object.defineProperty(document, 'visibilityState', { + value: state, + configurable: true, + }); + visibilityListeners.forEach((listener) => listener()); + render(current); + }, + sight: (index: number, ratio = 1) => { for (const observer of intersections) { if ( !observer.disconnected && observer.observed.has(fresh[index]) ) observer.callback([ - { target: fresh[index], isIntersecting: true }, + { + target: fresh[index], + isIntersecting: ratio > 0, + intersectionRatio: ratio, + }, ]); } render(current); @@ -580,6 +602,70 @@ test('keeps the prompt until the newest arriving row is visible', () => { ); }); +test('keeps the announcement when less than 60 percent of the newest row is visible', () => { + const result = insertAt(80); + result.finish(); + result.sight(0, 0.31); + vi.advanceTimersByTime(8000); + assert.equal( + result.render().some((node) => node.type === 'button'), + true, + ); + result.sight(0, 0.6); + vi.advanceTimersByTime(450); + assert.equal( + result.render().some((node) => node.type === 'button'), + false, + ); +}); + +test('keeps background arrivals announced until they are seen in the visible tab', () => { + const result = insertAt(80); + result.visibility('hidden'); + result.finish(); + result.sight(0); + vi.advanceTimersByTime(8000); + assert.equal( + result.render().some((node) => node.type === 'button'), + true, + ); + result.visibility('visible'); + result.sight(0, 0.31); + vi.advanceTimersByTime(450); + assert.equal( + result.render().some((node) => node.type === 'button'), + true, + ); + result.sight(0); + vi.advanceTimersByTime(450); + assert.equal( + result.render().some((node) => node.type === 'button'), + false, + ); +}); + +test('retains background arrivals when a focused composer keeps the viewport at the top', () => { + const result = insertAt(0, false, [], 'latest', 1, true); + result.visibility('hidden'); + result.finish(); + assert.equal( + result.render().some((node) => node.type === 'button'), + true, + ); + result.scrollTo(0); + vi.advanceTimersByTime(450); + assert.equal( + result.render().some((node) => node.type === 'button'), + true, + ); + result.visibility('visible'); + vi.advanceTimersByTime(450); + assert.equal( + result.render().some((node) => node.type === 'button'), + false, + ); +}); + test('still dismisses on seeing the newest commit after older highlights expire', () => { const result = insertAt(500); result.finish(); From 6fdf742a784f3b46d4eed9be9fbb1dfce095b97f Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 24 Oct 2026 04:49:11 +0200 Subject: [PATCH 3/4] fix: release reading anchor during smooth return --- resources/js/pages/log/show.tsx | 28 +++++++++++++++++++-- tests/Unit/LogInsertion.test.tsx | 42 ++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/resources/js/pages/log/show.tsx b/resources/js/pages/log/show.tsx index 58a126a9f..6f9a280d1 100644 --- a/resources/js/pages/log/show.tsx +++ b/resources/js/pages/log/show.tsx @@ -70,12 +70,14 @@ export default function Show({ documentTop: number; scrollY: number; } | null>(null); + const returningToTop = useRef(false); const [composer, setComposer] = useState(null); useLaneSwipe(feedRef, log); useComposerVisibility(composer, tabsHeight); useLayoutEffect(() => { followAnchor.current = null; + returningToTop.current = false; }, [log]); useEffect(() => { @@ -97,6 +99,7 @@ export default function Show({ const preserveReadingPosition = useCallback( (ids: Set) => { + if (returningToTop.current) return; const headerBottom = tabsRef.current?.getBoundingClientRect().bottom ?? 0; const focused = document.activeElement?.closest( @@ -131,6 +134,7 @@ export default function Show({ ); const restoreReadingPosition = useCallback(() => { + if (returningToTop.current) return; const anchor = followAnchor.current; if (!anchor?.row.isConnected) return; @@ -178,18 +182,36 @@ export default function Show({ new Set(commits.data.map((commit) => commit.id)), ); }; + let scrollTimer: ReturnType | undefined; + const updateScroll = () => { + if (!returningToTop.current) { + restoreReadingPosition(); + return; + } + + // Smooth scrolling owns the viewport until it finishes or the reader interrupts it. + clearTimeout(scrollTimer); + scrollTimer = setTimeout(() => { + returningToTop.current = false; + preserveReadingPosition( + new Set(commits.data.map((commit) => commit.id)), + ); + }, 150); + }; window.addEventListener('focusin', updateFocus); window.addEventListener('focusout', updateFocus); - window.addEventListener('scroll', restoreReadingPosition, { + window.addEventListener('scroll', updateScroll, { passive: true, }); + if (returningToTop.current) updateScroll(); return () => { observer.disconnect(); mutations.disconnect(); + clearTimeout(scrollTimer); window.removeEventListener('focusin', updateFocus); window.removeEventListener('focusout', updateFocus); - window.removeEventListener('scroll', restoreReadingPosition); + window.removeEventListener('scroll', updateScroll); style.overflowAnchor = previous; }; }, [commits, isRanked, preserveReadingPosition, restoreReadingPosition]); @@ -540,6 +562,8 @@ export default function Show({