diff --git a/apps/docs/editor/built-in-ui/track-changes.mdx b/apps/docs/editor/built-in-ui/track-changes.mdx index b9fe67687d..50a21cc631 100644 --- a/apps/docs/editor/built-in-ui/track-changes.mdx +++ b/apps/docs/editor/built-in-ui/track-changes.mdx @@ -159,7 +159,7 @@ Every entry returned by `list()` and `get()` has this shape: - SuperDoc's internal id for the revision. Stable across calls. + SuperDoc's tracked-change id for the loaded document instance. Stable across edits while the document is loaded. Entity address: `{ kind: 'entity', entityType: 'trackedChange', entityId: id }`. @@ -194,6 +194,12 @@ Every entry returned by `list()` and `get()` has this shape: - [`trackChanges.get`](/document-api/reference/track-changes/get) - [`trackChanges.decide`](/document-api/reference/track-changes/decide) +## Tracked-change ids + +`TrackChangeInfo.id` is SuperDoc's tracked-change id for the loaded document instance. It matches `address.entityId`, `payload.changeId`, and tracked-change `payload.comment.commentId`, and is the id accepted by `trackChanges.get()` and `trackChanges.decide()`. + +It is not the source DOCX `w:id`. For source-file correlation, use `wordRevisionIds.insert`, `.delete`, or `.format` when present. If you build a custom review panel, see [Custom UI > Track changes](/editor/custom-ui/track-changes) for story-scoped ids and cached-row patterns. + ## Toggling tracked edits Control recording via document mode: diff --git a/apps/docs/editor/custom-ui/track-changes.mdx b/apps/docs/editor/custom-ui/track-changes.mdx index e614f4f44e..77954649af 100644 --- a/apps/docs/editor/custom-ui/track-changes.mdx +++ b/apps/docs/editor/custom-ui/track-changes.mdx @@ -33,6 +33,48 @@ export function ReviewPanel() { `items` mirrors `editor.doc.trackChanges.list()`. Each item carries `id` plus the full `change` record (type, author, excerpt, address). +## Which id do I use? + +For nearly every workflow, `item.id` is the answer. + +| Question | Answer | +|---|---| +| What do I pass to `decide()` or `get()`? | `item.id` | +| What matches `onCommentsUpdate`? | `payload.changeId` / `payload.comment.commentId`, same value as `item.id` | +| Can I cache it during async work (LLM, dialog, etc.)? | Yes, while the document is loaded | +| Need to map back to the source DOCX revision? | `item.wordRevisionIds` | +| Should I parse `handle.ref`? | No - treat it as opaque | + +`item.id` is SuperDoc's tracked-change id for the loaded document. It's stable across edits, matches the id emitted by events, and is the value `decide()` and `get()` accept. Use it as your normal handle for everything UI- or API-related. + +```tsx +const { items } = editor.doc.trackChanges.list(); +editor.doc.trackChanges.decide({ + decision: 'accept', + target: { id: items[0].id }, +}); +``` + +### Source correlation (advanced) + +`wordRevisionIds` is provenance, not another id. It carries the original Word `w:id` values from the imported DOCX, keyed by `insert` / `delete` / `format`. Only reach for it when you need to correlate a SuperDoc change with the source file or an external review system. It is not present for tracked changes created in the current session. + +### Story scope + +When listing across stories (`list({ in: 'all' })`), the same id can appear in body and a footnote. Pair the id with `address.story` and pass `{ id, story }` to `decide`: + +```tsx +const all = editor.doc.trackChanges.list({ in: 'all' }); +const item = all.items[0]; + +editor.doc.trackChanges.decide({ + decision: 'accept', + target: item.address.story + ? { id: item.id, story: item.address.story } + : { id: item.id }, +}); +``` + ## Accept and reject ```tsx diff --git a/packages/document-api/src/types/track-changes.types.ts b/packages/document-api/src/types/track-changes.types.ts index ce4c380840..51b12eda3c 100644 --- a/packages/document-api/src/types/track-changes.types.ts +++ b/packages/document-api/src/types/track-changes.types.ts @@ -13,26 +13,30 @@ export const TRACK_CHANGES_IN_ALL = 'all' as const; export type TrackChangesInAll = typeof TRACK_CHANGES_IN_ALL; /** - * Raw imported Word OOXML revision IDs (`w:id`) from the source document when available. - * - * This is provenance metadata, not the canonical SuperDoc tracked-change ID. - * Replacements may include both `insert` and `delete` IDs. + * Source provenance: the original Word `w:id` values from the imported DOCX, + * keyed by `insert` / `delete` / `format`. Use to correlate a SuperDoc tracked + * change back to the source file or an external review system. Not present + * for tracked changes created in the current session. */ export interface TrackChangeWordRevisionIds { - /** Raw imported Word OOXML revision ID (`w:id`) from a `` element when present. */ + /** Original `w:id` from the source DOCX's `` element. */ insert?: string; - /** Raw imported Word OOXML revision ID (`w:id`) from a `` element when present. */ + /** Original `w:id` from the source DOCX's `` element. */ delete?: string; - /** Raw imported Word OOXML revision ID (`w:id`) from a `` element when present. */ + /** Original `w:id` from the source DOCX's `` element. */ format?: string; } export interface TrackChangeInfo { address: TrackedChangeAddress; - /** Convenience alias for `address.entityId`. */ + /** + * SuperDoc tracked-change id for the loaded document. Use this with `get()`, + * `decide()`, UI rows, and tracked-change events. For source DOCX correlation, + * use {@link TrackChangeInfo.wordRevisionIds}. + */ id: string; type: TrackChangeType; - /** Raw imported Word OOXML revision IDs (`w:id`) from the source document when available. */ + /** Source provenance: original Word `w:id` values from the imported DOCX. See {@link TrackChangeWordRevisionIds}. */ wordRevisionIds?: TrackChangeWordRevisionIds; author?: string; authorEmail?: string; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts index fd170d76a4..3a86626bc5 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.test.ts @@ -8,16 +8,23 @@ import { import { getTrackChanges } from '../../extensions/track-changes/trackChangesHelpers/getTrackChanges.js'; import { buildTrackedChangeCanonicalIdMap, + deriveTrackedChangeId, groupTrackedChanges, resolveTrackedChange, + resolveTrackedChangeInStory, resolveTrackedChangeType, toCanonicalTrackedChangeId, } from './tracked-change-resolver.js'; +import { resolveStoryRuntime } from '../story-runtime/resolve-story-runtime.js'; vi.mock('../../extensions/track-changes/trackChangesHelpers/getTrackChanges.js', () => ({ getTrackChanges: vi.fn(), })); +vi.mock('../story-runtime/resolve-story-runtime.js', () => ({ + resolveStoryRuntime: vi.fn(), +})); + function makeEditor(): Editor { return { state: { @@ -189,15 +196,16 @@ describe('toCanonicalTrackedChangeId', () => { vi.clearAllMocks(); }); - it('maps a raw id to its canonical derived id', () => { + it('returns the stable raw id as the canonical id (SD-3084)', () => { vi.mocked(getTrackChanges).mockReturnValue([ { ...makeTrackMark(TrackInsertMarkName, 'tc-1'), from: 1, to: 5 }, ] as never); const editor = makeEditor(); const canonical = toCanonicalTrackedChangeId(editor, 'tc-1'); - expect(typeof canonical).toBe('string'); - expect(canonical).not.toBe('tc-1'); + // Canonical id is the stable raw mark id, matching `comment.commentId` + // from `onCommentsUpdate` and the value passed to `trackChanges.decide`. + expect(canonical).toBe('tc-1'); }); it('returns null for unknown raw ids', () => { @@ -230,3 +238,125 @@ describe('buildTrackedChangeCanonicalIdMap', () => { expect(buildTrackedChangeCanonicalIdMap(makeEditor()).size).toBe(0); }); }); + +describe('stable id contract (SD-3084)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('keeps the same id when positions shift after an edit', () => { + // Same rawId, same author/date; only positions move. Under the old + // positional-hash contract the id changed across snapshots; under the + // stable contract it must remain equal to the rawId. + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'rev-7', { author: 'Ada' }), from: 1, to: 5 }, + ] as never); + + const editor = makeEditor(); + const before = groupTrackedChanges(editor)[0]?.id; + + // Simulate a position-shifting edit by swapping the doc reference and + // moving the mark's `from`/`to`. + (editor.state as { doc: unknown }).doc = { + ...editor.state.doc, + textBetween: vi.fn(() => 'excerpt'), + }; + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'rev-7', { author: 'Ada' }), from: 42, to: 47 }, + ] as never); + + const after = groupTrackedChanges(editor)[0]?.id; + + expect(before).toBe('rev-7'); + expect(after).toBe('rev-7'); + }); + + it('exposes id === rawId so consumers can correlate with onCommentsUpdate.commentId', () => { + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'rev-stable'), from: 1, to: 5 }, + ] as never); + + const grouped = groupTrackedChanges(makeEditor()); + + expect(grouped[0]?.id).toBe('rev-stable'); + expect(grouped[0]?.id).toBe(grouped[0]?.rawId); + }); + + it('resolves an old ephemeral derived id within the same snapshot (soft fallback)', () => { + // A consumer that cached the previously-published derived id from an + // earlier release should still be able to call into the resolver in the + // same snapshot. Compute the old hash, then verify the resolver returns + // the matching change when looked up by it. + vi.mocked(getTrackChanges).mockReturnValue([ + { ...makeTrackMark(TrackInsertMarkName, 'rev-9', { author: 'Ada', date: '2026-05-11' }), from: 3, to: 9 }, + ] as never); + + const editor = makeEditor(); + const grouped = groupTrackedChanges(editor); + const change = grouped[0]; + expect(change).toBeDefined(); + expect(change?.rawId).toBe('rev-9'); + + const legacyId = deriveTrackedChangeId(editor, change!); + expect(legacyId).not.toBe('rev-9'); + + // Primary path: stable raw id resolves. + expect(toCanonicalTrackedChangeId(editor, 'rev-9')).toBe('rev-9'); + // Compat fallback via resolveTrackedChange. + const resolvedByLegacy = resolveTrackedChange(editor, legacyId); + expect(resolvedByLegacy?.rawId).toBe('rev-9'); + // Compat fallback via toCanonicalTrackedChangeId — locks the broadened + // "any known form, returns canonical" semantics documented on the helper. + expect(toCanonicalTrackedChangeId(editor, legacyId)).toBe('rev-9'); + // Bogus ids still return null. + expect(toCanonicalTrackedChangeId(editor, 'not-a-real-id')).toBeNull(); + }); + + it('disambiguates same rawId across body and footnote via target.story (SD-3084)', () => { + // Body editor: a change with rawId='shared'. + const hostEditor = makeEditor(); + vi.mocked(getTrackChanges).mockImplementation((state: unknown) => { + // Distinguish by reference: each editor has its own state.doc reference. + if (state === hostEditor.state) { + return [{ ...makeTrackMark(TrackInsertMarkName, 'shared', { author: 'Body' }), from: 1, to: 5 }] as never; + } + // Footnote editor uses the runtime's state. + return [{ ...makeTrackMark(TrackInsertMarkName, 'shared', { author: 'Footnote' }), from: 7, to: 12 }] as never; + }); + + // Footnote runtime: a different editor with its own state, but the same rawId. + const footnoteEditor = { + state: { + doc: { + content: { size: 50 }, + textBetween: vi.fn(() => 'fn excerpt'), + }, + }, + } as unknown as Editor; + vi.mocked(resolveStoryRuntime).mockReturnValue({ + editor: footnoteEditor, + locator: { kind: 'story', storyType: 'footnote', noteId: '1' }, + storyKey: 'fn:1', + commit: vi.fn(), + } as never); + + // Body-scoped lookup (no story) finds the body change. + const body = resolveTrackedChangeInStory(hostEditor, { + kind: 'entity', + entityType: 'trackedChange', + entityId: 'shared', + }); + expect(body?.editor).toBe(hostEditor); + expect(body?.change.attrs.author).toBe('Body'); + + // Footnote-scoped lookup with the same id finds the footnote change. + const footnote = resolveTrackedChangeInStory(hostEditor, { + kind: 'entity', + entityType: 'trackedChange', + entityId: 'shared', + story: { kind: 'story', storyType: 'footnote', noteId: '1' }, + }); + expect(footnote?.editor).toBe(footnoteEditor); + expect(footnote?.change.attrs.author).toBe('Footnote'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts index f21bf2bdb2..0bc9b40ce3 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/tracked-change-resolver.ts @@ -82,15 +82,15 @@ function portableHash(input: string): string { } /** - * Derives a deterministic ID for a tracked change from the current document state. + * Derives a positional/content hash for a tracked change. Previously used as + * the canonical `id` on grouped changes; the canonical id is now + * `change.rawId` (the persistent mark id). Kept only as the fallback inside + * {@link findMatchingChange} for callers that cached the previously-published + * ephemeral id within the same snapshot. Not part of the public API. * - * The ID is computed from the change type, ProseMirror positions, author, - * date, and a text excerpt. It is stable for a given document state but will - * change if the document is edited, since positions shift. These are NOT - * persistent identifiers — they are ephemeral keys valid only for the - * current transaction snapshot. + * AIDEV-NOTE: temporary - remove when SD-3095 lands the fallback removal. */ -function deriveTrackedChangeId(editor: Editor, change: Omit): string { +export function deriveTrackedChangeId(editor: Editor, change: Omit): string { const type = resolveTrackedChangeType(change); const excerpt = normalizeExcerpt(editor.state.doc.textBetween(change.from, change.to, ' ', '\ufffc')) ?? ''; const author = toNonEmptyString(change.attrs.author) ?? ''; @@ -191,7 +191,9 @@ export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] { const grouped = Array.from(byRawId.values()) .map((change) => ({ ...change, - id: deriveTrackedChangeId(editor, change), + // Same value `comment.commentId` carries on `onCommentsUpdate`, and the + // value `trackChanges.decide` accepts. See SD-3084. + id: change.rawId, })) .sort((a, b) => { if (a.from !== b.from) return a.from - b.from; @@ -203,13 +205,17 @@ export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] { } export function resolveTrackedChange(editor: Editor, id: string): GroupedTrackedChange | null { - const grouped = groupTrackedChanges(editor); - return grouped.find((item) => item.id === id) ?? null; + return findMatchingChange(editor, id); } -export function toCanonicalTrackedChangeId(editor: Editor, rawId: string): string | null { - const grouped = groupTrackedChanges(editor); - return grouped.find((item) => item.rawId === rawId)?.id ?? null; +/** + * Resolves any known form of a tracked-change identifier to the canonical id. + * Accepts the stable id (equal to `rawId` after SD-3084) and, for one release, + * the previously-published ephemeral derived id; both return the canonical id. + * Returns `null` for unknown ids. + */ +export function toCanonicalTrackedChangeId(editor: Editor, id: string): string | null { + return findMatchingChange(editor, id)?.id ?? null; } export function buildTrackedChangeCanonicalIdMap(editor: Editor): Map { @@ -311,10 +317,18 @@ export function resolveTrackedChangeInStory( } /** - * Lookup helper — accepts both the canonical id and the raw mark id to - * tolerate callers that stored whichever was convenient at the time. + * Lookup helper that accepts the canonical id (equal to `rawId` after + * SD-3084) and falls back to the previously-published ephemeral derived id + * so cached old values still resolve within the same snapshot. The + * derived-id branch is consulted only when the cheaper equality check misses. + * + * AIDEV-NOTE: temporary - remove the derived-id branch when SD-3095 lands. */ function findMatchingChange(editor: Editor, id: string): GroupedTrackedChange | null { const grouped = groupTrackedChanges(editor); - return grouped.find((item) => item.id === id || item.rawId === id) ?? null; + return ( + grouped.find((item) => item.rawId === id) ?? + grouped.find((item) => deriveTrackedChangeId(editor, item) === id) ?? + null + ); } diff --git a/tests/behavior/tests/comments/sd-3084-stable-tracked-change-id.spec.ts b/tests/behavior/tests/comments/sd-3084-stable-tracked-change-id.spec.ts new file mode 100644 index 0000000000..369c75c2d8 --- /dev/null +++ b/tests/behavior/tests/comments/sd-3084-stable-tracked-change-id.spec.ts @@ -0,0 +1,238 @@ +import type { Page } from '@playwright/test'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { + acceptTrackChange, + assertDocumentApiReady, + findFirstSelectionTarget, + insertText, + listTrackChanges, + rejectTrackChange, + replaceText, +} from '../../helpers/document-api.js'; + +/** + * SD-3084 contract: trackChanges.list().items[i].id is stable within the + * loaded document and matches the commentId emitted by onCommentsUpdate. + * Decide() accepts it. + * + * Each test simulates the realistic consumer pattern that motivated the fix: + * list / subscribe -> cache id -> async work -> act on the cached id + * + * Before SD-3084, items[i].id was a positional/content hash that changed on + * every edit. The scenarios below would have produced TARGET_NOT_FOUND on + * decide() in that world. + */ + +test.use({ config: { toolbar: 'full', comments: 'panel', trackChanges: true } }); + +async function collectTrackedChangeEventIds(page: Page): Promise { + await page.evaluate(() => { + const w = window as unknown as { + __sd3084EventIds: Set; + superdoc: { + on?: (event: string, cb: (p: unknown) => void) => void; + config?: { onCommentsUpdate?: (p: unknown) => void }; + }; + }; + w.__sd3084EventIds = new Set(); + const record = (p: unknown) => { + const payload = p as { + type?: string; + changeId?: string; + comment?: { commentId?: string; trackedChange?: boolean }; + }; + if (payload?.comment?.trackedChange === true && payload.comment.commentId) { + w.__sd3084EventIds.add(payload.comment.commentId); + } + if (payload?.type === 'trackedChange' && payload.changeId) { + w.__sd3084EventIds.add(payload.changeId); + } + }; + if (w.superdoc?.on) { + w.superdoc.on('comments-update', record); + } else if (w.superdoc?.config) { + const prev = w.superdoc.config.onCommentsUpdate; + w.superdoc.config.onCommentsUpdate = (p: unknown) => { + record(p); + prev?.(p); + }; + } + }); +} + +async function readEventIds(page: Page): Promise { + return page.evaluate(() => { + const w = window as unknown as { __sd3084EventIds?: Set }; + return [...(w.__sd3084EventIds ?? [])]; + }); +} + +async function createTrackedInsertion(page: Page, value: string): Promise { + const receipt = await insertText(page, { value }, { changeMode: 'tracked' }); + if (!receipt.success) { + throw new Error(`insertText (tracked) failed: ${JSON.stringify(receipt.failure)}`); + } +} + +test.describe('SD-3084 stable tracked-change id contract', () => { + test('items[i].id matches the commentId emitted by onCommentsUpdate', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + await collectTrackedChangeEventIds(superdoc.page); + + await createTrackedInsertion(superdoc.page, 'alpha bravo charlie'); + await superdoc.waitForStable(); + + const listed = await listTrackChanges(superdoc.page); + expect(listed.changes.length).toBeGreaterThan(0); + const listIds = listed.changes.map((c: { id: string }) => c.id); + + const eventIds = await readEventIds(superdoc.page); + for (const listId of listIds) { + expect(eventIds, `event ids should include list id ${listId}`).toContain(listId); + } + }); + + test('id is stable when positions shift due to an insertion before the change', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + // Seed an anchor word, then make a tracked replacement on it. This gives + // us a tracked change with definite positions. Pre-SD-3084 the id was a + // hash that included `from`/`to`, so shifting positions would have + // produced a different id. + await superdoc.type('anchor middle tail'); + await superdoc.waitForStable(); + + const anchorTarget = await findFirstSelectionTarget(superdoc.page, 'anchor'); + expect(anchorTarget, 'should find "anchor"').not.toBeNull(); + const trackedReceipt = await replaceText( + superdoc.page, + { target: anchorTarget!, text: 'ANCHOR' }, + { changeMode: 'tracked' }, + ); + expect(trackedReceipt.success).toBe(true); + await superdoc.waitForStable(); + + const before = (await listTrackChanges(superdoc.page)).changes; + expect(before.length).toBeGreaterThan(0); + const targetId = before[0].id; + const beforeRange = before[0].address?.range as { start?: number; end?: number } | undefined; + + // Now shift the tracked change's positions by inserting non-tracked text + // BEFORE it. The change's `from`/`to` must move, which would have changed + // the pre-SD-3084 hashed id. + const tailTarget = await findFirstSelectionTarget(superdoc.page, 'tail'); + expect(tailTarget, 'should find "tail"').not.toBeNull(); + // Replace "tail" with a longer string positioned before "ANCHOR"? "tail" + // is after the anchor, so we need a target before the anchor. Use the + // raw insert helper at position 0 of the body via an explicit target. + await superdoc.page.keyboard.press('Home'); + await superdoc.page.keyboard.press('Home'); + // Insert non-tracked text at the start to shift everything that follows. + await insertText(superdoc.page, { value: 'PREFIX_THAT_SHIFTS_POSITIONS ' }); + await superdoc.waitForStable(); + + const after = (await listTrackChanges(superdoc.page)).changes; + const survivor = after.find((c: { id: string }) => c.id === targetId); + expect( + survivor, + `tracked change ${targetId} should still resolve by the same id after positions shift`, + ).toBeDefined(); + + const afterRange = survivor?.address?.range as { start?: number; end?: number } | undefined; + if (beforeRange && afterRange && beforeRange.start != null && afterRange.start != null) { + expect( + afterRange.start, + 'sanity: positions actually moved (proving this is a real position-shift test)', + ).toBeGreaterThan(beforeRange.start); + } + }); + + test('decide() accepts a cached id after async work and intervening edits', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await createTrackedInsertion(superdoc.page, 'cache this revision'); + await superdoc.waitForStable(); + + const listed = await listTrackChanges(superdoc.page); + const cached = listed.changes[0]; + expect(cached).toBeDefined(); + + // Cache id, simulate async work, edit happens, then act on the cached id. + await superdoc.page.waitForTimeout(200); + await createTrackedInsertion(superdoc.page, ' intervening edit '); + await superdoc.waitForStable(); + + await rejectTrackChange(superdoc.page, { id: cached.id, story: cached.address?.story }); + + const after = await listTrackChanges(superdoc.page); + expect( + after.changes.find((c: { id: string }) => c.id === cached.id), + 'rejected change should leave the live list', + ).toBeUndefined(); + }); + + test('id of a surviving change is unchanged when another change is accepted', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + // Seed two distinct anchor texts via non-tracked typing so we can target + // each independently, then issue two tracked replacements at separate + // positions. Consecutive tracked inserts at the same caret merge into a + // single logical change, so we need positional separation to get two ids. + await superdoc.type('alpha middle bravo'); + await superdoc.waitForStable(); + + const alphaTarget = await findFirstSelectionTarget(superdoc.page, 'alpha'); + expect(alphaTarget, 'should find "alpha"').not.toBeNull(); + const alphaReceipt = await replaceText( + superdoc.page, + { target: alphaTarget!, text: 'ALPHA' }, + { changeMode: 'tracked' }, + ); + expect(alphaReceipt.success).toBe(true); + + const bravoTarget = await findFirstSelectionTarget(superdoc.page, 'bravo'); + expect(bravoTarget, 'should find "bravo"').not.toBeNull(); + const bravoReceipt = await replaceText( + superdoc.page, + { target: bravoTarget!, text: 'BRAVO' }, + { changeMode: 'tracked' }, + ); + expect(bravoReceipt.success).toBe(true); + await superdoc.waitForStable(); + + const initial = await listTrackChanges(superdoc.page); + expect(initial.changes.length).toBeGreaterThanOrEqual(2); + + const [first, second] = initial.changes; + await acceptTrackChange(superdoc.page, { id: first.id, story: first.address?.story }); + + const after = await listTrackChanges(superdoc.page); + const survivor = after.changes.find((c: { id: string }) => c.id === second.id); + expect(survivor, `surviving change ${second.id} should still resolve by its cached id`).toBeDefined(); + }); + + test('handle.ref is exposed but never required to call decide()', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await createTrackedInsertion(superdoc.page, 'opaque handle test'); + await superdoc.waitForStable(); + + const listed = await listTrackChanges(superdoc.page); + const item = listed.changes[0] as { + id: string; + handle?: { ref?: string; refStability?: string }; + address?: { story?: unknown }; + }; + expect(item).toBeDefined(); + + if (item.handle) { + expect(item.handle.refStability).toBe('stable'); + } + + // Decide using the public id only. + await rejectTrackChange(superdoc.page, { id: item.id, story: item.address?.story }); + + const after = await listTrackChanges(superdoc.page); + expect(after.changes.find((c: { id: string }) => c.id === item.id)).toBeUndefined(); + }); +});