dsh-smooth-stream 0.3.3 → 0.4.0

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,9 @@
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'
32
+ import { PROCESSED_CLASS } from './auto-collapse-controller.ts'
30
33
 
31
34
  /**
32
35
  * Programmatic follow marker retained for hosts that recognize external
@@ -115,7 +118,10 @@ const GESTURE_EVENTS = [
115
118
  ] as const
116
119
 
117
120
  /** Visible runway needed for the current reveal pressure. */
118
- export function computeFollowReserve(speedCps: number, runwayPx = FOLLOW_STATUS_RUNWAY_PX): number {
121
+ export function computeFollowReserve(
122
+ speedCps: number,
123
+ runwayPx = FOLLOW_STATUS_RUNWAY_PX,
124
+ ): number {
119
125
  const available = Math.max(0, runwayPx)
120
126
  if (available <= 0) return 0
121
127
  if (speedCps <= FOLLOW_RESERVE_MIN_CPS) return 0
@@ -136,17 +142,18 @@ export function computeFollowRevealScale(
136
142
  lagPx: number,
137
143
  capacityPx: number,
138
144
  constrained = false,
145
+ tuning: StreamDebugTuning = DEFAULT_STREAM_DEBUG_TUNING,
139
146
  ): number {
140
- if (constrained) return FOLLOW_REVEAL_MIN_SCALE
147
+ if (constrained) return tuning.backpressureMinScale
141
148
  if (!Number.isFinite(capacityPx)) return 1
142
- if (capacityPx <= 0) return lagPx > 0 ? FOLLOW_REVEAL_MIN_SCALE : 1
149
+ if (capacityPx <= 0) return lagPx > 0 ? tuning.backpressureMinScale : 1
143
150
  const ratio = Math.min(1, Math.max(0, lagPx / capacityPx))
144
- if (ratio >= FOLLOW_BACKPRESSURE_FULL_RATIO) return FOLLOW_REVEAL_MIN_SCALE
151
+ if (ratio >= FOLLOW_BACKPRESSURE_FULL_RATIO) return tuning.backpressureMinScale
145
152
  const progress = Math.min(1, Math.max(0, (
146
153
  ratio - FOLLOW_BACKPRESSURE_START_RATIO
147
154
  ) / (FOLLOW_BACKPRESSURE_FULL_RATIO - FOLLOW_BACKPRESSURE_START_RATIO)))
148
155
  const eased = progress * progress * (3 - 2 * progress)
149
- return 1 - (1 - FOLLOW_REVEAL_MIN_SCALE) * eased
156
+ return 1 - (1 - tuning.backpressureMinScale) * eased
150
157
  }
151
158
 
152
159
  export interface FollowGlideInput {
@@ -173,7 +180,11 @@ export interface FollowGlideStep {
173
180
  * @param input - Current visible lag and carried physics velocity.
174
181
  * @returns The position advance, its fraction, and next velocity.
175
182
  */
176
- export function computeFollowStep(dtMs: number, input: FollowGlideInput): FollowGlideStep {
183
+ export function computeFollowStep(
184
+ dtMs: number,
185
+ input: FollowGlideInput,
186
+ tuning: StreamDebugTuning = DEFAULT_STREAM_DEBUG_TUNING,
187
+ ): FollowGlideStep {
177
188
  if (input.lag <= 0.1 || dtMs <= 0) {
178
189
  return { advancePx: 0, lerpStep: 0, velocityPxPerSec: 0 }
179
190
  }
@@ -186,8 +197,8 @@ export function computeFollowStep(dtMs: number, input: FollowGlideInput): Follow
186
197
  for (let slice = 0; slice < slices; slice += 1) {
187
198
  for (let substep = 0; substep < FOLLOW_SPRING_SUBSTEPS; substep += 1) {
188
199
  const acceleration = (
189
- FOLLOW_SPRING_STIFFNESS * lag - FOLLOW_SPRING_DAMPING * velocity
190
- ) / FOLLOW_SPRING_MASS
200
+ tuning.springStiffness * lag - tuning.springDamping * velocity
201
+ ) / tuning.springMass
191
202
  velocity = Math.max(0, velocity + acceleration * subDt)
192
203
  const advance = velocity * subDt
193
204
  if (advance >= lag) {
@@ -207,10 +218,14 @@ function resizeProxyOf(port: HTMLElement): HTMLElement | null {
207
218
  }
208
219
 
209
220
  /** Outermost message surfaces; nested tool rows ride their parent. */
210
- function shiftSurfacesOf(port: HTMLElement): HTMLElement[] {
221
+ /** @internal Test seam: the equal-lag transform set for one scrollport. */
222
+ export function shiftSurfacesOf(port: HTMLElement): HTMLElement[] {
211
223
  const transcript = port.querySelector<HTMLElement>('[data-chat-transcript]')
212
224
  if (transcript !== null) return [transcript]
213
- return [...port.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
225
+ // Fold summary rows are plugin-injected flow children without an anchor
226
+ // key; they must ride the same lag transform as native rows, otherwise
227
+ // they visibly detach from their neighbours on every follow frame.
228
+ return [...port.querySelectorAll<HTMLElement>(`[data-chat-anchor-key], .${PROCESSED_CLASS}`)]
214
229
  .filter(row => row.parentElement?.closest('[data-chat-anchor-key]') === null)
215
230
  }
216
231
 
@@ -267,9 +282,21 @@ interface FollowRunway {
267
282
  readonly offset: number
268
283
  readonly original: string
269
284
  readonly property: 'marginBottom' | 'marginTop'
285
+ readonly requestedPx: number
270
286
  }
271
287
 
272
- const followRunways = new WeakMap<HTMLElement, FollowRunway>()
288
+ /**
289
+ * The plugin can be reinjected without replacing the conversation DOM. Keep
290
+ * runway ownership in the page realm so a fresh bundle adopts the existing
291
+ * margin instead of treating it as host layout and adding another 48px.
292
+ */
293
+ const FOLLOW_RUNWAYS_SYMBOL = Symbol.for('dsh-smooth-stream.follow-runways')
294
+ const followRunwayRegistry = globalThis as typeof globalThis & {
295
+ [key: symbol]: WeakMap<HTMLElement, FollowRunway> | undefined
296
+ }
297
+ const followRunways = followRunwayRegistry[FOLLOW_RUNWAYS_SYMBOL]
298
+ ?? new WeakMap<HTMLElement, FollowRunway>()
299
+ followRunwayRegistry[FOLLOW_RUNWAYS_SYMBOL] = followRunways
273
300
 
274
301
  interface FollowPaintLimit {
275
302
  readonly clientHeight: number
@@ -316,9 +343,65 @@ function restoreRunway(port: HTMLElement): void {
316
343
  invalidatePaintLimit(port)
317
344
  }
318
345
 
319
- function ensureRunway(port: HTMLElement, surfaces: readonly HTMLElement[]): void {
346
+ function isLegacyRunway(value: string): boolean {
347
+ if (value === '') return false
348
+ const terms = [...value.matchAll(/([\d.]+)px/g)]
349
+ if (terms.length === 0 || value.replaceAll(/calc|px|[\d.+()\s]/g, '') !== '') return false
350
+ return terms.every(([, raw]) => {
351
+ const px = Number(raw)
352
+ return Number.isFinite(px)
353
+ && px >= FOLLOW_STATUS_RUNWAY_PX
354
+ && Math.abs(px % FOLLOW_STATUS_RUNWAY_PX) <= Number.EPSILON
355
+ })
356
+ }
357
+
358
+ /** Remove unowned runway residue written by v0.3.3 and earlier bundles. */
359
+ function migrateLegacyRunway(
360
+ port: HTMLElement,
361
+ surfaces: readonly HTMLElement[],
362
+ status: HTMLElement | null,
363
+ composer: HTMLElement | null,
364
+ ): void {
365
+ if (followRunways.has(port)) return
366
+ let migrated = false
367
+ if (status !== null && isLegacyRunway(status.style.marginTop)) {
368
+ // Harness TurnStatus has no inline margin; exact 48px multiples here are
369
+ // values emitted by the old runway writer, including reload accumulation.
370
+ status.style.marginTop = ''
371
+ migrated = true
372
+ }
373
+ const last = surfaces.at(-1)
374
+ if (
375
+ status === null
376
+ && composer !== null
377
+ && last !== undefined
378
+ && isLegacyRunway(last.style.marginBottom)
379
+ ) {
380
+ // Without TurnStatus the old writer used the current final message as its
381
+ // completion runway. Limit migration to that same target topology.
382
+ last.style.marginBottom = ''
383
+ migrated = true
384
+ }
385
+ if (migrated) invalidatePaintLimit(port)
386
+ }
387
+
388
+ function ensureRunway(
389
+ port: HTMLElement,
390
+ surfaces: readonly HTMLElement[],
391
+ runwayPx = FOLLOW_STATUS_RUNWAY_PX,
392
+ ): void {
320
393
  const status = turnStatusOf(port)
321
394
  const composer = port.querySelector<HTMLElement>('[data-composer-seat]')
395
+ migrateLegacyRunway(port, surfaces, status, composer)
396
+ // A runway is useful only after the natural conversation already has a
397
+ // scroll floor for its equal message transform to ride. Before that point
398
+ // applyVisual keeps every surface in normal flow, so adding status margin
399
+ // would expose the whole runway as empty space below a short/early Think.
400
+ const naturalHeight = Math.max(0, port.scrollHeight - runwayOffsetOf(port))
401
+ if (runwayPx <= 0 || port.clientHeight <= 0 || naturalHeight <= port.clientHeight) {
402
+ restoreRunway(port)
403
+ return
404
+ }
322
405
  const target = status === null
323
406
  ? { element: composer === null ? undefined : surfaces.at(-1), property: 'marginBottom' as const }
324
407
  : { element: status, property: 'marginTop' as const }
@@ -328,15 +411,17 @@ function ensureRunway(port: HTMLElement, surfaces: readonly HTMLElement[]): void
328
411
  }
329
412
  const element = target.element
330
413
  const current = followRunways.get(port)
331
- if (current?.element === element && current.property === target.property) return
414
+ if (current?.element === element
415
+ && current.property === target.property
416
+ && current.requestedPx === runwayPx) return
332
417
  restoreRunway(port)
333
418
  const beforeHeight = port.scrollHeight
334
419
  const original = element.style[target.property]
335
420
  element.style[target.property] = original === ''
336
- ? `${FOLLOW_STATUS_RUNWAY_PX}px`
337
- : `calc(${original} + ${FOLLOW_STATUS_RUNWAY_PX}px)`
421
+ ? `${runwayPx}px`
422
+ : `calc(${original} + ${runwayPx}px)`
338
423
  const offset = Math.max(0, port.scrollHeight - beforeHeight)
339
- followRunways.set(port, { element, offset, property: target.property, original })
424
+ followRunways.set(port, { element, offset, property: target.property, original, requestedPx: runwayPx })
340
425
  invalidatePaintLimit(port)
341
426
  }
342
427
 
@@ -436,9 +521,10 @@ function applyVisual(
436
521
  animatedH: number,
437
522
  reservePx: number,
438
523
  velocityPxPerSec = 0,
524
+ runwayPx = FOLLOW_STATUS_RUNWAY_PX,
439
525
  ): number {
440
526
  const surfaces = shiftSurfacesOf(port)
441
- ensureRunway(port, surfaces)
527
+ ensureRunway(port, surfaces, runwayPx)
442
528
  const contentHeight = Math.max(0, port.scrollHeight)
443
529
  const runwayOffset = runwayOffsetOf(port)
444
530
  const targetHeight = Math.max(0, contentHeight - runwayOffset)
@@ -617,11 +703,12 @@ export function useConversationFollow(
617
703
 
618
704
  const updateRevealScale = (next: HTMLElement, elapsedMs: number, urgent = false): void => {
619
705
  if (revealScaleRef === undefined) return
706
+ const tuning = debugRuntime.activeTuning()
620
707
  const state = followMotionStates.get(next)
621
708
  const target = state === undefined
622
709
  ? 1
623
- : computeFollowRevealScale(state.lagPx, state.capacityPx, state.constrained)
624
- const current = Math.min(1, Math.max(FOLLOW_REVEAL_MIN_SCALE, revealScaleRef.current))
710
+ : computeFollowRevealScale(state.lagPx, state.capacityPx, state.constrained, tuning)
711
+ const current = Math.min(1, Math.max(tuning.backpressureMinScale, revealScaleRef.current))
625
712
  if (target < current || urgent) {
626
713
  // Slowing affects only future glyph commits, so it can react at once
627
714
  // without producing a visual discontinuity in the current frame.
@@ -636,6 +723,23 @@ export function useConversationFollow(
636
723
  if (revealScaleRef !== undefined) revealScaleRef.current = 1
637
724
  }
638
725
 
726
+ const reportFollow = (next: HTMLElement, isActive: boolean): void => {
727
+ const state = followMotionStates.get(next)
728
+ debugRuntime.reportFollow(next, {
729
+ lagPx: state?.lagPx ?? Math.max(0, next.scrollHeight - next.clientHeight - next.scrollTop),
730
+ velocityPxPerSec: state?.velocityPxPerSec ?? 0,
731
+ reservePx: state?.reservePx ?? 0,
732
+ capacityPx: state?.capacityPx ?? 0,
733
+ revealScale: revealScaleRef?.current ?? 1,
734
+ following,
735
+ constrained: state?.constrained ?? false,
736
+ scrollTop: next.scrollTop,
737
+ scrollHeight: next.scrollHeight,
738
+ clientHeight: next.clientHeight,
739
+ active: isActive,
740
+ })
741
+ }
742
+
639
743
  const isLeader = (next: HTMLElement): boolean => followLeaders.get(next)?.owner === owner
640
744
 
641
745
  const hold = (next: HTMLElement): void => {
@@ -654,12 +758,19 @@ export function useConversationFollow(
654
758
  clearMotion(next)
655
759
  followMotionStates.delete(next)
656
760
  releaseRevealScale()
761
+ debugRuntime.reportFollow(next, null)
657
762
  }
658
763
  }
659
764
 
660
765
  const handBackVisual = (next: HTMLElement): void => {
661
766
  const shift = currentShiftOf(shiftSurfacesOf(next).at(-1) ?? next)
662
767
  const visualTop = Math.max(0, next.scrollTop - Math.max(0, shift))
768
+ // The predictive runway only has meaning while this follower owns the
769
+ // floor. Remove it before choosing the reader's landing point; keeping
770
+ // it through the release paints a transient natural gap + 48px blank
771
+ // block until the next follow frame restores an equal transform.
772
+ clearMotion(next)
773
+ restoreRunway(next)
663
774
  const floor = Math.max(0, next.scrollHeight - next.clientHeight)
664
775
  // The host keeps its own bottom-follow bit while the reader remains in
665
776
  // its 25px slack band. Land one pixel beyond that band so even a light
@@ -697,8 +808,10 @@ export function useConversationFollow(
697
808
  const restoreBeforePaint = (): void => {
698
809
  if (!following || port === null || !isLeader(port)) return
699
810
  invalidatePaintLimit(port)
700
- animatedH = applyVisual(port, animatedH, reservePx, velocityPxPerSec)
811
+ const tuning = debugRuntime.activeTuning()
812
+ animatedH = applyVisual(port, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
701
813
  updateRevealScale(port, 0, true)
814
+ reportFollow(port, activeRef.current)
702
815
  }
703
816
 
704
817
  const bindPort = (next: HTMLElement): void => {
@@ -727,6 +840,7 @@ export function useConversationFollow(
727
840
  // frames would open paint room more slowly precisely when it is needed.
728
841
  const elapsedMs = Math.max(0.001, now - last)
729
842
  const dt = Math.min(FOLLOW_MAX_FRAME_MS, elapsedMs)
843
+ const tuning = debugRuntime.activeTuning()
730
844
  last = now
731
845
  const root = rootRef.current
732
846
  if (root === null) return
@@ -773,8 +887,9 @@ export function useConversationFollow(
773
887
  if (following) {
774
888
  hold(nextPort)
775
889
  if (isLeader(nextPort)) {
776
- animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec)
890
+ animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
777
891
  updateRevealScale(nextPort, elapsedMs)
892
+ reportFollow(nextPort, activeRef.current)
778
893
  const runwayOffset = runwayOffsetOf(nextPort)
779
894
  const entranceLag = Math.max(0, nextPort.scrollHeight - animatedH - runwayOffset)
780
895
  if (entranceLag <= FOLLOW_SETTLE_EPSILON_PX) finishEntrance()
@@ -789,7 +904,10 @@ export function useConversationFollow(
789
904
  }
790
905
 
791
906
  const repinSlack = readerReleased ? FOLLOW_REPIN_PX : FOLLOW_SLACK_PX
792
- if (!following && !interacting && reportedLag <= repinSlack) {
907
+ const returnedToFloor = readerReleased
908
+ && !readerGestureIntent
909
+ && reportedLag <= FOLLOW_REPIN_PX
910
+ if (!following && (!interacting || returnedToFloor) && reportedLag <= repinSlack) {
793
911
  following = true
794
912
  readerReleased = false
795
913
  animatedH = extent
@@ -813,6 +931,7 @@ export function useConversationFollow(
813
931
 
814
932
  if (!activeRef.current || !following) {
815
933
  followScrollLedgers.set(nextPort, nextPort.scrollTop)
934
+ reportFollow(nextPort, activeRef.current)
816
935
  return
817
936
  }
818
937
  hold(nextPort)
@@ -830,14 +949,14 @@ export function useConversationFollow(
830
949
  const predictGrowth = predictiveRef?.current ?? predictive
831
950
  const reserveTarget = !predictGrowth
832
951
  ? 0
833
- : computeFollowReserve(speedCpsRef.current, runwayOffset)
834
- const reserveStep = 1 - Math.exp(-elapsedMs / FOLLOW_RESERVE_RESPONSE_MS)
952
+ : computeFollowReserve(speedCpsRef.current, tuning.runwayPx)
953
+ const reserveStep = 1 - Math.exp(-elapsedMs / tuning.reserveResponseMs)
835
954
  reservePx += (reserveTarget - reservePx) * reserveStep
836
955
  const step = computeFollowStep(dt, {
837
956
  lag,
838
957
  speedEma: speedCpsRef.current,
839
958
  velocityPxPerSec,
840
- })
959
+ }, tuning)
841
960
  if (lag <= 0.1) {
842
961
  animatedH = contentHeight - runwayOffset
843
962
  velocityPxPerSec = 0
@@ -849,8 +968,9 @@ export function useConversationFollow(
849
968
  )
850
969
  velocityPxPerSec = step.velocityPxPerSec
851
970
  }
852
- animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec)
971
+ animatedH = applyVisual(nextPort, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
853
972
  updateRevealScale(nextPort, elapsedMs)
973
+ reportFollow(nextPort, true)
854
974
  const remainingEntranceLag = Math.max(
855
975
  0,
856
976
  nextPort.scrollHeight - animatedH - runwayOffsetOf(nextPort),
@@ -880,6 +1000,7 @@ export function useConversationFollow(
880
1000
  clearVisual(host)
881
1001
  followLeaders.delete(host)
882
1002
  releaseRevealScale()
1003
+ debugRuntime.reportFollow(host, null)
883
1004
  return
884
1005
  }
885
1006
  if (preserveReader) {
@@ -887,13 +1008,15 @@ export function useConversationFollow(
887
1008
  clearVisual(host)
888
1009
  followLeaders.delete(host)
889
1010
  releaseRevealScale()
1011
+ debugRuntime.reportFollow(host, null)
890
1012
  return
891
1013
  }
892
1014
 
893
1015
  // Completion can land the final Tool/command height in this same
894
1016
  // commit. Preserve the logical extent and drain it after unmount instead
895
1017
  // of clearing the compositor state before the first settled paint.
896
- ensureRunway(host, shiftSurfacesOf(host))
1018
+ const completionTuning = debugRuntime.activeTuning()
1019
+ ensureRunway(host, shiftSurfacesOf(host), completionTuning.runwayPx)
897
1020
  const completionRunway = runwayOffsetOf(host)
898
1021
  const completionMinimumLag = Math.max(0, reservePx)
899
1022
  animatedH = Math.min(
@@ -901,13 +1024,15 @@ export function useConversationFollow(
901
1024
  host.scrollHeight - completionRunway - completionMinimumLag,
902
1025
  )
903
1026
  settleAtFloor(host)
904
- animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec)
1027
+ animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, completionTuning.runwayPx)
1028
+ reportFollow(host, false)
905
1029
  const runwayOffset = runwayOffsetOf(host)
906
1030
  const remainingLag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
907
1031
  if (remainingLag <= FOLLOW_SETTLE_EPSILON_PX && reservePx <= FOLLOW_SETTLE_EPSILON_PX) {
908
1032
  finishAtNaturalFloor(host)
909
1033
  followLeaders.delete(host)
910
1034
  releaseRevealScale()
1035
+ debugRuntime.reportFollow(host, null)
911
1036
  return
912
1037
  }
913
1038
 
@@ -933,14 +1058,16 @@ export function useConversationFollow(
933
1058
  clearVisual(host)
934
1059
  followLeaders.delete(host)
935
1060
  releaseRevealScale()
1061
+ debugRuntime.reportFollow(host, null)
936
1062
  stopSettleListeners()
937
1063
  return
938
1064
  }
939
1065
  const dt = Math.min(FOLLOW_MAX_FRAME_MS, Math.max(0, now - settleLast))
1066
+ const tuning = debugRuntime.activeTuning()
940
1067
  settleLast = now
941
1068
  const runwayOffset = runwayOffsetOf(host)
942
1069
  const lag = Math.max(0, host.scrollHeight - animatedH - runwayOffset)
943
- const reserveStep = 1 - Math.exp(-dt / FOLLOW_RESERVE_RESPONSE_MS)
1070
+ const reserveStep = 1 - Math.exp(-dt / tuning.reserveResponseMs)
944
1071
  reservePx += (0 - reservePx) * reserveStep
945
1072
  if (lag <= FOLLOW_SETTLE_EPSILON_PX && reservePx <= FOLLOW_SETTLE_EPSILON_PX) {
946
1073
  animatedH = host.scrollHeight - runwayOffset
@@ -949,6 +1076,7 @@ export function useConversationFollow(
949
1076
  finishAtNaturalFloor(host)
950
1077
  followLeaders.delete(host)
951
1078
  releaseRevealScale()
1079
+ debugRuntime.reportFollow(host, null)
952
1080
  stopSettleListeners()
953
1081
  return
954
1082
  }
@@ -956,7 +1084,7 @@ export function useConversationFollow(
956
1084
  lag,
957
1085
  speedEma: speedCpsRef.current,
958
1086
  velocityPxPerSec,
959
- })
1087
+ }, tuning)
960
1088
  // The temporary runway is visually neutral only while an equal lag
961
1089
  // remains in the transform. Do not let the spring outrun the runway's
962
1090
  // closing reserve or cleanup would reveal an overshoot and rebound.
@@ -967,7 +1095,8 @@ export function useConversationFollow(
967
1095
  )
968
1096
  velocityPxPerSec = step.velocityPxPerSec
969
1097
  settleAtFloor(host)
970
- animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec)
1098
+ animatedH = applyVisual(host, animatedH, reservePx, velocityPxPerSec, tuning.runwayPx)
1099
+ reportFollow(host, false)
971
1100
  requestAnimationFrame(settleFrame)
972
1101
  }
973
1102
  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
  }