Skip to content

Commit eb3e11f

Browse files
committed
fix(collaboration): harden part-sync hydration and dynamic OOXML key resolution
1 parent 938bf61 commit eb3e11f

4 files changed

Lines changed: 222 additions & 18 deletions

File tree

packages/super-editor/src/extensions/collaboration/part-sync/part-spec-registry.js

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,58 @@ function deterministicDynamicId(partPath) {
572572
return `dyn_${encoded}`;
573573
}
574574

575+
/**
576+
* Decode a deterministic dynamic ID back to its part path.
577+
*
578+
* Inverse of deterministicDynamicId. Returns null for malformed encodings.
579+
*
580+
* @param {string} id
581+
* @returns {string | null}
582+
*/
583+
function decodeDeterministicDynamicId(id) {
584+
if (!id.startsWith('dyn_')) return null;
585+
const encoded = id.slice('dyn_'.length);
586+
let out = '';
587+
588+
for (let i = 0; i < encoded.length; ) {
589+
const ch = encoded[i];
590+
if (ch !== '_') {
591+
out += ch;
592+
i += 1;
593+
continue;
594+
}
595+
596+
const hex = encoded.slice(i + 1, i + 3);
597+
if (!/^[0-9A-Fa-f]{2}$/.test(hex)) return null;
598+
599+
out += String.fromCharCode(parseInt(hex, 16));
600+
i += 3;
601+
}
602+
603+
return out;
604+
}
605+
606+
/**
607+
* Ensure a discovered dynamic spec is cached for this converter and indexed.
608+
*
609+
* @param {object | undefined} converter
610+
* @param {PartSpec} spec
611+
*/
612+
function registerDiscoveredSpec(converter, spec) {
613+
registerInPrefixIndex(spec);
614+
615+
if (!converter) return;
616+
const cached = discoveredSpecsCache.get(converter);
617+
if (cached) {
618+
if (!cached.some((candidate) => candidate.id === spec.id)) {
619+
cached.push(spec);
620+
}
621+
return;
622+
}
623+
624+
discoveredSpecsCache.set(converter, [spec]);
625+
}
626+
575627
/**
576628
* Scan a converter for XML parts not covered by static specs and create
577629
* generic PartSpecs for them. Results are cached per converter.
@@ -583,7 +635,14 @@ export function discoverGenericSpecs(converter) {
583635
if (!converter) return [];
584636

585637
const cached = discoveredSpecsCache.get(converter);
586-
if (cached) return cached;
638+
if (cached) {
639+
// Re-register cached specs because another converter invalidation can
640+
// clear their shared prefixIndex entries.
641+
for (const spec of cached) {
642+
registerInPrefixIndex(spec);
643+
}
644+
return cached;
645+
}
587646

588647
const store = converter.parts ?? converter.convertedXml ?? {};
589648
const specs = [];
@@ -685,6 +744,19 @@ export function resolveOoxmlPartKey(key, converter) {
685744
if (section != null) return { spec, section };
686745
}
687746

747+
// Infer dynamic spec directly from incoming key so late-joining clients can
748+
// resolve remote dyn_* keys that are not in local converter.parts yet.
749+
if (slashIdx !== -1) {
750+
const id = key.slice(0, slashIdx);
751+
const partPath = decodeDeterministicDynamicId(id);
752+
if (partPath && !isExcludedFromDiscovery(partPath)) {
753+
const spec = createXmlPartSpec(id, partPath);
754+
registerDiscoveredSpec(converter, spec);
755+
const section = spec.parseKey(key);
756+
if (section != null) return { spec, section };
757+
}
758+
}
759+
688760
return null;
689761
}
690762

@@ -705,9 +777,19 @@ export function resolveOoxmlPartKey(key, converter) {
705777
*/
706778
export function resolvePartChangedSpec(partId, changedPaths, converter) {
707779
if (partId === 'styles') {
780+
const sectionHints = changedPaths
781+
?.map((path) => {
782+
if (typeof path !== 'string') return null;
783+
const noArrayIndices = path.replace(/\[\d+\]/g, '');
784+
const section = noArrayIndices.split('.')[0];
785+
return STYLE_SECTIONS.includes(section) ? section : null;
786+
})
787+
.filter(Boolean);
788+
789+
const dedupedSectionHints = sectionHints?.length > 0 ? [...new Set(sectionHints)] : undefined;
708790
return {
709791
spec: STYLES_SPEC,
710-
sectionHints: changedPaths?.map((p) => p.split('.')[0]),
792+
sectionHints: dedupedSectionHints,
711793
};
712794
}
713795

packages/super-editor/src/extensions/collaboration/part-sync/part-spec-registry.test.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,21 @@ describe('resolveOoxmlPartKey — prefix index', () => {
759759
expect(result).not.toBeNull();
760760
expect(result.section).toBe('root');
761761
});
762+
763+
it('resolves dyn_* keys from incoming key even when part is not local yet', () => {
764+
const converter = { parts: {} };
765+
const result = resolveOoxmlPartKey('dyn_customXml_2FremoteOnly_2Exml/root', converter);
766+
expect(result).not.toBeNull();
767+
expect(result.spec.id).toBe('dyn_customXml_2FremoteOnly_2Exml');
768+
expect(result.spec.partPath).toBe('customXml/remoteOnly.xml');
769+
expect(result.section).toBe('root');
770+
});
771+
772+
it('rejects inferred dyn_* keys that decode to excluded/static-covered paths', () => {
773+
const converter = { parts: {} };
774+
const result = resolveOoxmlPartKey('dyn_word_2Fdocument_2Exml/root', converter);
775+
expect(result).toBeNull();
776+
});
762777
});
763778

