2121 * correct in their own coordinate space.
2222 */
2323
24- import { diff } from '@automerge/automerge' ;
25- import type { Heads } from '@automerge/automerge' ;
24+ import {
25+ applyChanges ,
26+ decodeChange ,
27+ getAllChanges ,
28+ getChanges ,
29+ init ,
30+ } from '@automerge/automerge' ;
31+ import type { Change , Doc , Patch } from '@automerge/automerge' ;
2632import { decodeHeads } from '@automerge/automerge-repo' ;
2733import type { DocHandle } from '@automerge/automerge-repo' ;
2834
@@ -48,6 +54,15 @@ export interface RunListAttribution {
4854 runs : AttributionRun [ ] ;
4955 processedHeads : unknown [ ] ;
5056 processedHistoryIndex : number ;
57+ /**
58+ * Internal: forward-replay doc held at `processedHeads`, fed to
59+ * `A.applyChanges` so each incremental update only pays for new
60+ * changes (not a full doc-state load per step like `A.diff` did).
61+ * Absent when state is hand-constructed (tests), in which case
62+ * `updateRunListAttribution` throws `HistoryCompactedError` and
63+ * the caller (`useAttribution`) falls back to a full rebuild.
64+ */
65+ _workDoc ?: Doc < unknown > ;
5166}
5267
5368interface SplicePatch {
@@ -96,10 +111,12 @@ export function extractChangeHash(heads: unknown): string | null {
96111
97112/**
98113 * History entries processed between idle-callback yields. Larger
99- * values reduce the number of rIC round trips (faster
100- * time-to-attribution) but make each slice's CPU block bigger (more
101- * frame jank risk). 500 gives ~2.5 ms of CPU per slice at the
102- * prototype's bench-measured ~5 µs/entry.
114+ * values cut yield overhead at the cost of bigger per-slice CPU.
115+ *
116+ * Per-entry CPU is ~15 µs (applyChanges forward-replay, roughly
117+ * constant in N), so a 500-entry slice is ≈7-8 ms — comfortably
118+ * under one frame (16.67 ms). Slices above ~1000 risk overrunning a
119+ * frame in busier browsers.
103120 */
104121export const CHUNK_SIZE = 500 ;
105122
@@ -227,6 +244,45 @@ function applyPatchToRuns(
227244 }
228245}
229246
247+ // ---------------------------------------------------------------------------
248+ // Internal: shared replay loop
249+ // ---------------------------------------------------------------------------
250+
251+ /**
252+ * The new change introduced at this history step is whichever hash is in
253+ * `currHeads` but not in `prevHeads`. For the first step, take the first
254+ * head. Returns null if no new change can be identified (defensive).
255+ */
256+ function newChangeHashAt ( prevHeads : string [ ] | null , currHeads : string [ ] ) : string | null {
257+ if ( currHeads . length === 0 ) return null ;
258+ if ( prevHeads === null ) return currHeads [ 0 ] ;
259+ const prevSet = new Set ( prevHeads ) ;
260+ return currHeads . find ( h => ! prevSet . has ( h ) ) ?? null ;
261+ }
262+
263+ /**
264+ * Apply one change to `workDoc`, collect any patches via patchCallback,
265+ * and fold them into the running runs list using the change's own
266+ * actor/time. Returns the advanced workDoc.
267+ */
268+ function replayChange (
269+ workDoc : Doc < unknown > ,
270+ change : Change ,
271+ textFieldName : string ,
272+ runs : AttributionRun [ ] ,
273+ ) : Doc < unknown > {
274+ const decoded = decodeChange ( change ) ;
275+ const attribution : CharAttribution = { actor : decoded . actor , time : decoded . time } ;
276+ let collected : Patch [ ] = [ ] ;
277+ const [ next ] = applyChanges ( workDoc , [ change ] , {
278+ patchCallback : ( patches : Patch [ ] ) => { collected = patches ; } ,
279+ } ) ;
280+ for ( const patch of collected ) {
281+ if ( isTextPatch ( patch , textFieldName ) ) applyPatchToRuns ( runs , patch , attribution ) ;
282+ }
283+ return next ;
284+ }
285+
230286// ---------------------------------------------------------------------------
231287// buildRunListAttribution — full history processing
232288// ---------------------------------------------------------------------------
@@ -241,59 +297,46 @@ export async function buildRunListAttribution(
241297 if ( ! history ) return null ;
242298
243299 if ( history . length === 0 ) {
244- return { runs : [ ] , processedHeads : [ ] , processedHistoryIndex : 0 } ;
300+ return { runs : [ ] , processedHeads : [ ] , processedHistoryIndex : 0 , _workDoc : init ( ) } ;
301+ }
302+
303+ // Pre-index every change in the doc by hash so each history step can
304+ // look up its corresponding change in O(1).
305+ const doc = viewable . doc ( ) as Doc < unknown > ;
306+ const changeByHash = new Map < string , Change > ( ) ;
307+ for ( const c of getAllChanges ( doc ) ) {
308+ changeByHash . set ( decodeChange ( c ) . hash , c ) ;
245309 }
246310
247311 const runs : AttributionRun [ ] = [ ] ;
248- let prevHeads : unknown = null ;
312+ let prevHeads : string [ ] | null = null ;
249313 let lastHeads : unknown [ ] = [ ] ;
314+ let workDoc : Doc < unknown > = init ( ) ;
250315
251316 for ( let chunkStart = 0 ; chunkStart < history . length ; chunkStart += CHUNK_SIZE ) {
252- await waitForIdle ( ) ;
253317 if ( signal ?. aborted ) return null ;
254318
255319 const chunkEnd = Math . min ( chunkStart + CHUNK_SIZE , history . length ) ;
256320 for ( let i = chunkStart ; i < chunkEnd ; i ++ ) {
257321 const currHeads = history [ i ] ;
258- const changeHash = extractChangeHash ( currHeads ) ;
259- const meta = changeHash ? viewable . metadata ( changeHash ) : undefined ;
260- const attribution : CharAttribution = {
261- actor : meta ?. actor ?? 'unknown' ,
262- time : meta ?. time ?? 0 ,
263- } ;
264-
265322 const decodedCurr = decodeHeads ( currHeads as Parameters < typeof decodeHeads > [ 0 ] ) ;
266- let patches : unknown [ ] ;
267- if ( prevHeads === null ) {
268- patches = diff (
269- viewable . doc ( ) as Parameters < typeof diff > [ 0 ] ,
270- [ ] as unknown as Heads ,
271- decodedCurr as unknown as Heads ,
272- ) ;
273- } else {
274- const decodedPrev = decodeHeads ( prevHeads as Parameters < typeof decodeHeads > [ 0 ] ) ;
275- patches = diff (
276- viewable . doc ( ) as Parameters < typeof diff > [ 0 ] ,
277- decodedPrev as unknown as Heads ,
278- decodedCurr as unknown as Heads ,
279- ) ;
323+ const newHash = newChangeHashAt ( prevHeads , decodedCurr ) ;
324+ const change = newHash ? changeByHash . get ( newHash ) : undefined ;
325+ if ( change ) {
326+ workDoc = replayChange ( workDoc , change , textFieldName , runs ) ;
280327 }
281-
282- for ( const patch of patches ) {
283- if ( isTextPatch ( patch , textFieldName ) ) {
284- applyPatchToRuns ( runs , patch , attribution ) ;
285- }
286- }
287-
288- prevHeads = currHeads ;
328+ prevHeads = decodedCurr ;
289329 lastHeads = Array . isArray ( currHeads ) ? currHeads : [ currHeads ] ;
290330 }
331+
332+ if ( chunkEnd < history . length ) await waitForIdle ( ) ;
291333 }
292334
293335 return {
294336 runs,
295337 processedHeads : lastHeads as unknown [ ] ,
296338 processedHistoryIndex : history . length ,
339+ _workDoc : workDoc ,
297340 } ;
298341}
299342
@@ -310,42 +353,42 @@ export function updateRunListAttribution(
310353 const history = viewable . history ( ) ;
311354 if ( ! history ) throw new HistoryCompactedError ( ) ;
312355 if ( state . processedHistoryIndex > history . length ) throw new HistoryCompactedError ( ) ;
356+ if ( ! state . _workDoc ) throw new HistoryCompactedError ( ) ;
357+
358+ if ( state . processedHistoryIndex === history . length ) {
359+ return state ;
360+ }
361+
362+ // Pull just the new changes (since workDoc's heads), index by hash.
363+ const doc = viewable . doc ( ) as Doc < unknown > ;
364+ const newChanges = getChanges ( state . _workDoc , doc ) ;
365+ const changeByHash = new Map < string , Change > ( ) ;
366+ for ( const c of newChanges ) {
367+ changeByHash . set ( decodeChange ( c ) . hash , c ) ;
368+ }
313369
314370 const runs = state . runs . map ( r => ( { ...r } ) ) ;
315- let prevHeads = state . processedHeads ;
316- let lastHeads = state . processedHeads ;
371+ let prevHeads = decodeHeads ( state . processedHeads as Parameters < typeof decodeHeads > [ 0 ] ) ;
372+ let lastHeads : unknown [ ] = state . processedHeads ;
373+ let workDoc : Doc < unknown > = state . _workDoc ;
317374
318375 for ( let i = state . processedHistoryIndex ; i < history . length ; i ++ ) {
319376 const currHeads = history [ i ] ;
320- const changeHash = extractChangeHash ( currHeads ) ;
321- const meta = changeHash ? viewable . metadata ( changeHash ) : undefined ;
322- const attribution : CharAttribution = {
323- actor : meta ?. actor ?? 'unknown' ,
324- time : meta ?. time ?? 0 ,
325- } ;
326-
327- const decodedPrev = decodeHeads ( prevHeads as Parameters < typeof decodeHeads > [ 0 ] ) ;
328377 const decodedCurr = decodeHeads ( currHeads as Parameters < typeof decodeHeads > [ 0 ] ) ;
329- const patches = diff (
330- viewable . doc ( ) as Parameters < typeof diff > [ 0 ] ,
331- decodedPrev as unknown as Heads ,
332- decodedCurr as unknown as Heads ,
333- ) ;
334-
335- for ( const patch of patches ) {
336- if ( isTextPatch ( patch , textFieldName ) ) {
337- applyPatchToRuns ( runs , patch , attribution ) ;
338- }
378+ const newHash = newChangeHashAt ( prevHeads , decodedCurr ) ;
379+ const change = newHash ? changeByHash . get ( newHash ) : undefined ;
380+ if ( change ) {
381+ workDoc = replayChange ( workDoc , change , textFieldName , runs ) ;
339382 }
340-
341- prevHeads = currHeads as unknown [ ] ;
383+ prevHeads = decodedCurr ;
342384 lastHeads = Array . isArray ( currHeads ) ? currHeads : [ currHeads ] ;
343385 }
344386
345387 return {
346388 runs,
347389 processedHeads : lastHeads as unknown [ ] ,
348390 processedHistoryIndex : history . length ,
391+ _workDoc : workDoc ,
349392 } ;
350393}
351394
@@ -367,9 +410,13 @@ export function updateRunListAttribution(
367410 * ASCII-only docs: map is the identity. Non-ASCII docs require this
368411 * translation for correctness — a missing translation would silently
369412 * misattribute any range past the first multi-byte character.
413+ *
414+ * Returned as `Uint32Array` so the per-codeunit storage is a single
415+ * contiguous buffer of 32-bit ints rather than boxed `number` slots —
416+ * matters because this is rebuilt on every debounced payload update.
370417 */
371- export function buildCharToByteMap ( text : string ) : number [ ] {
372- const map = new Array < number > ( text . length + 1 ) ;
418+ export function buildCharToByteMap ( text : string ) : Uint32Array {
419+ const map = new Uint32Array ( text . length + 1 ) ;
373420 let byteOff = 0 ;
374421 for ( let i = 0 ; i < text . length ; i ++ ) {
375422 map [ i ] = byteOff ;
@@ -399,7 +446,7 @@ export function buildCharToByteMap(text: string): number[] {
399446 */
400447export function runsCharToByteOffsets (
401448 runs : AttributionRun [ ] ,
402- charToByte : number [ ] ,
449+ charToByte : Uint32Array ,
403450) : AttributionRun [ ] {
404451 return runs . map ( r => ( {
405452 start : charToByte [ r . start ] ?? r . start ,
0 commit comments