Skip to content

Commit 7263798

Browse files
committed
fix(document-api): invalidate stale header/footer story runtimes on part and ref mutations
1 parent 1ee57cb commit 7263798

15 files changed

Lines changed: 1243 additions & 251 deletions

packages/super-editor/src/document-api-adapters/header-footers-adapter.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
} from './helpers/header-footer-refs-mutation.js';
3838
import { createHeaderFooterPart, type ConverterWithHeaderFooterParts } from './helpers/header-footer-parts.js';
3939
import { rejectTrackedMode } from './helpers/mutation-helpers.js';
40+
import { getStoryRuntimeCache } from './story-runtime/resolve-story-runtime.js';
4041

4142
// ---------------------------------------------------------------------------
4243
// Constants
@@ -68,6 +69,23 @@ function requireConverter(editor: Editor, operationName: string): ConverterWithH
6869
// Helpers
6970
// ---------------------------------------------------------------------------
7071

72+
/**
73+
* Invalidates all cached header/footer *slot* runtimes after a ref-only
74+
* mutation (set, clear, setLinkedToPrevious). These operations retarget
75+
* which part a slot resolves to without touching the part itself, so the
76+
* generic `partChanged` event never fires for a header/footer part. The
77+
* cached slot runtimes would keep serving the old part's editor otherwise.
78+
*/
79+
function invalidateSlotRuntimesAfterRefChange(
80+
editor: Editor,
81+
result: SectionMutationResult,
82+
options?: MutationOptions,
83+
): void {
84+
if (!result.success || options?.dryRun) return;
85+
const cache = getStoryRuntimeCache(editor);
86+
if (cache) cache.invalidateByPrefix('hf:slot:');
87+
}
88+
7189
function effectiveLimitOf(limit: number | undefined, total: number): number {
7290
return limit ?? total;
7391
}
@@ -204,7 +222,7 @@ export function headerFootersRefsSetAdapter(
204222
const { section, headerFooterKind, variant } = input.target;
205223
const sectionTarget = { target: section };
206224

207-
return sectionMutationBySectPr(
225+
const result = sectionMutationBySectPr(
208226
editor,
209227
sectionTarget,
210228
options,
@@ -222,6 +240,8 @@ export function headerFootersRefsSetAdapter(
222240
);
223241
},
224242
);
243+
invalidateSlotRuntimesAfterRefChange(editor, result, options);
244+
return result;
225245
}
226246

227247
export function headerFootersRefsClearAdapter(
@@ -232,7 +252,7 @@ export function headerFootersRefsClearAdapter(
232252
const { section, headerFooterKind, variant } = input.target;
233253
const sectionTarget = { target: section };
234254

235-
return sectionMutationBySectPr(
255+
const result = sectionMutationBySectPr(
236256
editor,
237257
sectionTarget,
238258
options,
@@ -242,6 +262,8 @@ export function headerFootersRefsClearAdapter(
242262
clearHeaderFooterRefMutation(sectPr, headerFooterKind, variant, converter, dryRun);
243263
},
244264
);
265+
invalidateSlotRuntimesAfterRefChange(editor, result, options);
266+
return result;
245267
}
246268

247269
export function headerFootersRefsSetLinkedToPreviousAdapter(
@@ -252,7 +274,7 @@ export function headerFootersRefsSetLinkedToPreviousAdapter(
252274
const { section, headerFooterKind, variant } = input.target;
253275
const sectionTarget = { target: section };
254276

255-
return sectionMutationBySectPr(
277+
const result = sectionMutationBySectPr(
256278
editor,
257279
sectionTarget,
258280
options,
@@ -271,6 +293,8 @@ export function headerFootersRefsSetLinkedToPreviousAdapter(
271293
);
272294
},
273295
);
296+
invalidateSlotRuntimesAfterRefChange(editor, result, options);
297+
return result;
274298
}
275299

276300
// ---------------------------------------------------------------------------

packages/super-editor/src/document-api-adapters/helpers/range-resolver.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,15 @@ function encodeTestRef(rev: string, segments: Array<{ blockId: string; start: nu
105105
return `text:${btoa(JSON.stringify({ v: 3, rev, segments }))}`;
106106
}
107107

108+
/** Encodes a V4 text ref with story key support. */
109+
function encodeV4TestRef(
110+
rev: string,
111+
storyKey: string,
112+
segments: Array<{ blockId: string; start: number; end: number }>,
113+
): string {
114+
return `text:v4:${btoa(JSON.stringify({ v: 4, rev, storyKey, scope: 'match', segments }))}`;
115+
}
116+
108117
// ---------------------------------------------------------------------------
109118
// Fixtures
110119
// ---------------------------------------------------------------------------
@@ -374,7 +383,7 @@ describe('resolveRange', () => {
374383
end: { kind: 'document', edge: 'end' },
375384
};
376385

377-
expect(() => resolveRange(editor, input)).toThrow('Invalid text ref encoding');
386+
expect(() => resolveRange(editor, input)).toThrow('Only text refs');
378387
});
379388

380389
it('rejects ref with no segments', () => {
@@ -403,6 +412,50 @@ describe('resolveRange', () => {
403412
expect(() => resolveRange(editor, input)).toThrow(PlanError);
404413
expect(() => resolveRange(editor, input)).toThrow('REVISION_MISMATCH');
405414
});
415+
416+
it('resolves V4 text refs (text:v4: prefix) just like V3 refs', () => {
417+
const { editor, index } = singleParagraph();
418+
mocks.getBlockIndex.mockReturnValue(index);
419+
420+
const ref = encodeV4TestRef('0', 'fn:1', [{ blockId: 'p1', start: 1, end: 4 }]);
421+
mocks.resolveSelectionPointPosition
422+
.mockReturnValueOnce(2) // start boundary → pos 2
423+
.mockReturnValueOnce(5); // end boundary → pos 5
424+
425+
const input: ResolveRangeInput = {
426+
start: { kind: 'ref', ref, boundary: 'start' },
427+
end: { kind: 'ref', ref, boundary: 'end' },
428+
};
429+
430+
const result = resolveRange(editor, input);
431+
432+
expect(mocks.resolveSelectionPointPosition).toHaveBeenCalledWith(editor, {
433+
kind: 'text',
434+
blockId: 'p1',
435+
offset: 1,
436+
});
437+
expect(mocks.resolveSelectionPointPosition).toHaveBeenCalledWith(editor, {
438+
kind: 'text',
439+
blockId: 'p1',
440+
offset: 4,
441+
});
442+
expect(result.evaluatedRevision).toBe('0');
443+
expect(result.target.kind).toBe('selection');
444+
});
445+
446+
it('rejects stale V4 ref with REVISION_MISMATCH', () => {
447+
const { editor, index } = singleParagraph();
448+
mocks.getBlockIndex.mockReturnValue(index);
449+
450+
const ref = encodeV4TestRef('99', 'fn:1', [{ blockId: 'p1', start: 0, end: 3 }]);
451+
const input: ResolveRangeInput = {
452+
start: { kind: 'ref', ref, boundary: 'start' },
453+
end: { kind: 'document', edge: 'end' },
454+
};
455+
456+
expect(() => resolveRange(editor, input)).toThrow(PlanError);
457+
expect(() => resolveRange(editor, input)).toThrow('REVISION_MISMATCH');
458+
});
406459
});
407460

408461
// -----------------------------------------------------------------------

packages/super-editor/src/document-api-adapters/helpers/range-resolver.ts

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { encodeV3Ref } from '../plan-engine/query-match-adapter.js';
2727
import { getRevision, checkRevision } from '../plan-engine/revision-tracker.js';
2828
import { PlanError } from '../plan-engine/errors.js';
2929
import { DocumentApiAdapterError } from '../errors.js';
30+
import { decodeRef } from '../story-runtime/story-ref-codec.js';
3031

3132
// ---------------------------------------------------------------------------
3233
// Constants
@@ -75,52 +76,40 @@ function resolveDocumentEnd(editor: Editor, index: BlockIndex): number {
7576
/**
7677
* Decodes a text ref and extracts the start or end boundary as an absolute position.
7778
*
78-
* Only accepts `text:` prefixed refs (V3 text refs from query.match or ranges.resolve).
79+
* Accepts both V3 (`text:...`) and V4 (`text:v4:...`) refs from query.match or ranges.resolve.
7980
*/
8081
function resolveRefAnchor(editor: Editor, ref: string, boundary: 'start' | 'end', revision: string): number {
81-
if (!ref.startsWith('text:')) {
82+
const decoded = decodeRef(ref);
83+
84+
if (!decoded) {
8285
throw new DocumentApiAdapterError(
8386
'INVALID_TARGET',
84-
`Only text refs (from query.match or ranges.resolve) are valid range anchors. Got prefix: "${ref.split(':')[0]}".`,
87+
`Only text refs (from query.match or ranges.resolve) are valid range anchors. Got: "${ref}".`,
8588
{ ref, boundary },
8689
);
8790
}
8891

89-
const encoded = ref.slice('text:'.length);
90-
let payload: unknown;
91-
try {
92-
payload = JSON.parse(atob(encoded));
93-
} catch {
94-
throw new DocumentApiAdapterError('INVALID_TARGET', 'Invalid text ref encoding.', { ref, boundary });
95-
}
96-
97-
const data = payload as {
98-
v?: number;
99-
rev?: string;
100-
segments?: Array<{ blockId: string; start: number; end: number }>;
101-
};
102-
103-
if (!data.segments?.length) {
92+
const segments = decoded.segments;
93+
if (!segments?.length) {
10494
throw new DocumentApiAdapterError('INVALID_TARGET', 'Ref contains no segments.', { ref, boundary });
10595
}
10696

107-
if (data.rev !== revision) {
97+
if (decoded.rev !== revision) {
10898
throw new PlanError(
10999
'REVISION_MISMATCH',
110-
`REVISION_MISMATCH — ref was created at revision ${data.rev} but document is at revision ${revision}. Re-run the discovery operation to obtain a fresh ref.`,
100+
`REVISION_MISMATCH — ref was created at revision ${decoded.rev} but document is at revision ${revision}. Re-run the discovery operation to obtain a fresh ref.`,
111101
undefined,
112102
{
113103
ref,
114104
boundary,
115-
refRevision: data.rev,
105+
refRevision: decoded.rev,
116106
currentRevision: revision,
117107
refStability: 'ephemeral',
118108
remediation: 'Re-run ranges.resolve or query.match to obtain a fresh ref valid for the current revision.',
119109
},
120110
);
121111
}
122-
123-
const seg = boundary === 'start' ? data.segments[0] : data.segments[data.segments.length - 1];
112+
const seg = boundary === 'start' ? segments[0] : segments[segments.length - 1];
124113
const offset = boundary === 'start' ? seg.start : seg.end;
125114
const point: SelectionPoint = { kind: 'text', blockId: seg.blockId, offset };
126115

0 commit comments

Comments
 (0)