764779
// ---------------------------------------------------------------------------
@@ -772,6 +787,18 @@ describe('resolvePartChangedSpec', () => {
772787
expect(result.sectionHints).toEqual(['docDefaults', 'styles']);
773788
});
774789

790+
it('normalizes styles array-index changed paths to valid style sections', () => {
791+
const result = resolvePartChangedSpec('styles', ['styles[0].name', 'styles[10].runProperties.bold']);
792+
expect(result.spec).toBe(STYLES_SPEC);
793+
expect(result.sectionHints).toEqual(['styles']);
794+
});
795+
796+
it('falls back to full styles publish when changedPaths do not map to known sections', () => {
797+
const result = resolvePartChangedSpec('styles', ['unknownBranch.value']);
798+
expect(result.spec).toBe(STYLES_SPEC);
799+
expect(result.sectionHints).toBeUndefined();
800+
});
801+
775802
it('routes header:rId1 to HEADER_FOOTER_CONTENT_SPEC', () => {
776803
const result = resolvePartChangedSpec('header:rId1');
777804
expect(result.spec).toBe(HEADER_FOOTER_CONTENT_SPEC);
@@ -918,6 +945,25 @@ describe('discoverGenericSpecs', () => {
918945
const specs2 = discoverGenericSpecs(converter2);
919946
expect(specs1[0].id).toBe(specs2[0].id);
920947
});
948+
949+
it('re-registers cached dynamic prefixes after another converter invalidates', () => {
950+
const converter1 = {
951+
parts: { 'customXml/item1.xml': { elements: [{ name: 'root' }] } },
952+
};
953+
const converter2 = {
954+
parts: { 'customXml/item1.xml': { elements: [{ name: 'root' }] } },
955+
};
956+
957+
discoverGenericSpecs(converter1);
958+
discoverGenericSpecs(converter2);
959+
960+
// This removes dyn_customXml_2Fitem1_2Exml/ from the shared prefix index.
961+
invalidateDiscoveredSpecs(converter1);
962+
963+
const result = resolveOoxmlPartKey('dyn_customXml_2Fitem1_2Exml/root', converter2);
964+
expect(result).not.toBeNull();
965+
expect(result.section).toBe('root');
966+
});
921967
});
922968

