diff --git a/.lane/memory/packages/ui/src/components/scroll-view/scroll-view.tsx/01M5JPN2QNMG751NXC43TCM3SR-a-quiet-period-must-end-a-sc.md b/.lane/memory/packages/ui/src/components/scroll-view/scroll-view.tsx/01M5JPN2QNMG751NXC43TCM3SR-a-quiet-period-must-end-a-sc.md new file mode 100644 index 00000000..b5bb09d1 --- /dev/null +++ b/.lane/memory/packages/ui/src/components/scroll-view/scroll-view.tsx/01M5JPN2QNMG751NXC43TCM3SR-a-quiet-period-must-end-a-sc.md @@ -0,0 +1,12 @@ +--- +id: 01M5JPN2QNMG751NXC43TCM3SR +anchor: '@file' +created: 2026-10-22T21:00:43Z +norm: '1' +sig: e0cbdab303939db3 +body_hash: 6885f9b9dd17486d +raw_hash: 38eb59c30e3b5a2c +lines: 1-374 +--- + +a quiet period must end a scroll; browsers send no scrollend when they clamp scrollTop after content shrinks diff --git a/.lane/memory/packages/ui/src/components/timeline/layout.ts/01M53FDJQ4C0BGDGXNAY5GK09T-a-file-block-s-cache-key-inc.md b/.lane/memory/packages/ui/src/components/timeline/layout.ts/01M53FDJQ4C0BGDGXNAY5GK09T-a-file-block-s-cache-key-inc.md index e99a0f9c..3ba42a06 100644 --- a/.lane/memory/packages/ui/src/components/timeline/layout.ts/01M53FDJQ4C0BGDGXNAY5GK09T-a-file-block-s-cache-key-inc.md +++ b/.lane/memory/packages/ui/src/components/timeline/layout.ts/01M53FDJQ4C0BGDGXNAY5GK09T-a-file-block-s-cache-key-inc.md @@ -4,9 +4,9 @@ anchor: '@file' created: 2026-10-16T23:05:41Z norm: '1' sig: ce515648b5bedef2 -body_hash: a984910f8f8a9cdc -raw_hash: c0f95564f75550a7 -vouched: 2026-10-22T20:42:40Z +body_hash: 3696028d4b1ada45 +raw_hash: 67b90ab8452ae3e8 +vouched: 2026-10-22T21:00:46Z lines: 1-912 --- diff --git a/.lane/memory/packages/ui/src/components/timeline/layout.ts/01M5JPN2P38553WJGVMF742JC7-every-render-with-an-unparse.md b/.lane/memory/packages/ui/src/components/timeline/layout.ts/01M5JPN2P38553WJGVMF742JC7-every-render-with-an-unparse.md new file mode 100644 index 00000000..a7f73aa3 --- /dev/null +++ b/.lane/memory/packages/ui/src/components/timeline/layout.ts/01M5JPN2P38553WJGVMF742JC7-every-render-with-an-unparse.md @@ -0,0 +1,12 @@ +--- +id: 01M5JPN2P38553WJGVMF742JC7 +anchor: function textBlocks +created: 2026-10-22T21:00:43Z +norm: '1' +sig: 501d68b5e67bcf4e +body_hash: fd0944277f6addb6 +raw_hash: 64f19e54a7a5b9a1 +lines: 451-496 +--- + +every render with an unparsed chunk must queue its job, even one already in flight, or a cancelled effect leaves the old text on screen diff --git a/packages/ui/src/components/scroll-view/scroll-view.tsx b/packages/ui/src/components/scroll-view/scroll-view.tsx index efd4c3b2..07b03705 100644 --- a/packages/ui/src/components/scroll-view/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view/scroll-view.tsx @@ -15,6 +15,7 @@ import { cn } from "../../lib/utils"; const EMPTY: scroll.Frame = { items: [], total: 0 }; const INTENT = 500; +const QUIET = 200; /** Imperative handle exposed via ref. */ type ScrollViewHandle = { @@ -244,14 +245,20 @@ function ScrollView( project.current(true); }); }; + // Browsers send no scrollend when they clamp scrollTop after content shrinks, so a + // quiet period also ends a scroll; otherwise onScrollEnd never runs. + let quiet: ReturnType | undefined; let onscroll = () => { if (!scrolling.current) { scrolling.current = true; started.current?.(); } + clearTimeout(quiet); + quiet = setTimeout(done, QUIET); schedule(); }; let done = () => { + clearTimeout(quiet); if (!scrolling.current) return; scrolling.current = false; settled.current?.(); @@ -294,6 +301,7 @@ function ScrollView( return () => { active = false; + clearTimeout(quiet); observer.disconnect(); el.removeEventListener("scroll", onscroll); el.removeEventListener("scrollend", done); diff --git a/packages/ui/src/components/timeline/layout.ts b/packages/ui/src/components/timeline/layout.ts index 9fc92600..8badb432 100644 --- a/packages/ui/src/components/timeline/layout.ts +++ b/packages/ui/src/components/timeline/layout.ts @@ -476,7 +476,9 @@ function textBlocks( return hit; } - if (!parsed.get(k)?.promise && !seen.has(k) && (!streaming || !stream.has(slot))) { + // Queue in-flight parses too: start() reuses their promise, and this render's tick must not + // depend on an earlier render whose effect may already have been cancelled. + if (!seen.has(k)) { jobs.push({ key: k, raw: chunk.raw, sig, mentions, plan, compact, streaming, slot }); seen.add(k); }