Skip to content

Commit 997f2a7

Browse files
committed
fix: preserve paragraph runtime ids and reject empty insert refs
1 parent 3071690 commit 997f2a7

5 files changed

Lines changed: 89 additions & 13 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { executeInsert, type InsertInput } from './insert.js';
3+
import type { SelectionMutationAdapter } from '../selection-mutation.js';
4+
import type { WriteAdapter } from '../write/write.js';
5+
6+
// ---------------------------------------------------------------------------
7+
// Stub adapters — validation runs before any adapter method is called, so
8+
// these stubs only need to exist (they should never be invoked in error cases).
9+
// ---------------------------------------------------------------------------
10+
11+
function createStubSelectionAdapter(): SelectionMutationAdapter {
12+
return { execute: vi.fn() } as unknown as SelectionMutationAdapter;
13+
}
14+
15+
function createStubWriteAdapter(): WriteAdapter {
16+
return { write: vi.fn(), insertStructured: vi.fn(), replaceStructured: vi.fn() } as unknown as WriteAdapter;
17+
}
18+
19+
// ---------------------------------------------------------------------------
20+
// ref validation
21+
// ---------------------------------------------------------------------------
22+
23+
describe('executeInsert: ref validation', () => {
24+
it('rejects empty string ref', () => {
25+
const input: InsertInput = { value: 'hello', ref: '' } as unknown as InsertInput;
26+
expect(() => executeInsert(createStubSelectionAdapter(), createStubWriteAdapter(), input)).toThrow(
27+
'ref must be a non-empty string',
28+
);
29+
});
30+
31+
it('rejects non-string ref', () => {
32+
const input = { value: 'hello', ref: 42 } as unknown as InsertInput;
33+
expect(() => executeInsert(createStubSelectionAdapter(), createStubWriteAdapter(), input)).toThrow(
34+
'ref must be a non-empty string',
35+
);
36+
});
37+
38+
it('does not reject undefined ref (untargeted insert is valid)', () => {
39+
const writeAdapter = createStubWriteAdapter();
40+
(writeAdapter.write as ReturnType<typeof vi.fn>).mockReturnValue({ success: true, ref: 'r1' });
41+
expect(() => executeInsert(createStubSelectionAdapter(), writeAdapter, { value: 'hello' })).not.toThrow();
42+
});
43+
});

packages/document-api/src/insert/insert.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,8 @@ function validateTextInsertInput(input: Record<string, unknown>): void {
151151
});
152152
}
153153