923969
describe('invalidateDiscoveredSpecs', () => {

packages/super-editor/src/extensions/collaboration/part-sync/part-sync-engine.js

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,48 @@
1616

1717
import { EXCLUDED_PART_PATHS } from './part-spec-registry.js';
1818

19+
const SPEC_READY_META_PREFIX = '__specReady/';
20+
21+
/**
22+
* Build a stable metadata key for per-spec readiness tracking.
23+
*
24+
* @param {import('./part-spec-registry.js').PartSpec} spec
25+
* @returns {string}
26+
*/
27+
function getSpecReadinessMetaKey(spec) {
28+
return `${SPEC_READY_META_PREFIX}${spec.channel}:${spec.id}`;
29+
}
30+
31+
/**
32+
* Determine whether remote has ever published this spec.
33+
*
34+
* Uses an explicit readiness marker plus a legacy fallback that scans section
35+
* metadata keys, so older rooms still hydrate/delete correctly.
36+
*
37+
* @param {object} metaMap
38+
* @param {import('./part-spec-registry.js').PartSpec} spec
39+
* @returns {boolean}
40+
*/
41+
function hasRemoteSpecReadiness(metaMap, spec) {
42+
if (!metaMap) return false;
43+
44+
const readinessKey = getSpecReadinessMetaKey(spec);
45+
if (typeof metaMap.has === 'function' && metaMap.has(readinessKey)) {
46+
return true;
47+
}
48+
49+
if (typeof metaMap.forEach !== 'function') return false;
50+
51+
let found = false;
52+
metaMap.forEach((_value, key) => {
53+
if (found || typeof key !== 'string') return;
54+
if (spec.parseKey(key) != null) {
55+
found = true;
56+
}
57+
});
58+
return found;
59+
}
60+
1961
// ---------------------------------------------------------------------------
2062
// Per-editor guard state
2163
// ---------------------------------------------------------------------------
@@ -122,6 +164,8 @@ export function publishPartSections(editor, spec, sectionHints) {
122164

123165
const userId = editor.options.user?.id ?? 'unknown';
124166
const metaMap = ydoc.getMap('ooxmlPartMeta');
167+
const now = Date.now();
168+
const readinessKey = getSpecReadinessMetaKey(spec);
125169

126170
ydoc.transact(
127171
() => {
@@ -135,8 +179,12 @@ export function publishPartSections(editor, spec, sectionHints) {
135179
map.set('_version', spec.version);
136180
}
137181
for (const { key } of writes) {
138-
metaMap.set(key, { updatedBy: userId, updatedAt: Date.now() });
182+
metaMap.set(key, { updatedBy: userId, updatedAt: now });
139183
}
184+
// Marks this spec as remotely initialized even if all section keys were
185+
// later deleted. Hydration uses this to decide whether empty remote
186+
// state should delete stale local sections or trigger first-time seeding.
187+
metaMap.set(readinessKey, { updatedBy: userId, updatedAt: now, version: spec.version ?? null });
140188
},
141189
{ event: `${spec.id}-publish`, user: editor.options.user },
142190
);
@@ -264,6 +312,7 @@ export function hydrateOrSeedPart(editor, spec) {
264312
if (!ydoc || ydoc.isDestroyed) return;
265313

266314
const map = ydoc.getMap(spec.channel);
315+
const metaMap = ydoc.getMap('ooxmlPartMeta');
267316

268317
if (map.has('_version')) {
269318
// Room has this channel — hydrate from remote authority.
@@ -279,20 +328,27 @@ export function hydrateOrSeedPart(editor, spec) {
279328
});
280329
}
281330

282-
if (keys.length > 0) {
283-
applyRemotePartSections(editor, spec, map, keys);
284-
}
331+
const hasRemoteSpecState = keys.length > 0 || hasRemoteSpecReadiness(metaMap, spec);
332+
if (hasRemoteSpecState) {
333+
if (keys.length > 0) {
334+
applyRemotePartSections(editor, spec, map, keys);
335+
}
285336

286-
// Remove local sections that no longer exist remotely. This handles parts
287-
// deleted in-room before this client joined — the Y.Map has _version but
288-
// no remaining keys for this spec, yet bootstrap left stale local data.
289-
if (editor.converter && spec.removeSection) {
290-
const localSections = spec.listSections(editor.converter);
291-
const remoteSet = new Set(keys.map((k) => spec.parseKey(k)));
292-
const staleKeys = localSections.filter((s) => !remoteSet.has(s)).map((s) => spec.sectionKey(s));
293-
if (staleKeys.length > 0) {
294-
deleteRemotePartSections(editor, spec, staleKeys);
337+
// Remove local sections that no longer exist remotely, but only once this
338+
// specific spec has remote readiness. Shared channels (ooxmlPartModels)
339+
// can have _version set by another spec before this one is initialized.
340+
if (editor.converter && spec.removeSection) {
341+
const localSections = spec.listSections(editor.converter);
342+
const remoteSet = new Set(keys.map((k) => spec.parseKey(k)));
343+
const staleKeys = localSections.filter((s) => !remoteSet.has(s)).map((s) => spec.sectionKey(s));
344+
if (staleKeys.length > 0) {
345+
deleteRemotePartSections(editor, spec, staleKeys);
346+
}
295347
}
348+
} else if (editor.converter) {
349+
// Channel exists but this spec has never been initialized remotely.
350+
// Seed local state instead of deleting it as stale.
351+
publishPartSections(editor, spec);
296352
}
297353
} else if (editor.converter) {
298354
// Room doesn't have this channel — seed from local converter

packages/super-editor/src/extensions/collaboration/part-sync/part-sync-engine.test.js

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -441,8 +441,9 @@ describe('hydrateOrSeedPart', () => {
441441
expect(spec.applySection).not.toHaveBeenCalled();
442442
});
443443

444-
it('removes stale local sections that were deleted remotely', () => {
445-
// Room has _version but no keys for this spec → all local sections are stale
444+
it('seeds local sections when channel has _version but spec is not remotely initialized', () => {
445+
// Shared channel may have _version set by another spec while this one is
446+
// still uninitialized remotely. In that case we seed instead of delete.
446447
const channelMap = createMockYMap({ _version: 1 });
447448
const ydoc = createMockYdoc({ testChannel: channelMap });
448449
editor = createMockEditor({ options: { ydoc, user: { id: 'user-1' } } });
@@ -453,7 +454,26 @@ describe('hydrateOrSeedPart', () => {
453454

454455
hydrateOrSeedPart(editor, spec);
455456

456-
// Should not apply (nothing remote), but should remove stale local 'root'
457+
// Should seed local root to remote channel and not delete local state.
458+
expect(spec.applySection).not.toHaveBeenCalled();
459+
expect(channelMap.set).toHaveBeenCalledWith('test-spec/root', expect.anything());
460+
expect(spec.removeSection).not.toHaveBeenCalled();
461+
});
462+
463+
it('removes stale local sections when spec has remote readiness metadata', () => {
464+
const channelMap = createMockYMap({ _version: 1 });
465+
const metaMap = createMockYMap({
466+
'test-spec/root': { updatedBy: 'user-2', updatedAt: 1000 },
467+
});
468+
const ydoc = createMockYdoc({ testChannel: channelMap, ooxmlPartMeta: metaMap });
469+
editor = createMockEditor({ options: { ydoc, user: { id: 'user-1' } } });
470+
spec = createMockSpec({
471+
listSections: vi.fn(() => ['root']),
472+
removeSection: vi.fn(),
473+
});
474+
475+
hydrateOrSeedPart(editor, spec);
476+
457477
expect(spec.applySection).not.toHaveBeenCalled();
458478
expect(spec.removeSection).toHaveBeenCalledWith(editor.converter, 'root');
459479
});

0 commit comments

Comments
 (0)