Skip to content

fix(frontend): fence transcript scrollbar transactions by generation / 滚动条事务按 generation 围栏,拖动中几何变化时重新对齐 - #9759

Merged
SivanCola merged 1 commit into
esengine:main-v2from
SivanCola:fix/transcript-scrollbar-generation-fence
Sep 3, 2026
Merged

fix(frontend): fence transcript scrollbar transactions by generation / 滚动条事务按 generation 围栏,拖动中几何变化时重新对齐#9759
SivanCola merged 1 commit into
esengine:main-v2from
SivanCola:fix/transcript-scrollbar-generation-fence

Conversation

@SivanCola

Copy link
Copy Markdown
Collaborator

Summary

Part of the #9711 report: "scrollbar buttons work only intermittently… jump incorrectly" and "the scrollbar could move independently of the viewport". Two scrollbar owners were fragile in the same way — they trusted geometry captured at the start of a gesture and relied on callers to end them.

Changes

Custom (creation-mode) scrollbaruseCreationTranscriptScrollbar.ts

  • A drag captures overflow / maxThumbTop / thumbHeight at pointerdown. When content grows or the viewport resizes mid-drag, the mapping is now rebased at the current physical scroll ratio and the last pointer sample (rebaseFrozenScrollbarDrag), so the next pointer move writes against the live extent and the thumb does not visibly jump.
  • The rail-click settle loop waits for two quiet geometry frames, bounded by a 1000 ms wall-clock budget (RAIL_SETTLE_BUDGET_MS, same magnitude as the arbiter's mount budget) rather than spinning while geometry keeps changing.

Native scrollbaruseTranscriptNativeScrollbarOwnership.ts

  • Each thumb transaction records the surface generation it started in. A stale-generation observe() ends native ownership without claiming the tail; a stale finish() clears the DOM marker and reports no transaction; isActive() is false. On the current base every generation bump already cancels the transaction first, so this turns an ordering invariant into a data invariant rather than fixing a reproduced defect.
  • useTranscriptScrollArbiter.ts passes generationRef (one-line plumbing).

Verification

  • transcript-native-scrollbar-generation.test.tsx (new, 14 assertions): same-generation forward progress still claims the tail; stale observe / stale finish with and without a prior observe clean up silently. Four assertions fail against the previous hook.
  • creation-transcript-scrollbar.test.ts: rebase preserves the physical ratio; subsequent movement uses the current extent.
  • transcript-reader-extent-race (native-thumb section), test:transcript, typecheck, test:typecheck, lint:hooks, check:scroll-writer, repolint, git diff --check: pass.
  • Bundle budgets step to measured values: gzip 463.102 KiB (462.9 → 463.2), raw 2470.932 KiB (2469.9 → 2471.0).
  • Not covered here: native WebView2/WKWebView smoke. This PR does not touch the reader-extent, corridor, or question-jump paths that the Stabilize transcript scrolling and Markdown surfaces / 稳定 transcript 滚动与 Markdown 展示 #9754 smoke failures implicate.

Relationship to #9754

The generation fence and drag rebase are extracted from #9754 with their tests. The settle deadline is new. The remaining #9754 scroll-ownership changes (corridor policy, question-jump landing, anchor-compensation gating) need rework and will follow separately.

Documentation-impact: none - scrollbar interaction fixes; no documented behavior changes.

Problem (esengine#9711): scrollbar interaction fights the app's own scroll
handling. Dragging the custom scrollbar while rows are still mounting
writes through a mapping frozen at pointerdown, so the next pointer move
lands on a stale extent. A native-thumb transaction is only ended by call
ordering (cancel before generation bump), not by the transaction itself.

Root cause: the custom-scrollbar drag captures overflow/maxThumbTop once
and never rebases when geometry changes mid-drag. The native-thumb
transaction carries no generation, so observe()/finish() cannot tell a
stale transaction from a live one and rely on every generation-advancing
caller to cancel first.

Fix:
- useCreationTranscriptScrollbar: rebase the frozen drag mapping at the
  current physical ratio whenever overflow/maxThumbTop/thumbHeight change,
  keeping the last pointer sample so the thumb does not visibly jump.
  The rail-click settle loop waits for two quiet frames but is bounded by
  a 1000 ms wall-clock budget (RAIL_SETTLE_BUDGET_MS) instead of spinning
  while geometry keeps changing.
- useTranscriptNativeScrollbarOwnership: bind each transaction to the
  surface generation. A stale-generation observe() ends ownership without
  claiming the tail; a stale finish() clears the marker and reports no
  transaction; isActive() is false. The arbiter passes generationRef.

Verification:
- transcript-native-scrollbar-generation.test.tsx (new): 14 assertions
  covering same-generation claim-tail, stale observe, stale finish with and
  without prior observe. Four fail against the previous hook.
- creation-transcript-scrollbar.test.ts: rebase preserves the physical
  ratio and later movement uses the current extent.
- transcript-reader-extent-race (native-thumb section), test:transcript,
  typecheck, test:typecheck, lint:hooks, check:scroll-writer, repolint,
  git diff --check pass.
- Bundle: gzip 463.102 KiB (budget 462.9 -> 463.2), raw 2470.932 KiB
  (2469.9 -> 2471.0).

The generation fence and drag rebase are extracted from esengine#9754; the settle
deadline is new.
@SivanCola
SivanCola requested a review from esengine as a code owner September 3, 2026 08:36
@github-actions github-actions Bot added v2 Go rewrite (1.x) — main-v2 branch, active development desktop Wails desktop app (desktop/**) labels Sep 3, 2026
@SivanCola
SivanCola merged commit c9e81de into esengine:main-v2 Sep 3, 2026
42 of 43 checks passed
SivanCola added a commit to SivanCola/DeepSeek-Reasonix that referenced this pull request Sep 3, 2026
Problem (esengine#9711): scrolling up through a long Markdown answer skips the
viewport thousands of pixels to another place in the document. The field
diagnostics (stable d9cd713, Windows, reducedMotion=false, all 138 rows
mounted) show eight upward wheel transactions where scrollHeight grows by
G, scrollTop moves by exactly +G with bottomDistance unchanged, and the
reader guard then fires restore-anchor and rewrites scrollTop to its
pre-growth value (19140.95 reappears one frame after a 26393 sample).

Root cause: the reader's transaction anchor is a row's top edge. When the
answer's block window prepends older blocks inside that row above the
visible blocks, MarkdownHistory compensates scrollTop by the same amount
so nothing visible moves. The anchor row's top edge is now G px higher
relative to the viewport and scrollTop moved against the reader, so the
guard reads a reverse displacement and "restores" the anchor, which
scrolls the reader G px up into the newly inserted blocks. That restore is
the reported jump. The guard's positive translateY also extends the
scroller's overflow, which is why extentDelta reads 2G for one frame.

Fix: the arbiter measures the scrollTop delta an accepted
block-window-prepend write produced and hands it to the reader hook's new
absorbOffsetWrite(). The active transaction re-baselines baselineTop,
lastAcceptedTop, expectedTop and anchor.offset to the compensated position
and resets its extent baselines to the grown scrollHeight, so neither the
scrollTop step nor the anchor-edge shift counts as displacement. Real
displacements after the absorption are still detected.

Verification:
- transcript-reader-visual-guard-race.test.tsx replays the field shape
  (row starting 1,450px above the viewport, 7,252px in-row prepend with
  exact compensation). On the previous head it records one anomaly, one
  visual guard, one reader-stability correction and rewinds scrollTop to
  19204; on this head no anomaly, no guard, no correction, scrollTop
  stays compensated, and a later 700px real displacement is still caught.
- transcript-reader-extent-race, native-scrollbar-generation,
  anchor-compensation-race, history-prepend-race, scroll-release,
  markdown-history, recovery-race, test:transcript, test:motion,
  typecheck, test:typecheck, lint:hooks, check:scroll-writer, repolint,
  git diff --check pass on the tree rebased onto esengine#9759.
- Bundle on the rebased tree: gzip 463.292 KiB (budget 463.2 -> 463.4,
  one cross-platform decimal of headroom over an 8-byte margin), raw
  2471.741 KiB (2471.0 -> 2471.8).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

desktop Wails desktop app (desktop/**) v2 Go rewrite (1.x) — main-v2 branch, active development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant