dsh-smooth-stream 0.6.1 → 0.7.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.
@@ -1,4 +1,5 @@
1
1
  import { useLayoutEffect, useRef, type RefObject } from 'react'
2
+ import { FrameCoordinator } from './FrameCoordinator.ts'
2
3
  import css from './LogarithmicFade.module.css'
3
4
 
4
5
  export const FADE_DURATION_MS = 240
@@ -11,6 +12,15 @@ const COLOR_PROPERTY = '--dsh-smooth-stream-fade-color'
11
12
  // Lightning CSS scopes highlight identifiers as well as class names.
12
13
  const highlightName = (index: number): string => css[`${PREFIX}${index}`] ?? `${PREFIX}${index}`
13
14
  const EXCLUDED = 'pre,code,math,.katex,.katex-display,mjx-container,svg,script,style,textarea,input,button,select,[role="button"],[contenteditable],[hidden],[aria-hidden="true"],[aria-live]'
15
+ // Attributes that can start or stop an EXCLUDED match mid-stream (`hidden` and
16
+ // `aria-hidden` flip whole subtrees, `class` carries the math renderers,
17
+ // `role`/`contenteditable` match their own selectors). `style` is deliberately
18
+ // absent: every colour write below is a style property, so observing it would
19
+ // make the fade re-enter its own observer.
20
+ const EXCLUDED_ATTRIBUTES = ['hidden', 'aria-hidden', 'role', 'contenteditable', 'class']
21
+ // Attributes on <html>/<body> that can move the resolved `currentColor`, i.e.
22
+ // an app theme switch that is not delivered through the media query.
23
+ const APPEARANCE_ATTRIBUTES = ['class', 'style', 'data-theme', 'data-color-scheme', 'data-appearance']
14
24
 
15
25
  export function logarithmicOpacity(progress: number): number {
16
26
  const p = Number.isFinite(progress) ? Math.max(0, Math.min(1, progress)) : 1
@@ -31,29 +41,130 @@ interface FadeCharacter {
31
41
  bucket: number
32
42
  }
33
43
 
44
+ /** One text node of the fade root with its span in the concatenated source text. */
45
+ interface TextNodeEntry {
46
+ node: Text
47
+ start: number
48
+ end: number
49
+ eligible: boolean
50
+ }
51
+
52
+ interface PreservedColor {
53
+ value: string
54
+ priority: string
55
+ /** Reconcile pass that last wanted this element, so stale entries can be pruned. */
56
+ generation: number
57
+ }
58
+
34
59
  interface Scheduler {
35
60
  highlights: Highlight[]
36
61
  clients: Set<LogarithmicFadeController>
37
62
  pending: Set<LogarithmicFadeController>
38
- frame: number
63
+ /** Task handle on the shared frame clock; non-null while a client is live. */
64
+ taskId: string | null
39
65
  registry: HighlightRegistry
40
66
  window: Window
67
+ coordinator: FrameCoordinator
68
+ /** Detaches the document-level appearance watcher when the last client goes. */
69
+ unwatchAppearance: () => void
41
70
  }
42
71
 
43
72
  const schedulers = new WeakMap<Document, Scheduler>()
44
73
  const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
45
74
 
75
+ /**
76
+ * Hot-path counters for the streaming benchmark. Increments only — this is the
77
+ * evidence that the per-frame pass count collapsed, not a control input.
78
+ */
79
+ export const fadeHotPathStats = {
80
+ /** Observer batches that reached reconcile(). */
81
+ reconcileCalls: 0,
82
+ /** Reconciles that did real work (the rest are dirty-check no-ops). */
83
+ reconcilePasses: 0,
84
+ /** Full text-node table rebuilds (structural DOM change). */
85
+ nodeScans: 0,
86
+ /** Forced style resolutions for newly faded elements. */
87
+ styleReads: 0,
88
+ }
89
+
90
+ export function resetFadeHotPathStats(): void {
91
+ fadeHotPathStats.reconcileCalls = 0
92
+ fadeHotPathStats.reconcilePasses = 0
93
+ fadeHotPathStats.nodeScans = 0
94
+ fadeHotPathStats.styleReads = 0
95
+ }
96
+
46
97
  function schedule(scheduler: Scheduler): void {
47
- if (scheduler.frame !== 0 || scheduler.pending.size === 0) return
48
- scheduler.frame = scheduler.window.requestAnimationFrame((now) => {
49
- scheduler.frame = 0
50
- for (const client of scheduler.pending) {
51
- if (!client.paint(now)) scheduler.pending.delete(client)
52
- }
53
- schedule(scheduler)
98
+ if (scheduler.taskId !== null || scheduler.pending.size === 0) return
99
+ // One fade task per document on the shared clock, so the fade's bucket
100
+ // bookkeeping shares the reveal/follow frame instead of racing it.
101
+ scheduler.taskId = scheduler.coordinator.registerTask({
102
+ onSimulate: (_dtMs, now) => {
103
+ for (const client of scheduler.pending) {
104
+ if (!client.paint(now)) scheduler.pending.delete(client)
105
+ }
106
+ if (scheduler.pending.size === 0) {
107
+ scheduler.taskId = null
108
+ return false
109
+ }
110
+ return true
111
+ },
54
112
  })
55
113
  }
56
114
 
115
+ /**
116
+ * Whether an attribute record actually moved the attribute's value. jsdom (and
117
+ * some engines) report a write even when the serialized value is unchanged, and
118
+ * this controller writes the scope class on every enable/disable transition —
119
+ * counting a no-op record as a DOM change would re-enter the observer forever.
120
+ * Requires `attributeOldValue` on the observing call.
121
+ * @param record - attribute mutation record from either observer.
122
+ * @returns true only when the value differs from the recorded old value.
123
+ */
124
+ function attributeChanged(record: MutationRecord): boolean {
125
+ const attribute = record.attributeName
126
+ if (attribute === null) return true
127
+ return record.oldValue !== (record.target as Element).getAttribute(attribute)
128
+ }
129
+
130
+ /**
131
+ * One appearance watcher per document. A theme switch arrives either as an OS
132
+ * media-query flip (the app's default `preference: system`) or as an attribute
133
+ * write on <html>/<body>, so both are observed; the fade only ever writes
134
+ * custom properties on its own root, never on those elements, so this watcher
135
+ * cannot observe the controller's own output.
136
+ * @param scheduler - document-scoped registry notified when the look changes.
137
+ * @returns teardown for the last client's dispose.
138
+ */
139
+ function watchAppearance(scheduler: Scheduler): () => void {
140
+ // The scheduler already resolved the root's realm; re-state it so the DOM
141
+ // constructors resolve off that window rather than the ambient global.
142
+ const win = scheduler.window as Window & typeof globalThis
143
+ const doc = win.document
144
+ const invalidate = (): void => {
145
+ for (const client of scheduler.clients) client.invalidateColors()
146
+ }
147
+ const observer = new win.MutationObserver((records) => {
148
+ if (records.some(attributeChanged)) invalidate()
149
+ })
150
+ const watched = doc.body === null ? [doc.documentElement] : [doc.documentElement, doc.body]
151
+ for (const element of watched) {
152
+ observer.observe(element, {
153
+ attributes: true,
154
+ attributeFilter: APPEARANCE_ATTRIBUTES,
155
+ attributeOldValue: true,
156
+ })
157
+ }
158
+ const query = typeof win.matchMedia === 'function'
159
+ ? win.matchMedia('(prefers-color-scheme: dark)')
160
+ : null
161
+ if (query !== null) query.addEventListener('change', invalidate)
162
+ return () => {
163
+ observer.disconnect()
164
+ query?.removeEventListener('change', invalidate)
165
+ }
166
+ }
167
+
57
168
  function schedulerFor(root: HTMLElement): Scheduler | null {
58
169
  const doc = root.ownerDocument
59
170
  const win = doc.defaultView
@@ -70,32 +181,78 @@ function schedulerFor(root: HTMLElement): Scheduler | null {
70
181
  highlights,
71
182
  clients: new Set(),
72
183
  pending: new Set(),
73
- frame: 0,
184
+ taskId: null,
74
185
  registry: realm.CSS.highlights,
75
186
  window: win,
187
+ coordinator: FrameCoordinator.forDocument(doc),
188
+ unwatchAppearance: () => {},
76
189
  }
190
+ scheduler.unwatchAppearance = watchAppearance(scheduler)
77
191
  schedulers.set(doc, scheduler)
78
192
  }
79
193
  return scheduler
80
194
  }
81
195
 
82
- /** Owns ranges only: React retains ownership of every element and Text node. */
196
+ /**
197
+ * Owns ranges only: React retains ownership of every element and Text node.
198
+ *
199
+ * The controller used to re-derive its whole view of the subtree (textContent,
200
+ * prefix walk, full TreeWalker, an O(tail x #nodes) filter) on *every* commit
201
+ * AND on every observer batch for that same commit. It now keeps an
202
+ * incremental text-node table, reconciles at most once per observed DOM
203
+ * change, and preserves the per-element colour memo across passes.
204
+ */
83
205
  export class LogarithmicFadeController {
84
206
  private previous = ''
85
207
  private characters: FadeCharacter[] = []
86
- private colors = new Map<HTMLElement, { value: string, priority: string }>()
208
+ private colors = new Map<HTMLElement, PreservedColor>()
87
209
  private enabled = false
88
210
  private active = false
89
211
  private speedCps = 100
90
212
  private pausedAt: number | null = null
91
213
  private disposed = false
92
214
  private readonly observer: MutationObserver
215
+ /** Text nodes of the root with their source offsets; survives data-only edits. */
216
+ private nodes: TextNodeEntry[] = []
217
+ /** Ineligible nodes in nodes[0..index): a span is fadeable when its bounds match. */
218
+ private ineligiblePrefix: number[] = [0]
219
+ /**
220
+ * `closest(EXCLUDED)` derived once per element instead of once per Text node:
221
+ * exclusion is inherited from the ancestor chain, so a memo stays usable only
222
+ * while the element keeps its own parent. Moving a subtree into `code` — or
223
+ * into anything else that matches — invalidates exactly the moved chain the
224
+ * next time it is read.
225
+ */
226
+ private excludedElements = new WeakMap<Element, { parent: Element | null, excluded: boolean }>()
227
+ /** Own tag/attribute match per element; dropped when an exclusion attribute moves. */
228
+ private ownExcluded = new WeakMap<Element, boolean>()
229
+ /** Bumped by every observed mutation; reconcile() no-ops while it does not move. */
230
+ private domRevision = 0
231
+ /** Bumped only by structural (childList) mutations; the node table is rebuilt then. */
232
+ private structureRevision = 0
233
+ private scannedStructureRevision = -1
234
+ private reconciledRevision = -1
235
+ private reconciledEnabled = false
236
+ private reconciledActive = false
237
+ private reconciledPaused = false
238
+ private reconciledTailSize = 0
239
+ private colorGeneration = 0
93
240
 
94
241
  private constructor(private readonly root: HTMLElement, private readonly scheduler: Scheduler) {
95
242
  scheduler.clients.add(this)
96
243
  const win = root.ownerDocument.defaultView as Window & typeof globalThis
97
- this.observer = new win.MutationObserver(() => { this.reconcile() })
98
- this.observer.observe(root, { subtree: true, childList: true, characterData: true })
244
+ // The observer is the single invalidation source. It watches structure,
245
+ // text and only the exclusion-relevant attributes; every colour write below
246
+ // is a `style` property, which stays unobserved, so it cannot re-enter.
247
+ this.observer = new win.MutationObserver((records) => { this.onDomMutation(records) })
248
+ this.observer.observe(root, {
249
+ subtree: true,
250
+ childList: true,
251
+ characterData: true,
252
+ attributes: true,
253
+ attributeFilter: EXCLUDED_ATTRIBUTES,
254
+ attributeOldValue: true,
255
+ })
99
256
  }
100
257
 
101
258
  static create(root: HTMLElement): LogarithmicFadeController | null {
@@ -117,90 +274,292 @@ export class LogarithmicFadeController {
117
274
  this.reconcile()
118
275
  }
119
276
 
277
+ /** Invalidate and reconcile from one observer batch (one batch per commit). */
278
+ private onDomMutation(records: MutationRecord[]): void {
279
+ let changed = false
280
+ let exclusionAttributesMoved = false
281
+ for (const record of records) {
282
+ if (record.type === 'childList') {
283
+ this.structureRevision += 1
284
+ changed = true
285
+ continue
286
+ }
287
+ if (record.type === 'characterData') {
288
+ changed = true
289
+ continue
290
+ }
291
+ // Only EXCLUDED_ATTRIBUTES reach this branch, and only value changes
292
+ // count: this controller writes its own scope class below, which must not
293
+ // be mistaken for a DOM change (it re-renders the same token list).
294
+ if (!attributeChanged(record)) continue
295
+ exclusionAttributesMoved = true
296
+ changed = true
297
+ }
298
+ // A batch made only of this controller's own no-op writes is not a DOM
299
+ // change. Reconciling on it would re-add the scope class, whose record is
300
+ // another such batch, and the observer would re-enter itself forever.
301
+ if (!changed) return
302
+ if (exclusionAttributesMoved) {
303
+ // `hidden`/`aria-hidden`/`class` on an ancestor decides the eligibility of
304
+ // every descendant Text node, so the whole memo is stale — not just the
305
+ // mutation target — and the next pass has to re-derive the table.
306
+ this.excludedElements = new WeakMap()
307
+ this.ownExcluded = new WeakMap()
308
+ this.structureRevision += 1
309
+ }
310
+ this.domRevision += 1
311
+ this.reconcile()
312
+ }
313
+
314
+ /**
315
+ * Whether this element matches a SKIP rule itself or inherits one from an
316
+ * ancestor — the memoised form of `element.closest(EXCLUDED) !== null`.
317
+ * Memoised per element and revalidated through the parent chain, so a node
318
+ * that moves into `code` — or whose ancestor does — is re-decided without
319
+ * paying the selector walk once per Text node per pass.
320
+ * @param element - element owning the Text node being classified.
321
+ * @returns true when text under this element must stay out of the fade.
322
+ */
323
+ private elementExcluded(element: Element): boolean {
324
+ const cached = this.excludedElements.get(element)
325
+ if (cached !== undefined && cached.parent === element.parentElement) return cached.excluded
326
+ const parent = element.parentElement
327
+ const inherited = parent === null ? false : this.elementExcluded(parent)
328
+ let own = this.ownExcluded.get(element)
329
+ if (own === undefined) {
330
+ own = element.matches(EXCLUDED)
331
+ this.ownExcluded.set(element, own)
332
+ }
333
+ const excluded = inherited || own
334
+ this.excludedElements.set(element, { parent, excluded })
335
+ return excluded
336
+ }
337
+
338
+ /** Rebuild the text-node table after a structural change. */
339
+ private scanNodes(): void {
340
+ const nodes: TextNodeEntry[] = []
341
+ const walker = this.root.ownerDocument.createTreeWalker(this.root, NodeFilter.SHOW_TEXT)
342
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
343
+ const text = node as Text
344
+ const parent = text.parentElement
345
+ // Derived through the parent element, never cached on Text identity: a
346
+ // node that was moved keeps its identity, so a Text-keyed memo would keep
347
+ // answering for the tree it used to live in.
348
+ const eligible = parent !== null && !this.elementExcluded(parent)
349
+ nodes.push({ node: text, start: 0, end: 0, eligible })
350
+ }
351
+ this.nodes = nodes
352
+ this.scannedStructureRevision = this.structureRevision
353
+ fadeHotPathStats.nodeScans += 1
354
+ }
355
+
356
+ /**
357
+ * Re-derive the source text, the per-node spans and the eligibility prefix
358
+ * sums from the cached table. No DOM read, no selector match, no allocation
359
+ * beyond the string itself.
360
+ */
361
+ private readText(): string {
362
+ if (this.scannedStructureRevision !== this.structureRevision) this.scanNodes()
363
+ // The commit-driven pass runs before the observer microtask for the same
364
+ // commit, so a replaced text node can still be in the table. A removed
365
+ // node keeps its data, which would silently hand a detached node to the
366
+ // Range and colour code, so re-scan on the first eviction.
367
+ for (const entry of this.nodes) {
368
+ if (entry.node.parentNode !== null) continue
369
+ this.scanNodes()
370
+ break
371
+ }
372
+ const nodes = this.nodes
373
+ const prefix = this.ineligiblePrefix
374
+ if (prefix.length < nodes.length + 1) prefix.length = nodes.length + 1
375
+ prefix[0] = 0
376
+ let text = ''
377
+ let offset = 0
378
+ for (let index = 0; index < nodes.length; index += 1) {
379
+ const entry = nodes[index]!
380
+ const data = entry.node.data
381
+ entry.start = offset
382
+ offset += data.length
383
+ entry.end = offset
384
+ text += data
385
+ prefix[index + 1] = prefix[index]! + (entry.eligible ? 0 : 1)
386
+ }
387
+ prefix.length = nodes.length + 1
388
+ this.ineligiblePrefix = prefix
389
+ return text
390
+ }
391
+
392
+ /** First cached node whose span ends after `offset` (binary search). */
393
+ private firstNodeEndingAfter(offset: number): number {
394
+ const nodes = this.nodes
395
+ let low = 0
396
+ let high = nodes.length
397
+ while (low < high) {
398
+ const mid = (low + high) >> 1
399
+ if (nodes[mid]!.end > offset) high = mid
400
+ else low = mid + 1
401
+ }
402
+ return low
403
+ }
404
+
405
+ /** Last cached node whose span starts before `offset` (binary search). */
406
+ private lastNodeStartingBefore(offset: number): number {
407
+ const nodes = this.nodes
408
+ let low = -1
409
+ let high = nodes.length - 1
410
+ while (low < high) {
411
+ const mid = (low + high + 1) >> 1
412
+ if (nodes[mid]!.start < offset) low = mid
413
+ else high = mid - 1
414
+ }
415
+ return low
416
+ }
417
+
120
418
  private clearRanges(): void {
121
419
  for (const character of this.characters) {
122
420
  this.scheduler.highlights[character.bucket]?.delete(character.range)
123
421
  }
124
422
  this.characters = []
125
- this.restoreColors()
423
+ }
424
+
425
+ private restoreColor(element: HTMLElement, preserved: PreservedColor): void {
426
+ if (preserved.value === '') element.style.removeProperty(COLOR_PROPERTY)
427
+ else element.style.setProperty(COLOR_PROPERTY, preserved.value, preserved.priority)
428
+ }
429
+
430
+ private rootColorSet = false
431
+
432
+ private ensureRootColor(): void {
433
+ if (this.rootColorSet) return
434
+ const win = this.scheduler.window
435
+ const color = win.getComputedStyle(this.root).color || 'currentColor'
436
+ this.root.style.setProperty(COLOR_PROPERTY, color)
437
+ this.rootColorSet = true
126
438
  }
127
439
 
128
440
  private restoreColors(): void {
129
- for (const [element, original] of this.colors) {
130
- if (original.value === '') element.style.removeProperty(COLOR_PROPERTY)
131
- else element.style.setProperty(COLOR_PROPERTY, original.value, original.priority)
441
+ if (this.rootColorSet) {
442
+ this.root.style.removeProperty(COLOR_PROPERTY)
443
+ this.rootColorSet = false
132
444
  }
445
+ for (const [element, preserved] of this.colors) this.restoreColor(element, preserved)
133
446
  this.colors.clear()
134
447
  }
135
448
 
136
- private preserveColor(element: HTMLElement): void {
137
- if (this.colors.has(element)) return
138
- const color = this.scheduler.window.getComputedStyle(element).color
139
- this.colors.set(element, {
140
- value: element.style.getPropertyValue(COLOR_PROPERTY),
141
- priority: element.style.getPropertyPriority(COLOR_PROPERTY),
142
- })
143
- // An explicit source color prevents highlight inheritance from multiplying
144
- // alpha through nested Markdown elements (root → paragraph → strong).
145
- element.style.setProperty(COLOR_PROPERTY, color)
449
+ /**
450
+ * The document's appearance moved (theme switch, restyle): the captured ink
451
+ * colour is stale, so drop it and let the next pass read the new one. Costs
452
+ * nothing while no colour is captured, which is the common case — the read
453
+ * still happens only when text is actually fading.
454
+ */
455
+ invalidateColors(): void {
456
+ if (this.disposed || (!this.rootColorSet && this.colors.size === 0)) return
457
+ this.restoreColors()
458
+ // Force the next reconcile past its dirty check; it re-reads the colour and
459
+ // re-cuts the live tail, so the characters already fading recolour in place
460
+ // instead of finishing in the previous theme's ink.
461
+ this.domRevision += 1
462
+ this.reconcile()
146
463
  }
147
464
 
465
+
466
+ /**
467
+ * Refresh the fade ranges. Two triggers used to run this 2-3x per frame with
468
+ * no dirty check: the commit-driven `update()` and the observer batch for the
469
+ * very same DOM write. Both converge here, and the pass is skipped unless the
470
+ * DOM revision, the gate, the fade window or the pause state actually moved.
471
+ */
148
472
  private reconcile(): void {
149
473
  if (this.disposed) return
150
- const text = this.root.textContent ?? ''
474
+ fadeHotPathStats.reconcileCalls += 1
475
+ const tailSize = fadeTailSize(this.speedCps)
476
+ const paused = this.pausedAt !== null
477
+ const domChanged = this.domRevision !== this.reconciledRevision
478
+ // The window only matters while characters are still fading: an idle
479
+ // controller has nothing to re-cut, and the next append reconciles anyway.
480
+ const paramsChanged = this.enabled !== this.reconciledEnabled
481
+ || this.active !== this.reconciledActive
482
+ || paused !== this.reconciledPaused
483
+ || (tailSize !== this.reconciledTailSize && this.characters.length > 0)
484
+ if (!domChanged && !paramsChanged) return
485
+ fadeHotPathStats.reconcilePasses += 1
486
+ this.reconciledRevision = this.domRevision
487
+ this.reconciledEnabled = this.enabled
488
+ this.reconciledActive = this.active
489
+ this.reconciledPaused = paused
490
+ this.reconciledTailSize = tailSize
491
+
492
+ const text = this.readText()
151
493
  const previous = this.previous
152
494
  this.previous = text
153
495
  const old = this.characters
154
496
  this.clearRanges()
155
497
  if (!this.enabled) {
156
498
  this.root.classList.remove(css.scope!)
499
+ this.restoreColors()
157
500
  this.scheduler.pending.delete(this)
158
501
  this.stopIfIdle()
159
502
  return
160
503
  }
161
504
  this.root.classList.add(css.scope!)
505
+ this.ensureRootColor()
162
506
  const now = this.pausedAt ?? this.scheduler.window.performance.now()
163
- // A parser rewrite must not replay already readable content. Retain only
164
- // the unchanged prefix; future appends resume the effect normally.
165
- let prefix = 0
166
- while (prefix < previous.length && prefix < text.length && previous[prefix] === text[prefix]) prefix += 1
167
507
  const appended = text.startsWith(previous)
168
- const nodes: { node: Text, start: number, end: number, eligible: boolean }[] = []
169
- const walker = this.root.ownerDocument.createTreeWalker(this.root, NodeFilter.SHOW_TEXT)
170
- let offset = 0
171
- for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
172
- const end = offset + (node.textContent?.length ?? 0)
173
- nodes.push({ node: node as Text, start: offset, end, eligible: node.parentElement?.closest(EXCLUDED) === null })
174
- offset = end
508
+ let prefix = appended ? previous.length : 0
509
+ if (!appended) {
510
+ while (prefix < previous.length && prefix < text.length && previous[prefix] === text[prefix]) prefix += 1
175
511
  }
176
- // containing() walks backwards by grapheme without segmenting the entire
177
- // answer into an array. Offsets still refer to the original DOM text.
178
- const segments = segmenter.segment(text)
512
+ const nodes = this.nodes
513
+ const ineligible = this.ineligiblePrefix
179
514
  // Keep existing characters alive when the engine resets its speed during
180
515
  // completion. A shrinking window must not abruptly darken the old tail.
181
- const tailSize = fadeTailSize(this.speedCps)
182
516
  const oldestLiveStart = old.reduce((start, character) => (
183
517
  now - character.born < FADE_DURATION_MS && character.end <= prefix
184
518
  ? Math.min(start, character.start)
185
519
  : start
186
520
  ), Infinity)
521
+ const retainedBySpan = new Map<string, FadeCharacter>()
522
+ for (const character of old) retainedBySpan.set(`${String(character.start)}:${String(character.end)}`, character)
523
+ this.colorGeneration += 1
524
+
525
+ // Windowed segmentation: fade only affects the active tail. Windowing to the
526
+ // safe boundary keeps Intl.Segmenter at O(1) constant time (<0.1ms) even for
527
+ // long texts with tens of thousands of characters.
528
+ const windowStart = Math.max(
529
+ 0,
530
+ Math.min(
531
+ text.length - FADE_MAX_TAIL_SIZE * 2,
532
+ oldestLiveStart < Infinity ? oldestLiveStart : text.length,
533
+ ),
534
+ )
535
+ const tailText = windowStart > 0 ? text.slice(windowStart) : text
536
+ const segments = segmenter.segment(tailText)
537
+
187
538
  let end = text.length
188
539
  for (let count = 0; count < FADE_MAX_TAIL_SIZE && end > 0 && (count < tailSize || end > oldestLiveStart); count += 1) {
189
- const segment = segments.containing(end - 1)
540
+ const localEnd = end - windowStart
541
+ if (localEnd <= 0) break
542
+ const segment = segments.containing(localEnd - 1)
190
543
  if (segment === undefined) break
191
- const start = segment.index
192
- const parts = nodes.filter(node => node.end > start && node.start < end)
193
- const retained = old.find(character => character.start === start && character.end === end && end <= prefix)
544
+ const start = segment.index + windowStart
545
+ const retained = end <= prefix
546
+ ? retainedBySpan.get(`${String(start)}:${String(end)}`)
547
+ : undefined
194
548
  const born = retained?.born ?? (this.active && appended && start >= previous.length ? now : null)
195
- if (born !== null && now - born < FADE_DURATION_MS && segment.segment.trim() !== ''
196
- && parts.length > 0 && parts.every(part => part.eligible)) {
197
- const first = parts[0]!
198
- const last = parts[parts.length - 1]!
199
- const range = this.root.ownerDocument.createRange()
200
- range.setStart(first.node, start - first.start)
201
- range.setEnd(last.node, end - last.start)
202
- for (const part of parts) this.preserveColor(part.node.parentElement!)
203
- this.characters.push({ start, end, born, range, bucket: -1 })
549
+ if (born !== null && now - born < FADE_DURATION_MS && segment.segment.trim() !== '') {
550
+ // One binary-searched node span replaces the former O(tail x #nodes)
551
+ // filter; the prefix sums answer "is every part eligible" in O(1).
552
+ const first = this.firstNodeEndingAfter(start)
553
+ const last = this.lastNodeStartingBefore(end)
554
+ if (last >= first && first < nodes.length && nodes[last]!.end > start
555
+ && ineligible[last + 1] === ineligible[first]) {
556
+ const firstPart = nodes[first]!
557
+ const lastPart = nodes[last]!
558
+ const range = this.root.ownerDocument.createRange()
559
+ range.setStart(firstPart.node, start - firstPart.start)
560
+ range.setEnd(lastPart.node, end - lastPart.start)
561
+ this.characters.push({ start, end, born, range, bucket: -1 })
562
+ }
204
563
  }
205
564
  end = start
206
565
  }
@@ -239,8 +598,9 @@ export class LogarithmicFadeController {
239
598
 
240
599
  private stopIfIdle(): void {
241
600
  if (this.scheduler.pending.size !== 0) return
242
- this.scheduler.window.cancelAnimationFrame(this.scheduler.frame)
243
- this.scheduler.frame = 0
601
+ if (this.scheduler.taskId === null) return
602
+ this.scheduler.coordinator.unregisterTask(this.scheduler.taskId)
603
+ this.scheduler.taskId = null
244
604
  }
245
605
 
246
606
  dispose(): void {
@@ -248,6 +608,9 @@ export class LogarithmicFadeController {
248
608
  this.disposed = true
249
609
  this.observer.disconnect()
250
610
  this.clearRanges()
611
+ this.restoreColors()
612
+ this.nodes = []
613
+ this.scannedStructureRevision = -1
251
614
  this.root.classList.remove(css.scope!)
252
615
  this.scheduler.pending.delete(this)
253
616
  this.scheduler.clients.delete(this)
@@ -257,6 +620,9 @@ export class LogarithmicFadeController {
257
620
  const name = highlightName(index)
258
621
  if (this.scheduler.registry.get(name) === highlight) this.scheduler.registry.delete(name)
259
622
  }
623
+ // "Fade off" must cost nothing: no observer, no frame seat, no media
624
+ // listener left behind for a document that no longer fades.
625
+ this.scheduler.unwatchAppearance()
260
626
  schedulers.delete(this.root.ownerDocument)
261
627
  }
262
628
  }
@@ -278,11 +644,24 @@ export function useLogarithmicFade(
278
644
  controller.current = null
279
645
  }
280
646
  }, [rootRef])
281
- // Deliberately commit-driven, including Markdown updates with unchanged source.
647
+ // Deliberately commit-driven, including Markdown updates with unchanged
648
+ // source: React may swap text nodes for identical text, and the controller's
649
+ // observer cannot report that before the commit's layout effects. The
650
+ // controller itself drops the redundant half of those passes now.
282
651
  useLayoutEffect(() => {
283
652
  const root = rootRef.current
653
+ if (!enabled) {
654
+ // "Fade off" must cost nothing: no observer, no reconcile, no rAF seat,
655
+ // no leftover scope class or colour property.
656
+ if (controller.current !== null) {
657
+ controller.current.dispose()
658
+ controller.current = null
659
+ }
660
+ committed.current = true
661
+ return
662
+ }
284
663
  // Settled history allocates neither observers nor highlight buckets.
285
- if (controller.current === null && root !== null && enabled && active) {
664
+ if (controller.current === null && root !== null && active) {
286
665
  controller.current = LogarithmicFadeController.create(root)
287
666
  // Enabling midway through a message must not replay readable text.
288
667
  if (committed.current) controller.current?.update(false, false)