@@ -3,9 +3,11 @@ import { Doc as YDoc, XmlElement } from 'yjs';
33import {
44 DEFAULT_BOOTSTRAP_SETTLING_MS ,
55 DEFAULT_BOOTSTRAP_JITTER_MS ,
6+ waitForContentSettling ,
67 detectRoomState ,
78 resolveBootstrapDecision ,
89 writeBootstrapMarker ,
10+ clearBootstrapMarker ,
911 claimBootstrap ,
1012 detectBootstrapRace ,
1113 type BootstrapMarker ,
@@ -109,6 +111,15 @@ describe('writeBootstrapMarker', () => {
109111 expect ( typeof marker . seededAt ) . toBe ( 'string' ) ;
110112 } ) ;
111113
114+ test ( 'clearBootstrapMarker removes the marker from the meta map' , ( ) => {
115+ const ydoc = new YDoc ( ) ;
116+ writeBootstrapMarker ( ydoc , 'doc' ) ;
117+ expect ( ydoc . getMap ( 'meta' ) . get ( 'bootstrap' ) ) . toBeDefined ( ) ;
118+
119+ clearBootstrapMarker ( ydoc ) ;
120+ expect ( ydoc . getMap ( 'meta' ) . get ( 'bootstrap' ) ) . toBeUndefined ( ) ;
121+ } ) ;
122+
112123 test ( 'finalized marker makes detectRoomState return populated' , ( ) => {
113124 const ydoc = new YDoc ( ) ;
114125 writeBootstrapMarker ( ydoc , 'doc' ) ;
@@ -233,6 +244,46 @@ describe('claimBootstrap', () => {
233244 expect ( decision ) . toEqual ( { action : 'seed' , source : 'doc' } ) ;
234245 } ) ;
235246
247+ test ( 'SD-2138 regression: stale pending marker after join-after-claim causes false-empty on reconnect' , async ( ) => {
248+ // Simulates the exact scenario that causes data loss:
249+ // 1. Client wins claim (pending marker written)
250+ // 2. Content arrives during settling → client joins instead of seeding
251+ // 3. If pending marker is NOT cleared, a future reconnect sees:
252+ // - empty fragment (slow sync) + pending-only marker → 'empty' → destructive re-seed
253+ // 4. Clearing the marker ensures the room doesn't have a misleading pending signal
254+ const ydoc = new YDoc ( ) ;
255+
256+ // Step 1: Win the claim — writes pending marker
257+ const claim = await claimBootstrap ( ydoc , 0 , 0 ) ;
258+ expect ( claim . granted ) . toBe ( true ) ;
259+
260+ const marker = ydoc . getMap ( 'meta' ) . get ( 'bootstrap' ) as BootstrapMarker ;
261+ expect ( marker . source ) . toBe ( 'pending' ) ;
262+
263+ // Step 2: Content arrived during settling (simulate)
264+ const fragment = ydoc . getXmlFragment ( 'supereditor' ) ;
265+ fragment . insert ( 0 , [ new XmlElement ( 'p' ) ] ) ;
266+ expect ( detectRoomState ( ydoc ) ) . toBe ( 'populated' ) ;
267+
268+ // Step 3: Clear the pending marker (this is the fix)
269+ clearBootstrapMarker ( ydoc ) ;
270+
271+ // Step 4: Simulate future reconnect — new ydoc where only meta synced,
272+ // fragment hasn't arrived yet (slow-sync scenario)
273+ const reconnectYdoc = new YDoc ( ) ;
274+ // No fragment content, no meta — room is clean after marker was cleared
275+ expect ( detectRoomState ( reconnectYdoc ) ) . toBe ( 'empty' ) ;
276+
277+ // Without the fix, the pending marker would persist and detectRoomState
278+ // would still return 'empty' — but the danger is that it LOOKS like a
279+ // fresh room rather than a room with a stale claim. With the marker
280+ // cleared, at least there's no misleading pending signal.
281+
282+ // The critical assertion: after clearing, the original ydoc's meta map
283+ // has no bootstrap key that could sync to new clients as a stale pending marker
284+ expect ( ydoc . getMap ( 'meta' ) . get ( 'bootstrap' ) ) . toBeUndefined ( ) ;
285+ } ) ;
286+
236287 test ( 'concurrent claimers: second claimer re-detects and joins after first seeds' , async ( ) => {
237288 // Simulates the full claim -> re-detect -> join path for a race loser
238289 const ydoc = new YDoc ( ) ;
@@ -354,3 +405,82 @@ describe('DEFAULT_BOOTSTRAP_JITTER_MS', () => {
354405 expect ( DEFAULT_BOOTSTRAP_JITTER_MS ) . toBeGreaterThan ( 0 ) ;
355406 } ) ;
356407} ) ;
408+
409+ // ---------------------------------------------------------------------------
410+ // waitForContentSettling (SD-2138)
411+ // ---------------------------------------------------------------------------
412+
413+ describe ( 'waitForContentSettling' , ( ) => {
414+ test ( 'resolves immediately when fragment already has content' , async ( ) => {
415+ const ydoc = new YDoc ( ) ;
416+ const fragment = ydoc . getXmlFragment ( 'supereditor' ) ;
417+ fragment . insert ( 0 , [ new XmlElement ( 'p' ) ] ) ;
418+
419+ const before = Date . now ( ) ;
420+ await waitForContentSettling ( ydoc , 500 ) ;
421+ expect ( Date . now ( ) - before ) . toBeLessThan ( 50 ) ;
422+ } ) ;
423+
424+ test ( 'resolves immediately when meta map has finalized bootstrap marker' , async ( ) => {
425+ const ydoc = new YDoc ( ) ;
426+ ydoc . getMap ( 'meta' ) . set ( 'bootstrap' , { version : 1 , source : 'doc' } ) ;
427+
428+ const before = Date . now ( ) ;
429+ await waitForContentSettling ( ydoc , 500 ) ;
430+ expect ( Date . now ( ) - before ) . toBeLessThan ( 50 ) ;
431+ } ) ;
432+
433+ test ( 'resolves immediately when meta map has non-bootstrap entries' , async ( ) => {
434+ const ydoc = new YDoc ( ) ;
435+ ydoc . getMap ( 'meta' ) . set ( 'docx' , 'some-content' ) ;
436+
437+ const before = Date . now ( ) ;
438+ await waitForContentSettling ( ydoc , 500 ) ;
439+ expect ( Date . now ( ) - before ) . toBeLessThan ( 50 ) ;
440+ } ) ;
441+
442+ test ( 'waits and resolves when fragment is populated during settling' , async ( ) => {
443+ const ydoc = new YDoc ( ) ;
444+ const fragment = ydoc . getXmlFragment ( 'supereditor' ) ;
445+
446+ // Populate fragment after 20ms
447+ setTimeout ( ( ) => {
448+ fragment . insert ( 0 , [ new XmlElement ( 'p' ) ] ) ;
449+ } , 20 ) ;
450+
451+ const before = Date . now ( ) ;
452+ await waitForContentSettling ( ydoc , 500 ) ;
453+ const elapsed = Date . now ( ) - before ;
454+
455+ // Should resolve quickly after content arrives, not wait full 500ms
456+ expect ( elapsed ) . toBeGreaterThanOrEqual ( 15 ) ;
457+ expect ( elapsed ) . toBeLessThan ( 200 ) ;
458+ } ) ;
459+
460+ test ( 'times out when no content arrives' , async ( ) => {
461+ const ydoc = new YDoc ( ) ;
462+
463+ const before = Date . now ( ) ;
464+ await waitForContentSettling ( ydoc , 50 ) ;
465+ const elapsed = Date . now ( ) - before ;
466+
467+ expect ( elapsed ) . toBeGreaterThanOrEqual ( 40 ) ;
468+ } ) ;
469+
470+ test ( 'does not treat pending bootstrap marker as content' , async ( ) => {
471+ const ydoc = new YDoc ( ) ;
472+ ydoc . getMap ( 'meta' ) . set ( 'bootstrap' , {
473+ version : 1 ,
474+ clientId : 999 ,
475+ seededAt : new Date ( ) . toISOString ( ) ,
476+ source : 'pending' ,
477+ } ) ;
478+
479+ const before = Date . now ( ) ;
480+ await waitForContentSettling ( ydoc , 50 ) ;
481+ const elapsed = Date . now ( ) - before ;
482+
483+ // Should wait full timeout since pending marker is not content
484+ expect ( elapsed ) . toBeGreaterThanOrEqual ( 40 ) ;
485+ } ) ;
486+ } ) ;
0 commit comments