dsh-smooth-stream 0.3.4 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,6 +27,8 @@
27
27
  */
28
28
 
29
29
  import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react'
30
+ import { DEFAULT_STREAM_DEBUG_TUNING, type StreamDebugTuning } from '../settings.ts'
31
+ import { debugRuntime } from './debugRuntime.ts'
30
32
 
31
33
  /**
32
34
  * Programmatic follow marker retained for hosts that recognize external
@@ -115,7 +117,10 @@ const GESTURE_EVENTS = [
115
117
  ] as const
116
118
 
117
119
  /** Visible runway needed for the current reveal pressure. */
118
- export function computeFollowReserve(speedCps: number, runwayPx = FOLLOW_STATUS_RUNWAY_PX): number {
120
+ export function computeFollowReserve(
121
+ speedCps: number,
122
+ runwayPx = FOLLOW_STATUS_RUNWAY_PX,
123
+ ): number {
119
124
  const available = Math.max(0, runwayPx)
120
125
  if (available <= 0) return 0
121
126
  if (speedCps <= FOLLOW_RESERVE_MIN_CPS) return 0
@@ -136,17 +141,18 @@ export function computeFollowRevealScale(
136
141
  lagPx: number,
137
142
  capacityPx: number,
138
143
  constrained = false,
144
+ tuning: StreamDebugTuning = DEFAULT_STREAM_DEBUG_TUNING,
139
145
  ): number {
140
- if (constrained) return FOLLOW_REVEAL_MIN_SCALE
146
+ if (constrained) return tuning.backpressureMinScale
141
147
  if (!Number.isFinite(capacityPx)) return 1
142
- if (capacityPx <= 0) return lagPx > 0 ? FOLLOW_REVEAL_MIN_SCALE : 1
148
+ if (capacityPx <= 0) return lagPx > 0 ? tuning.backpressureMinScale : 1
143
149
  const ratio = Math.min(1, Math.max(0, lagPx / capacityPx))
144
- if (ratio >= FOLLOW_BACKPRESSURE_FULL_RATIO) return FOLLOW_REVEAL_MIN_SCALE
150
+ if (ratio >= FOLLOW_BACKPRESSURE_FULL_RATIO) return tuning.backpressureMinScale
145
151
  const progress = Math.min(1, Math.max(0, (
146
152
  ratio - FOLLOW_BACKPRESSURE_START_RATIO
147
153
  ) / (FOLLOW_BACKPRESSURE_FULL_RATIO - FOLLOW_BACKPRESSURE_START_RATIO)))
148
154
  const eased = progress * progress * (3 - 2 * progress)
149
- return 1 - (1 - FOLLOW_REVEAL_MIN_SCALE) * eased
155
+ return 1 - (1 - tuning.backpressureMinScale) * eased
150
156
  }
151
157
 
152
158
  export interface FollowGlideInput {
@@ -173,7 +179,11 @@ export interface FollowGlideStep {
173
179
  * @param input - Current visible lag and carried physics velocity.
174
180
  * @returns The position advance, its fraction, and next velocity.
175
181
  */
176
- export function computeFollowStep(dtMs: number, input: FollowGlideInput): FollowGlideStep {
182
+ export function computeFollowStep(
183
+ dtMs: number,
184
+ input: FollowGlideInput,
185
+ tuning: StreamDebugTuning = DEFAULT_STREAM_DEBUG_TUNING,
186
+ ): FollowGlideStep {
177
187
  if (input.lag <= 0.1 || dtMs <= 0) {
178
188
  return { advancePx: 0, lerpStep: 0, velocityPxPerSec: 0 }
179
189
  }
@@ -186,8 +196,8 @@ export function computeFollowStep(dtMs: number, input: FollowGlideInput): Follow
186
196
  for (let slice = 0; slice < slices; slice += 1) {
187
197
  for (let substep = 0; substep < FOLLOW_SPRING_SUBSTEPS; substep += 1) {
188
198
  const acceleration = (
189
- FOLLOW_SPRING_STIFFNESS * lag - FOLLOW_SPRING_DAMPING * velocity
190
- ) / FOLLOW_SPRING_MASS
199
+ tuning.springStiffness * lag - tuning.springDamping * velocity
200
+ ) / tuning.springMass
191
201
  velocity = Math.max(0, velocity + acceleration * subDt)
192
202
  const advance = velocity * subDt
193
203
  if (advance >= lag) {
@@ -206,12 +216,36 @@ function resizeProxyOf(port: HTMLElement): HTMLElement | null {
206
216
  return port.querySelector('[data-chat-transcript]') ?? port.querySelector('[data-chat-flow]')
207
217
  }
208
218
 
209
- /** Outermost message surfaces; nested tool rows ride their parent. */
210
- function shiftSurfacesOf(port: HTMLElement): HTMLElement[] {
219
+ /**
220
+ * Outermost message surfaces; nested tool rows ride their parent.
221
+ *
222
+ * Another plugin may insert its own element as a flow sibling of the Chat rows
223
+ * (meow-memory's fold bar is one; so is this plugin's own fold summary row).
224
+ * Such a row carries no `data-chat-anchor-key`, so selecting only anchored
225
+ * rows would shift the conversation while leaving the foreign row at its
226
+ * natural offset, letting the shifted rows paint over it. Every direct flow
227
+ * child therefore rides the same transform, keeping the visual order of the
228
+ * column intact.
229
+ */
230
+ export function shiftSurfacesOf(port: HTMLElement): HTMLElement[] {
211
231
  const transcript = port.querySelector<HTMLElement>('[data-chat-transcript]')
212
232
  if (transcript !== null) return [transcript]
213
- return [...port.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
233
+ const anchored = [...port.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
214
234
  .filter(row => row.parentElement?.closest('[data-chat-anchor-key]') === null)
235
+ const flow = port.querySelector<HTMLElement>('[data-chat-flow]')
236
+ if (flow === null) return anchored
237
+ const status = turnStatusOf(port)
238
+ const anchoredSet = new Set(anchored)
239
+ // One document-order pass: an anchored row, or a foreign child that contains
240
+ // no anchored row of its own (a wrapper around real rows would double-shift
241
+ // the rows inside it).
242
+ const __dbg = [...flow.children].filter((child): child is HTMLElement =>
243
+ child instanceof HTMLElement
244
+ && child !== status
245
+ && (anchoredSet.has(child) || child.querySelector('[data-chat-anchor-key]') === null))
246
+ console.log('[engine] reached end of shiftSurfacesOf, n=', __dbg.length)
247
+ ;(globalThis as Record<string, unknown>).__dshssProbe = __dbg.map(e => `${e.tagName}:${e.getAttribute('data-chat-anchor-key') ?? e.className}`)
248
+ return __dbg
215
249
  }
216
250
 
217
251
  function currentShiftOf(element: HTMLElement): number {
@@ -267,6 +301,7 @@ interface FollowRunway {
267
301
  readonly offset: number
268
302
  readonly original: string
269
303
  readonly property: 'marginBottom' | 'marginTop'
304
+ readonly requestedPx: number
270
305
  }
271
306
 
272
307
  /**
@@ -369,7 +404,11 @@ function migrateLegacyRunway(
369
404
  if (migrated) invalidatePaintLimit(port)
370
405
  }
371
406
 
372
- function ensureRunway(port: HTMLElement, surfaces: readonly HTMLElement[]): void {
407
+ function ensureRunway(
408
+ port: HTMLElement,
409
+ surfaces: readonly HTMLElement[],
410
+ runwayPx = FOLLOW_STATUS_RUNWAY_PX,
411
+ ): void {
373
412
  const status = turnStatusOf(port)
374
413
  const composer = port.querySelector<HTMLElement>('[data-composer-seat]')
375
414
  migrateLegacyRunway(port, surfaces, status, composer)
@@ -378,7 +417,7 @@ function ensureRunway(port: HTMLElement, surfaces: readonly HTMLElement[]): void
378
417
  // applyVisual keeps every surface in normal flow, so adding status margin
379
418
  // would expose the whole runway as empty space below a short/early Think.
380
419
  const naturalHeight = Math.max(0, port.scrollHeight - runwayOffsetOf(port))
381
- if (port.clientHeight <= 0 || naturalHeight <= port.clientHeight) {
420
+ if (runwayPx <= 0 || port.clientHeight <= 0 || naturalHeight <= port.clientHeight) {
382
421
  restoreRunway(port)
383
422
  return
384
423
  }
@@ -391,15 +430,17 @@ function ensureRunway(port: HTMLElement, surfaces: readonly HTMLElement[]): void
391
430
  }
392
431
  const element = target.element
393
432
  const current = followRunways.get(port)
394
- if (current?.element === element && current.property === target.property) return
433
+ if (current?.element === element
434
+ && current.property === target.property
435
+ && current.requestedPx === runwayPx) return
395
436
  restoreRunway(port)
396
437
  const beforeHeight = port.scrollHeight
397
438
  const original = element.style[target.property]
398
439
  element.style[target.property] = original === ''
399
- ? `${FOLLOW_STATUS_RUNWAY_PX}px`
400
- : `calc(${original} + ${FOLLOW_STATUS_RUNWAY_PX}px)`
440
+ ? `${runwayPx}px`
441
+ : `calc(${original} + ${runwayPx}px)`
401
442
  const offset = Math.max(0, port.scrollHeight - beforeHeight)
402
- followRunways.set(port, { element, offset, property: target.property, original })
443
+ followRunways.set(port, { element, offset, property: target.property, original, requestedPx: runwayPx })
403
444
  invalidatePaintLimit(port)
404
445
  }
405
446
 
@@ -499,9 +540,10 @@ function applyVisual(
499
540
  animatedH: number,
500
541
  reservePx: number,
501
542
  velocityPxPerSec = 0,
543
+ runwayPx = FOLLOW_STATUS_RUNWAY_PX,
502
544
  ): number {
503
545
  const surfaces = shiftSurfacesOf(port)
504
- ensureRunway(port, surfaces)
546
+ ensureRunway(port, surfaces, runwayPx)
505
547
  const contentHeight = Math.max(0, port.scrollHeight)
506
548
  const runwayOffset = runwayOffsetOf(port)
507
549
  const targetHeight = Math.max(0, contentHeight - runwayOffset)
@@ -680,11 +722,12 @@ export function useConversationFollow(
680
722
 
681
723
  const updateRevealScale = (next: HTMLElement, elapsedMs: number, urgent = false): void => {
682
724
  if (revealScaleRef === undefined) return
725
+ const tuning = debugRuntime.activeTuning()
683
726
  const state = followMotionStates.get(next)
684
727
  const target = state === undefined
685
728
  ? 1
686
- : computeFollowRevealScale(state.lagPx, state.capacityPx, state.constrained)
687
- const current = Math.min(1, Math.max(FOLLOW_REVEAL_MIN_SCALE, revealScaleRef.current))
729
+ : computeFollowRevealScale(state.lagPx, state.capacityPx, state.constrained, tuning)
730
+ const current = Math.min(1, Math.max(tuning.backpressureMinScale, revealScaleRef.current))
688
731
  if (target < current || urgent) {
689
732
  // Slowing affects only future glyph commits, so it can react at once
690
733
  // without producing a visual discontinuity in the current frame.
@@ -699,6 +742,23 @@ export function useConversationFollow(
699
742
  if (revealScaleRef !== undefined) revealScaleRef.current = 1
700
743
  }
701
744
 
745
+ const reportFollow = (next: HTMLElement, isActive: boolean): void => {
746
+ const state = followMotionStates.get(next)
747
+ debugRuntime.reportFollow(next, {
748
+ lagPx: state?.lagPx ?? Math.max(0, next.scrollHeight - next.clientHeight - next.scrollTop),
749
+ velocityPxPerSec: state?.velocityPxPerSec ?? 0,
750
+ reservePx: state?.reservePx ?? 0,
751
+ capacityPx: state?.capacityPx ?? 0,
752
+ revealScale: revealScaleRef?.current ?? 1,
753
+ following,
754
+ constrained: state?.constrained ?? false,
755
+ scrollTop: next.scrollTop,
756
+ scrollHeight: next.scrollHeight,
757
+ clientHeight: next.clientHeight,
758
+ active: isActive,
759
+ })
760
+ }
761
+
702
762
  const isLeader = (next: HTMLElement): boolean => followLeaders.get(next)?.owner === owner
703
763
 
704
764
  const hold = (next: HTMLElement): void => {
@@ -717,12 +777,19 @@ export function useConversationFollow(
717
777
  clearMotion(next)
718
778
  followMotionStates.delete(next)
719
779
  releaseRevealScale()
780
+ debugRuntime.reportFollow(next, null)
720
781
  }
721
782
  }
722
783
 
723
784
  const handBackVisual = (next: HTMLElement): void => {
724
785
  const shift = currentShiftOf(shiftSurfacesOf(next).at(-1) ?? next)
725
786
  const visualTop = Math.max(0, next.scrollTop - Math.max(0, shift))
787
+ // The predictive runway only has meaning while this follower owns the
788
+ // floor. Remove it before choosing the reader's landing point; keeping
789
+ // it through the release paints a transient natural gap + 48px blank
790
+ // block until the next follow frame restores an equal transform.
791
+ clearMotion(next)
792
+ restoreRunway(next)
726
793
  const floor = Math.max(0, next.scrollHeight - next.clientHeight)
727
794
  // The host keeps its own bottom-follow bit while the reader remains in
728
795
  // its 25px slack band. Land one pixel beyond that band so even a light
@@ -760,8 +827,10 @@ export function useConversationFollow(
760
827
  const restoreBeforePaint = (): void => {
761
828
  if (!following || port === null || !isLeader(port)) return
762
829
  invalidatePaintLimit(port)
763
- animatedH = applyVisual(port, animatedH, reservePx, velocityPxPerSec)
830
+ const tuning = debugRuntime.activeTuning()
831
+ animatedH = applyVisual(port, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
764
832
  updateRevealScale(port, 0, true)
833
+ reportFollow(port, activeRef.current)
765
834
  }
766
835
 
767
836
  const bindPort = (next: HTMLElement): void => {
@@ -790,6 +859,7 @@ export function useConversationFollow(
790
859
  // frames would open paint room more slowly precisely when it is needed.
791
860
  const elapsedMs = Math.max(0.001, now - last)
792
861
  const dt = Math.min(FOLLOW_MAX_FRAME_MS, elapsedMs)
862
+ const tuning = debugRuntime.activeTuning()
793
863
  last = now
794
864
  const root = rootRef.current
795
865
  if (root === null) return
@@ -836,8 +906,9 @@ export function useConversationFollow(
836
906
  if (following) {
837
907
  hold(nextPort)
838
908
  if (isLeader(nextPort)) {
839
- animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec)
909
+ animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
840
910
  updateRevealScale(nextPort, elapsedMs)
911
+ reportFollow(nextPort, activeRef.current)
841
912
  const runwayOffset = runwayOffsetOf(nextPort)
842
913
  const entranceLag = Math.max(0, nextPort.scrollHeight - animatedH - runwayOffset)
843
914
  if (entranceLag <= FOLLOW_SETTLE_EPSILON_PX) finishEntrance()
@@ -852,7 +923,10 @@ export function useConversationFollow(
852
923
  }
853
924
 
854
925
  const repinSlack = readerReleased ? FOLLOW_REPIN_PX : FOLLOW_SLACK_PX
855
- if (!following && !interacting && reportedLag <= repinSlack) {
926
+ const returnedToFloor = readerReleased
927
+ && !readerGestureIntent
928
+ && reportedLag <= FOLLOW_REPIN_PX
929
+ if (!following && (!interacting || returnedToFloor) && reportedLag <= repinSlack) {
856
930
  following = true
857
931
  readerReleased = false
858
932
  animatedH = extent
@@ -876,6 +950,7 @@ export function useConversationFollow(
876
950
 
877
951
  if (!activeRef.current || !following) {
878
952
  followScrollLedgers.set(nextPort, nextPort.scrollTop)
953
+ reportFollow(nextPort, activeRef.current)
879
954
  return
880
955
  }
881
956
  hold(nextPort)
@@ -893,14 +968,14 @@ export function useConversationFollow(
893
968
  const predictGrowth = predictiveRef?.current ?? predictive
894
969
  const reserveTarget = !predictGrowth
895
970
  ? 0
896
- : computeFollowReserve(speedCpsRef.current, runwayOffset)
897
- const reserveStep = 1 - Math.exp(-elapsedMs / FOLLOW_RESERVE_RESPONSE_MS)
971
+ : computeFollowReserve(speedCpsRef.current, tuning.runwayPx)
972
+ const reserveStep = 1 - Math.exp(-elapsedMs / tuning.reserveResponseMs)
898
973
  reservePx += (reserveTarget - reservePx) * reserveStep
899
974
  const step = computeFollowStep(dt, {
900
975
  lag,
901
976
  speedEma: speedCpsRef.current,
902
977
  velocityPxPerSec,
903
- })
978
+ }, tuning)
904
979
  if (lag <= 0.1) {
905
980
  animatedH = contentHeight - runwayOffset
906
981
  velocityPxPerSec = 0
@@ -912,8 +987,9 @@ export function useConversationFollow(
912
987
  )
913
988
  velocityPxPerSec = step.velocityPxPerSec
914
989
  }
915
- animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec)
990
+ animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
916
991
  updateRevealScale(nextPort, elapsedMs)
992
+ reportFollow(nextPort, true)
917
993
  const remainingEntranceLag = Math.max(
918
994
  0,
919
995
  nextPort.scrollHeight - animatedH - runwayOffsetOf(nextPort),
@@ -943,6 +1019,7 @@ export function useConversationFollow(
943
1019
  clearVisual(host)
944
1020
  followLeaders.delete(host)
945
1021
  releaseRevealScale()
1022
+ debugRuntime.reportFollow(host, null)
946
1023
  return
947
1024
  }
948
1025
  if (preserveReader) {
@@ -950,13 +1027,15 @@ export function useConversationFollow(
950
1027
  clearVisual(host)
951
1028
  followLeaders.delete(host)
952
1029
  releaseRevealScale()
1030
+ debugRuntime.reportFollow(host, null)
953
1031
  return
954
1032
  }
955
1033
 
956
1034
  // Completion can land the final Tool/command height in this same
957
1035
  // commit. Preserve the logical extent and drain it after unmount instead
958
1036
  // of clearing the compositor state before the first settled paint.
959
- ensureRunway(host, shiftSurfacesOf(host))
1037
+ const completionTuning = debugRuntime.activeTuning()
1038
+ ensureRunway(host, shiftSurfacesOf(host), completionTuning.runwayPx)
960
1039
  const completionRunway = runwayOffsetOf(host)
961
1040
  const completionMinimumLag = Math.max(0, reservePx)
962
1041
  animatedH = Math.min(
@@ -964,13 +1043,15 @@ export function useConversationFollow(
964
1043
  host.scrollHeight - completionRunway - completionMinimumLag,
965
1044
  )
966
1045
  settleAtFloor(host)
967
- animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec)
1046
+ animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, completionTuning.runwayPx)
1047
+ reportFollow(host, false)
968
1048
  const runwayOffset = runwayOffsetOf(host)
969
1049
  const remainingLag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
970
1050
  if (remainingLag <= FOLLOW_SETTLE_EPSILON_PX && reservePx <= FOLLOW_SETTLE_EPSILON_PX) {
971
1051
  finishAtNaturalFloor(host)
972
1052
  followLeaders.delete(host)
973
1053
  releaseRevealScale()
1054
+ debugRuntime.reportFollow(host, null)
974
1055
  return
975
1056
  }
976
1057
 
@@ -996,14 +1077,16 @@ export function useConversationFollow(
996
1077
  clearVisual(host)
997
1078
  followLeaders.delete(host)
998
1079
  releaseRevealScale()
1080
+ debugRuntime.reportFollow(host, null)
999
1081
  stopSettleListeners()
1000
1082
  return
1001
1083
  }
1002
1084
  const dt = Math.min(FOLLOW_MAX_FRAME_MS, Math.max(0, now - settleLast))
1085
+ const tuning = debugRuntime.activeTuning()
1003
1086
  settleLast = now
1004
1087
  const runwayOffset = runwayOffsetOf(host)
1005
1088
  const lag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
1006
- const reserveStep = 1 - Math.exp(-dt / FOLLOW_RESERVE_RESPONSE_MS)
1089
+ const reserveStep = 1 - Math.exp(-dt / tuning.reserveResponseMs)
1007
1090
  reservePx += (0 - reservePx) * reserveStep
1008
1091
  if (lag <= FOLLOW_SETTLE_EPSILON_PX && reservePx <= FOLLOW_SETTLE_EPSILON_PX) {
1009
1092
  animatedH = host.scrollHeight - runwayOffset
@@ -1012,6 +1095,7 @@ export function useConversationFollow(
1012
1095
  finishAtNaturalFloor(host)
1013
1096
  followLeaders.delete(host)
1014
1097
  releaseRevealScale()
1098
+ debugRuntime.reportFollow(host, null)
1015
1099
  stopSettleListeners()
1016
1100
  return
1017
1101
  }
@@ -1019,7 +1103,7 @@ export function useConversationFollow(
1019
1103
  lag,
1020
1104
  speedEma: speedCpsRef.current,
1021
1105
  velocityPxPerSec,
1022
- })
1106
+ }, tuning)
1023
1107
  // The temporary runway is visually neutral only while an equal lag
1024
1108
  // remains in the transform. Do not let the spring outrun the runway's
1025
1109
  // closing reserve or cleanup would reveal an overshoot and rebound.
@@ -1030,7 +1114,8 @@ export function useConversationFollow(
1030
1114
  )
1031
1115
  velocityPxPerSec = step.velocityPxPerSec
1032
1116
  settleAtFloor(host)
1033
- animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec)
1117
+ animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
1118
+ reportFollow(host, false)
1034
1119
  requestAnimationFrame(settleFrame)
1035
1120
  }
1036
1121
  requestAnimationFrame(settleFrame)
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { useCallback, useEffect, useRef } from 'react'
12
+ import { debugRuntime } from './debugRuntime.ts'
12
13
 
13
14
  const FPS_THRESHOLD = 30
14
15
  const FPS_ALPHA = 0.12
@@ -33,6 +34,7 @@ export function useFpsGuard(active: boolean): {
33
34
  useEffect(() => {
34
35
  if (!active) return
35
36
  let rafId = 0
37
+ let lastDebugReport = 0
36
38
  const frame = (now: number) => {
37
39
  rafId = requestAnimationFrame(frame)
38
40
  const fps = fpsRef.current
@@ -51,11 +53,16 @@ export function useFpsGuard(active: boolean): {
51
53
  fps.healthyRun += 1
52
54
  if (fps.healthyRun >= RECOVER_FRAMES) fps.degraded = false
53
55
  }
56
+ if (now - lastDebugReport >= 100) {
57
+ debugRuntime.reportFps(currentFps, fps.emaMs, fps.degraded)
58
+ lastDebugReport = now
59
+ }
54
60
  }
55
61
  rafId = requestAnimationFrame(frame)
56
62
  return () => {
57
63
  cancelAnimationFrame(rafId)
58
64
  fpsRef.current = { emaMs: 0, lastMs: 0, healthyRun: 0, degraded: false }
65
+ debugRuntime.clearFps()
59
66
  }
60
67
  }, [active])
61
68
 
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { useLayoutEffect, type RefObject } from 'react'
12
12
  import { computeAdaptiveQueueStep } from './useSmoothStreamContent.ts'
13
+ import { debugRuntime } from './debugRuntime.ts'
13
14
 
14
15
  interface TextRevealRecord {
15
16
  chars: readonly string[]
@@ -17,6 +18,9 @@ interface TextRevealRecord {
17
18
  shown: number
18
19
  }
19
20
 
21
+ /** Last presented text per root, retained across follow lifecycle flips. */
22
+ const ledgerByRoot = new WeakMap<HTMLElement, Map<Text, TextRevealRecord>>()
23
+
20
24
  const SKIP_TEXT_SELECTOR = [
21
25
  '[aria-hidden="true"]',
22
26
  '[aria-live]',
@@ -55,11 +59,58 @@ export function useProgressiveDomText(
55
59
  onSettled?: (() => void) | undefined,
56
60
  ): void {
57
61
  useLayoutEffect(() => {
58
- if (!enabled) return
59
62
  const root = rootRef.current
60
63
  if (root === null || typeof document === 'undefined') return
61
64
 
62
- const records = new Map<Text, TextRevealRecord>()
65
+ let records = ledgerByRoot.get(root)
66
+ if (!enabled && records === undefined) return
67
+ if (records === undefined) {
68
+ records = new Map<Text, TextRevealRecord>()
69
+ ledgerByRoot.set(root, records)
70
+ }
71
+
72
+ const forEachText = (from: Node, callback: (text: Text) => void): void => {
73
+ if (from.nodeType === Node.TEXT_NODE) {
74
+ callback(from as Text)
75
+ return
76
+ }
77
+ const walker = document.createTreeWalker(from, NodeFilter.SHOW_TEXT)
78
+ let current = walker.nextNode()
79
+ while (current !== null) {
80
+ callback(current as Text)
81
+ current = walker.nextNode()
82
+ }
83
+ }
84
+
85
+ const settle = (text: Text): void => {
86
+ if (!revealable(text, root)) return
87
+ const chars = [...text.data]
88
+ records.set(text, { chars, full: text.data, shown: chars.length })
89
+ }
90
+
91
+ const snapshotVisible = (): void => {
92
+ const current = new Set<Text>()
93
+ forEachText(root, (text) => {
94
+ if (!revealable(text, root)) return
95
+ current.add(text)
96
+ settle(text)
97
+ })
98
+ for (const text of records.keys()) {
99
+ if (!current.has(text)) records.delete(text)
100
+ }
101
+ }
102
+
103
+ if (!enabled) {
104
+ snapshotVisible()
105
+ const observer = typeof MutationObserver === 'undefined'
106
+ ? null
107
+ : new MutationObserver(snapshotVisible)
108
+ observer?.observe(root, { childList: true, characterData: true, subtree: true })
109
+ return () => {
110
+ observer?.disconnect()
111
+ }
112
+ }
113
+
63
114
  const pending = new Set<Text>()
64
115
  const internalWrites = new WeakMap<Text, string>()
65
116
  let rafId = 0
@@ -67,11 +118,13 @@ export function useProgressiveDomText(
67
118
  let debt = 0
68
119
  let stopped = false
69
120
  let announcedSettled = false
121
+ const streamId = `dom-${Math.random().toString(36).slice(2)}`
70
122
 
71
123
  const announceSettled = (): void => {
72
124
  lastFrame = null
73
125
  debt = 0
74
126
  speedCpsRef.current = 35
127
+ debugRuntime.reportStream(streamId, null)
75
128
  if (announcedSettled) return
76
129
  announcedSettled = true
77
130
  onSettled?.()
@@ -101,18 +154,22 @@ export function useProgressiveDomText(
101
154
  }
102
155
 
103
156
  const visit = (from: Node, reveal: boolean): void => {
104
- if (from.nodeType === Node.TEXT_NODE) {
105
- const text = from as Text
106
- if (reveal) enqueue(text, text.data, records.get(text))
107
- return
108
- }
109
- const walker = document.createTreeWalker(from, NodeFilter.SHOW_TEXT)
110
- let current = walker.nextNode()
111
- while (current !== null) {
112
- const text = current as Text
113
- if (reveal) enqueue(text, text.data, records.get(text))
114
- current = walker.nextNode()
115
- }
157
+ forEachText(from, (text) => {
158
+ if (!revealable(text, root)) return
159
+ if (reveal) {
160
+ enqueue(text, text.data, records.get(text))
161
+ return
162
+ }
163
+ settle(text)
164
+ })
165
+ }
166
+
167
+ const forget = (from: Node): void => {
168
+ forEachText(from, (text) => {
169
+ if (root.contains(text)) return
170
+ records.delete(text)
171
+ pending.delete(text)
172
+ })
116
173
  }
117
174
 
118
175
  const scheduleFrame = (): void => {
@@ -140,7 +197,7 @@ export function useProgressiveDomText(
140
197
  const record = records.get(node)
141
198
  if (record !== undefined) backlog += record.chars.length - record.shown
142
199
  }
143
- const step = computeAdaptiveQueueStep(backlog, elapsed, debt)
200
+ const step = computeAdaptiveQueueStep(backlog, elapsed, debt, 1, debugRuntime.activeTuning())
144
201
  debt = step.debt
145
202
  speedCpsRef.current = step.speedCps
146
203
  let remaining = step.revealChars
@@ -149,6 +206,7 @@ export function useProgressiveDomText(
149
206
  const record = records.get(node)
150
207
  if (record === undefined || !node.isConnected) {
151
208
  pending.delete(node)
209
+ records.delete(node)
152
210
  continue
153
211
  }
154
212
  const amount = Math.min(remaining, record.chars.length - record.shown)
@@ -159,11 +217,31 @@ export function useProgressiveDomText(
159
217
  remaining -= amount
160
218
  if (shown >= next.chars.length) pending.delete(node)
161
219
  }
220
+ let targetChars = 0
221
+ let displayedChars = 0
222
+ let nextBacklog = 0
223
+ for (const [node, record] of records) {
224
+ if (!node.isConnected) {
225
+ records.delete(node)
226
+ pending.delete(node)
227
+ continue
228
+ }
229
+ targetChars += record.chars.length
230
+ displayedChars += record.shown
231
+ if (pending.has(node)) nextBacklog += record.chars.length - record.shown
232
+ }
233
+ debugRuntime.reportStream(streamId, {
234
+ backlog: nextBacklog,
235
+ speedCps: step.speedCps,
236
+ targetChars,
237
+ displayedChars,
238
+ active: pending.size > 0,
239
+ })
162
240
  if (pending.size === 0) announceSettled()
163
241
  else scheduleFrame()
164
242
  }
165
243
 
166
- if (revealInitial) visit(root, true)
244
+ visit(root, revealInitial)
167
245
 
168
246
  const observer = typeof MutationObserver === 'undefined'
169
247
  ? null
@@ -179,6 +257,7 @@ export function useProgressiveDomText(
179
257
  enqueue(node, node.data, records.get(node))
180
258
  continue
181
259
  }
260
+ for (const removed of mutation.removedNodes) forget(removed)
182
261
  for (const added of mutation.addedNodes) visit(added, true)
183
262
  }
184
263
  if (pending.size === 0) announceSettled()
@@ -197,10 +276,13 @@ export function useProgressiveDomText(
197
276
  // React may have committed a terminal replacement in the same mutation
198
277
  // phase that disables this hook. Never overwrite that newer renderer
199
278
  // value with the previous controlled source during layout cleanup.
200
- if (node.isConnected && node.data === controlled) write(node, record.full)
279
+ if (node.isConnected && node.data === controlled) {
280
+ write(node, record.full)
281
+ }
201
282
  }
202
283
  pending.clear()
203
284
  speedCpsRef.current = 35
285
+ debugRuntime.reportStream(streamId, null)
204
286
  }
205
287
  }, [enabled, onSettled, revealInitial, rootRef, speedCpsRef])
206
288
  }