154-
if (ref !== undefined && typeof ref !== 'string') {
155-
throw new DocumentApiValidationError('INVALID_TARGET', `ref must be a string, got ${typeof ref}.`, {
154+
if (ref !== undefined && (typeof ref !== 'string' || ref === '')) {
155+
throw new DocumentApiValidationError('INVALID_TARGET', 'ref must be a non-empty string.', {
156156
field: 'ref',
157157
value: ref,
158158
});

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -307,16 +307,19 @@ describe('buildBlockIndex', () => {
307307
expect(index.candidates[0].nodeId).toMatch(/^para-auto-[0-9a-f]{8}$/);
308308
});
309309

310-
it('assigns deterministic fallback id when sdBlockId is a volatile UUID', () => {
310+
it('keeps volatile sdBlockId as primary id for session stability', () => {
311+
// Volatile (UUID-like) sdBlockIds are preferred over deterministic
312+
// fallback IDs because the fallback hashes nodeType + traversal path,
313+
// which shifts when siblings are inserted/moved or a paragraph is
314+
// restyled to heading/list-item. The UUID stays stable for the session.
315+
const volatileUuid = '7701a615-4ad8-45b5-922c-2a32114df4c8';
311316
const index = indexFromNodes({
312317
typeName: 'paragraph',
313-
attrs: { sdBlockId: '7701a615-4ad8-45b5-922c-2a32114df4c8' },
318+
attrs: { sdBlockId: volatileUuid },
314319
offset: 7,
315320
});
316321
expect(index.candidates).toHaveLength(1);
317-
expect(index.candidates[0].nodeId).toMatch(/^para-auto-[0-9a-f]{8}$/);
318-
// Volatile sdBlockId registered as alias
319-
expect(index.byId.get('paragraph:7701a615-4ad8-45b5-922c-2a32114df4c8')).toBeDefined();
322+
expect(index.candidates[0].nodeId).toBe(volatileUuid);
320323
});
321324

322325
it('keeps non-volatile sdBlockId as primary id for paragraphs', () => {

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

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,15 @@ function resolveLegacyTableIdentity(attrs: BlockIdAttrs): string | undefined {
117117
return toId(attrs.paraId) ?? toId(attrs.blockId) ?? toId(attrs.id) ?? toId(attrs.uuid);
118118
}
119119

120-
function resolveRuntimeBlockIdentity(
120+
/**
121+
* Resolves a runtime block identity for **table-like** nodes.
122+
*
123+
* Non-volatile sdBlockId is preferred; otherwise a deterministic fallback
124+
* (hashed from nodeType + traversal path) is used. This is correct for tables
125+
* because they keep a stable nodeType across mutations — their sdBlockId may
126+
* change when ProseMirror replaces the node during property edits.
127+
*/
128+
function resolveTableRuntimeIdentity(
121129
nodeType: BlockNodeType,
122130
attrs: BlockIdAttrs,
123131
pos: number,
@@ -130,6 +138,25 @@ function resolveRuntimeBlockIdentity(
130138
return buildFallbackBlockNodeId(nodeType, pos, path);
131139
}
132140

141+
/**
142+
* Resolves a runtime block identity for **paragraph-like** nodes
143+
* (paragraph, heading, listItem).
144+
*
145+
* Always prefers sdBlockId — even a volatile (UUID-like) one — because the
146+
* deterministic fallback hashes nodeType + traversal path, both of which shift
147+
* during ordinary edits: sibling inserts/moves change the path, and restyles
148+
* (paragraph → heading/listItem) change the nodeType. The sdBlockId stays
149+
* stable for the session lifetime.
150+
*/
151+
function resolveParagraphRuntimeIdentity(
152+
nodeType: BlockNodeType,
153+
attrs: BlockIdAttrs,
154+
pos: number,
155+
path?: TraversalPath,
156+
): string | undefined {
157+
return toId(attrs.sdBlockId) ?? buildFallbackBlockNodeId(nodeType, pos, path);
158+
}
159+
133160
export function resolveBlockNodeId(
134161
node: ProseMirrorNode,
135162
pos: number,
@@ -138,10 +165,9 @@ export function resolveBlockNodeId(
138165
): string | undefined {
139166
if (node.type.name === 'paragraph') {
140167
const attrs = node.attrs as ParagraphAttrs | undefined;
141-
// paraId (imported from DOCX) is the primary identity for paragraphs. This
142-
// preserves historical IDs across DOCX round-trips. Non-volatile sdBlockId
143-
// is preferred over deterministic fallback for freshly created nodes.
144-
return toId(attrs?.paraId) ?? resolveRuntimeBlockIdentity(nodeType, (attrs ?? {}) as BlockIdAttrs, pos, path);
168+
// paraId (imported from DOCX) is the primary identity for paragraphs —
169+
// preserves historical IDs across DOCX round-trips.
170+
return toId(attrs?.paraId) ?? resolveParagraphRuntimeIdentity(nodeType, (attrs ?? {}) as BlockIdAttrs, pos, path);
145171
}
146172

147173
if (nodeType === 'tableOfContents') {
@@ -162,7 +188,7 @@ export function resolveBlockNodeId(
162188
// UUID sdBlockId exists, expose a deterministic fallback instead so session
163189
// addresses remain reusable across fresh document opens.
164190
if (typeName === 'table' || typeName === 'tableCell' || typeName === 'tableHeader') {
165-
return resolveLegacyTableIdentity(attrs) ?? resolveRuntimeBlockIdentity(nodeType, attrs, pos, path);
191+
return resolveLegacyTableIdentity(attrs) ?? resolveTableRuntimeIdentity(nodeType, attrs, pos, path);
166192
}
167193

168194
// NOTE: Migration surface for the stable-addresses plan.

packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,10 @@ function insertStructuredInner(editor: Editor, input: InsertInput, options?: Mut
827827
let resolvedRange: ResolvedTextTarget;
828828
let effectiveTarget: TextAddress;
829829

830+
if (ref !== undefined && ref === '') {
831+
throw new DocumentApiAdapterError('INVALID_TARGET', 'ref must be a non-empty string.', { ref });
832+
}
833+
830834
if (target) {
831835
const resolved = resolveSelectionTarget(editor, target);
832836
resolvedRange = { from: resolved.absFrom, to: resolved.absTo };

0 commit comments

Comments
 (0)