You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/04 19:10:27 UTC
(maka) 01/01: fix(ui): hear the reader when resize lands before scroll
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch cursor/transcript-pin-resize-race-6558
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 3da20728afee59c6b9c2c2459597bbeca82f6808
Author: AstroHan <le...@outlook.com>
AuthorDate: Sat Sep 5 03:10:16 2026 +0800
fix(ui): hear the reader when resize lands before scroll
ResizeObserver wrote the tail whenever the pin was still on. A reader
who had already moved scrollTop — with the scroll event still in flight —
was yanked back. That is the Storybook smoke flake on
ReaderScrolledUpIsNotPulledBack, and a real path: uncovering estimated
Turns is itself a layout pass.
The classifier that already distinguishes content from the reader on
scroll events now runs on resize too. It is not the write-echo check:
growth under a pin leaves scrollTop on lastWrittenTop until the follow
write happens.
Generated-by: Cursor Grok 4.6
---
.../__tests__/transcript-scroll-authority.test.ts | 25 ++++++
packages/ui/src/transcript-scroll-authority.tsx | 98 +++++++++++++---------
2 files changed, 82 insertions(+), 41 deletions(-)
diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts
index 4debc43eaf..a93535c63b 100644
--- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts
+++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts
@@ -164,6 +164,31 @@ test('a scroll this authority did not write is the reader, and releases the tail
});
});
+test('a resize that lands before the reader\'s scroll event does not yank them back', () => {
+ withObservers((resize) => {
+ const root = fakeRoot();
+ const authority = createTranscriptScrollAuthority();
+ authority.attach(root as unknown as HTMLElement);
+ assert.equal(root.scrollTop, 2_400);
+
+ // The reader left the tail. The scroll event is still in flight, and a
+ // layout pass (content-visibility resolving a Turn they just uncovered)
+ // delivers ResizeObserver first. Writing the tail now would put them
+ // back where they just left.
+ root.scrollTop = 1_900;
+ resize();
+ assert.equal(root.scrollTop, 1_900);
+ assert.equal(authority.getSnapshot().pinned, false);
+
+ root.emitScroll();
+ assert.equal(authority.getSnapshot().pinned, false);
+
+ root.grow(500);
+ resize();
+ assert.equal(root.scrollTop, 1_900);
+ });
+});
+
test('returning to the tail re-pins, and following resumes', () => {
withObservers((resize) => {
const root = fakeRoot();
diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx
index f4de10b70c..399c77c09c 100644
--- a/packages/ui/src/transcript-scroll-authority.tsx
+++ b/packages/ui/src/transcript-scroll-authority.tsx
@@ -158,6 +158,45 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority {
publish();
};
+ const recordGeometry = (target: HTMLElement): void => {
+ lastScrollHeight = target.scrollHeight;
+ lastClientHeight = target.clientHeight;
+ lastScrollTop = target.scrollTop;
+ };
+
+ /**
+ * Whether the current offset left the band content can account for.
+ *
+ * Content moves `scrollTop` only by how much the end of the transcript
+ * moved: native anchoring for arrivals and removals above the reader, and
+ * the browser clamping an offset past the new end. Inside that band the
+ * offset changed and intent did not. Outside it, the move is the reader's
+ * — including during growth, which is why a streaming answer cannot hide a
+ * scroll up inside a changed `scrollHeight`.
+ *
+ * Does not consume the geometry. The caller records it after deciding
+ * whether this observation is a scroll echo, a content resize, or the
+ * reader.
+ */
+ const offsetIsTheReader = (target: HTMLElement): boolean => {
+ const contentDelta =
+ target.scrollHeight - target.clientHeight - (lastScrollHeight - lastClientHeight);
+ const explainedLow = Math.min(0, contentDelta);
+ const explainedHigh = Math.max(0, contentDelta);
+ const topDelta = target.scrollTop - lastScrollTop;
+ const unexplained = topDelta - Math.min(explainedHigh, Math.max(explainedLow, topDelta));
+ const slack = contentDelta === 0 ? 0 : GEOMETRY_ROUNDING_PX;
+ return Math.abs(unexplained) > slack;
+ };
+
+ const hearTheReader = (): void => {
+ const distance = distanceToTail();
+ awayFromTail = distance > BUTTON_THRESHOLD_PX;
+ pinned = distance <= PIN_THRESHOLD_PX;
+ publish();
+ for (const listener of [...readerListeners]) listener();
+ };
+
return {
attach(next) {
root = next;
@@ -171,54 +210,19 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority {
// `scroll` does not bubble, and there is no `wheel` listener to catch
// instead.
if (lastWrittenTop !== undefined && Math.abs(target.scrollTop - lastWrittenTop) < 1) {
- lastScrollHeight = target.scrollHeight;
- lastClientHeight = target.clientHeight;
- lastScrollTop = target.scrollTop;
+ recordGeometry(target);
return;
}
- // Content moves the offset too, and only ever by how much the end of
- // the transcript moved. Native anchoring answers content landing above
- // the reader by pushing the offset down by exactly what was inserted,
- // content leaving from above by pulling it up by exactly what went,
- // and a transcript that ends before the offset by clamping it to the
- // new end — every one of them somewhere between nothing and that whole
- // amount. Inside that band their offset changed and their intent did
- // not, so the pin must not be re-derived from where they now are, and
- // nobody may be told the reader asked for anything. The affordance
- // still follows the new distance, because that is a fact about the
- // viewport rather than about them.
- //
- // Outside it, the move is the reader's, and this may not be decided
- // from the geometry merely having changed. During growth it always
- // has, so a reader who scrolled while an answer streamed arrived
- // carrying a changed `scrollHeight` and was discarded along with it —
- // the pin stayed, and the next growth wrote the view back to the tail.
- // Scrolling away from a streaming answer is the one moment a reader
- // most needs to be believed.
- const maxScroll = target.scrollHeight - target.clientHeight;
- const contentDelta = maxScroll - (lastScrollHeight - lastClientHeight);
- const explainedLow = Math.min(0, contentDelta);
- const explainedHigh = Math.max(0, contentDelta);
- const topDelta = target.scrollTop - lastScrollTop;
- const unexplained = topDelta - Math.min(explainedHigh, Math.max(explainedLow, topDelta));
- const slack = contentDelta === 0 ? 0 : GEOMETRY_ROUNDING_PX;
- const readerMoved = Math.abs(unexplained) > slack;
- lastScrollHeight = target.scrollHeight;
- lastClientHeight = target.clientHeight;
- lastScrollTop = target.scrollTop;
- const distance = distanceToTail();
- awayFromTail = distance > BUTTON_THRESHOLD_PX;
+ const readerMoved = offsetIsTheReader(target);
+ recordGeometry(target);
if (!readerMoved) {
+ awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX;
publish();
return;
}
- pinned = distance <= PIN_THRESHOLD_PX;
- publish();
- for (const listener of [...readerListeners]) listener();
+ hearTheReader();
};
- lastScrollHeight = target.scrollHeight;
- lastClientHeight = target.clientHeight;
- lastScrollTop = target.scrollTop;
+ recordGeometry(target);
target.addEventListener('scroll', onScroll, { passive: true });
// Everything that moves the tail without the reader asking, watched in
// one place: the scroller's own box, because the tail also moves when the
@@ -232,7 +236,19 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority {
// an observer that knows which nodes matter is an observer that can be
// wrong about it.
const box = new ResizeObserver(() => {
+ // Pin is only updated on scroll events. ResizeObserver often lands
+ // first — a reader uncovering estimated Turns is itself a layout
+ // change — and writing the tail while the scroll event is still in
+ // flight would put them back where they just left. The classifier
+ // is the same one the event will run; it is not an echo check,
+ // because growth under a pin leaves `scrollTop` on lastWrittenTop
+ // until this write happens.
if (pinned) {
+ if (offsetIsTheReader(target)) {
+ recordGeometry(target);
+ hearTheReader();
+ return;
+ }
writeToTail();
return;
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org