From bb27ecdfd8f0e0a7e20690da45b222300147b1f1 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 11 May 2026 12:03:03 -0300 Subject: [PATCH 1/7] fix(track-changes): expose stable id on trackChanges.list (SD-3084) The id on TrackChangeInfo was a positional/content hash recomputed every time the document changed. Callers who stored it from list() and passed it to decide() after an edit hit TARGET_NOT_FOUND. The stable raw mark id is now the canonical id, so id matches comment.commentId from onCommentsUpdate and survives edits. Keep the old derived id resolvable as a one-release compat fallback inside findMatchingChange, marked AIDEV-NOTE: temporary. --- .../src/types/track-changes.types.ts | 12 +- .../helpers/tracked-change-resolver.test.ts | 133 +++++++++++++++++- .../helpers/tracked-change-resolver.ts | 41 ++++-- 3 files changed, 167 insertions(+), 19 deletions(-) diff --git a/packages/document-api/src/types/track-changes.types.ts b/packages/document-api/src/types/track-changes.types.ts index ce4c380840..2faf36d6af 100644 --- a/packages/document-api/src/types/track-changes.types.ts +++ b/packages/document-api/src/types/track-changes.types.ts @@ -29,7 +29,17 @@ export interface TrackChangeWordRevisionIds { export interface TrackChangeInfo { address: TrackedChangeAddress; - /** Convenience alias for `address.entityId`. */ + /** + * Stable identifier for the tracked change. Safe to store and use across + * document edits. Equal to `address.entityId` and to the `commentId` + * emitted by `onCommentsUpdate` for this change. + * + * Note on story scope: the id is story-local. Two changes in different + * stories (body, header, footer, footnote, endnote) may share the same + * id. When listing across stories (`list({ in: 'all' })`), use + * `address.story` together with `id` for full identity, and pass + * `{ id, story }` as the `decide` target to disambiguate. + */ id: string; type: TrackChangeType; /** Raw imported Word OOXML revision IDs (`w:id`) from the source document when available. */ 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..3b279ecf9b 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,122 @@ 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: legacy derived id resolves to the same change. + const resolvedByLegacy = resolveTrackedChange(editor, legacyId); + expect(resolvedByLegacy?.rawId).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..9a07289700 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,16 @@ 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 - SD-3084 soft-fallback. Remove one release after + * SD-3084 ships and consumers have migrated to the stable id. */ -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 +192,10 @@ export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] { const grouped = Array.from(byRawId.values()) .map((change) => ({ ...change, - id: deriveTrackedChangeId(editor, change), + // Canonical id is the persistent mark rawId. This is the 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 +207,11 @@ 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; + return findMatchingChange(editor, rawId)?.id ?? null; } export function buildTrackedChangeCanonicalIdMap(editor: Editor): Map { @@ -311,10 +313,19 @@ 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 checks miss. + * + * AIDEV-NOTE: temporary - SD-3084 soft-fallback. Remove the third branch one + * release after SD-3084 ships and consumers have migrated. */ 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.id === id || item.rawId === id) ?? + grouped.find((item) => deriveTrackedChangeId(editor, item) === id) ?? + null + ); } From 33ea959ddd729983bd4089e221d74f83e5f2a0ae Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 11 May 2026 13:51:43 -0300 Subject: [PATCH 2/7] refactor(track-changes): tighten resolver comments and predicates (SD-3084) Address self-review nits: - Drop paraphrase opener on the `id: change.rawId` site; the rest of the comment carries the load-bearing context. - Document `toCanonicalTrackedChangeId`'s broadened "any known form, returns canonical" contract and rename the param from `rawId` to `id` to match. - Simplify the lookup predicate to `item.rawId === id`; the previous `item.id === id || item.rawId === id` was redundant because `id` and `rawId` are equal by construction. The existing "id === rawId" invariant test guards the simplification. - Sharpen the AIDEV-NOTE removal trigger to reference SD-3095. - Lock the broadened `toCanonicalTrackedChangeId` semantics with an extra assertion in the soft-fallback test. --- .../helpers/tracked-change-resolver.test.ts | 5 +++- .../helpers/tracked-change-resolver.ts | 25 +++++++++++-------- 2 files changed, 18 insertions(+), 12 deletions(-) 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 3b279ecf9b..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 @@ -302,9 +302,12 @@ describe('stable id contract (SD-3084)', () => { // Primary path: stable raw id resolves. expect(toCanonicalTrackedChangeId(editor, 'rev-9')).toBe('rev-9'); - // Compat fallback: legacy derived id resolves to the same change. + // 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(); }); 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 9a07289700..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 @@ -88,8 +88,7 @@ function portableHash(input: string): string { * {@link findMatchingChange} for callers that cached the previously-published * ephemeral id within the same snapshot. Not part of the public API. * - * AIDEV-NOTE: temporary - SD-3084 soft-fallback. Remove one release after - * SD-3084 ships and consumers have migrated to the stable id. + * AIDEV-NOTE: temporary - remove when SD-3095 lands the fallback removal. */ export function deriveTrackedChangeId(editor: Editor, change: Omit): string { const type = resolveTrackedChangeType(change); @@ -192,9 +191,8 @@ export function groupTrackedChanges(editor: Editor): GroupedTrackedChange[] { const grouped = Array.from(byRawId.values()) .map((change) => ({ ...change, - // Canonical id is the persistent mark rawId. This is the same value - // `comment.commentId` carries on `onCommentsUpdate`, and the value - // `trackChanges.decide` accepts. See SD-3084. + // Same value `comment.commentId` carries on `onCommentsUpdate`, and the + // value `trackChanges.decide` accepts. See SD-3084. id: change.rawId, })) .sort((a, b) => { @@ -210,8 +208,14 @@ export function resolveTrackedChange(editor: Editor, id: string): GroupedTracked return findMatchingChange(editor, id); } -export function toCanonicalTrackedChangeId(editor: Editor, rawId: string): string | null { - return findMatchingChange(editor, 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 { @@ -316,15 +320,14 @@ export function resolveTrackedChangeInStory( * 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 checks miss. + * derived-id branch is consulted only when the cheaper equality check misses. * - * AIDEV-NOTE: temporary - SD-3084 soft-fallback. Remove the third branch one - * release after SD-3084 ships and consumers have migrated. + * 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) ?? + grouped.find((item) => item.rawId === id) ?? grouped.find((item) => deriveTrackedChangeId(editor, item) === id) ?? null ); From 0fdc567ec4ac4e1e4dbda91610537d4593cb2d8c Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 11 May 2026 14:06:40 -0300 Subject: [PATCH 3/7] docs(track-changes): document the stable id contract (SD-3084) Add a "Tracked-change ids" section to both the built-in UI and Custom UI track-changes pages. Names the contract surfaced by SD-3084: - `items[i].id` (and `address.entityId`) survives across edits. - Same value as `payload.changeId` / `payload.comment.commentId` on `onCommentsUpdate`. - Same value `decide` and `get` accept as `target.id`. Story scope guidance: same id can appear in body and a footnote; pair with `address.story` when listing across stories. --- .../docs/editor/built-in-ui/track-changes.mdx | 24 +++++++++++++++++++ apps/docs/editor/custom-ui/track-changes.mdx | 24 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/apps/docs/editor/built-in-ui/track-changes.mdx b/apps/docs/editor/built-in-ui/track-changes.mdx index b9fe67687d..5f1b53e44b 100644 --- a/apps/docs/editor/built-in-ui/track-changes.mdx +++ b/apps/docs/editor/built-in-ui/track-changes.mdx @@ -194,6 +194,30 @@ 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 + +Every revision carries an `id` that survives across edits. The same id appears on every track-changes surface, so events, list entries, and decisions all refer to the same value: + +- `items[i].id` from `editor.doc.trackChanges.list()` (also `address.entityId`) +- `payload.changeId` (or `payload.comment.commentId`) on `onCommentsUpdate` +- `target.id` accepted by `decide` and `get` + +Store the id when a change first appears. Edit around it, accept or reject other changes, hit undo. Pass the original id back into `decide` and it still resolves. + +The id is local to its story. The same id can appear in body and in a footnote. When listing across stories with `list({ in: 'all' })`, pair the id with `address.story`: + +```javascript +const all = superdoc.activeEditor.doc.trackChanges.list({ in: "all" }); +const item = all.items[0]; + +superdoc.activeEditor.doc.trackChanges.decide({ + decision: "accept", + target: item.address.story + ? { id: item.id, story: item.address.story } + : { id: item.id }, +}); +``` + ## 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..827f106579 100644 --- a/apps/docs/editor/custom-ui/track-changes.mdx +++ b/apps/docs/editor/custom-ui/track-changes.mdx @@ -33,6 +33,30 @@ export function ReviewPanel() { `items` mirrors `editor.doc.trackChanges.list()`. Each item carries `id` plus the full `change` record (type, author, excerpt, address). +## Tracked-change ids + +Every item from `useSuperDocTrackChanges()` carries an `id` that survives across edits. The same id appears on every track-changes surface: + +- `editor.doc.trackChanges.list().items[i].id` and `address.entityId` +- `payload.changeId` (or `payload.comment.commentId`) on `onCommentsUpdate` +- `target.id` accepted by `decide` and `get` + +Store the id when a change first appears. Type around it, accept or reject other changes, hit undo. Pass the original id back into `decide` and it still resolves. + +The id is local to its story. The same id can appear in body and in a footnote. When listing across stories with `list({ in: 'all' })`, pair the id with `address.story`: + +```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 From 4727ce24bfab5fd071660c51210930451ca5bd9c Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 11 May 2026 16:15:26 -0300 Subject: [PATCH 4/7] test(track-changes): e2e contract test for stable id (SD-3084) Five Playwright scenarios that exercise the SD-3084 contract through the public Document API in a real browser. They simulate the consumer pattern that motivated the fix: list -> cache id -> async work / intervening edits -> decide() with the cached id. - items[i].id matches the commentId emitted by onCommentsUpdate - id is stable across a position-shifting edit - decide() accepts a cached id after a wait and an intervening edit - accepting one change does not invalidate the ids of other changes - handle.ref is exposed but the public id alone is sufficient for decide Passes 15/15 across Chromium, Firefox, and WebKit. --- .../sd-3084-stable-tracked-change-id.spec.ts | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 tests/behavior/tests/comments/sd-3084-stable-tracked-change-id.spec.ts 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..e75c394505 --- /dev/null +++ b/tests/behavior/tests/comments/sd-3084-stable-tracked-change-id.spec.ts @@ -0,0 +1,202 @@ +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 across a position-shifting edit', async ({ superdoc }) => { + await assertDocumentApiReady(superdoc.page); + + await createTrackedInsertion(superdoc.page, 'first tracked insertion'); + await superdoc.waitForStable(); + + const before = (await listTrackChanges(superdoc.page)).changes.map((c: { id: string }) => c.id).sort(); + expect(before.length).toBeGreaterThan(0); + + await createTrackedInsertion(superdoc.page, ' more text after'); + await superdoc.waitForStable(); + + const after = (await listTrackChanges(superdoc.page)).changes.map((c: { id: string }) => c.id).sort(); + + for (const id of before) { + expect(after, `id ${id} should survive a position-shifting edit`).toContain(id); + } + }); + + 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(); + }); +}); From f4660a4e9a8905e11c7cb43ae8bb82e9c1d4bf68 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 11 May 2026 17:08:55 -0300 Subject: [PATCH 5/7] docs(track-changes): scope id to loaded document, point at wordRevisionIds (SD-3084) Three related corrections from review feedback: - Built-in UI and Custom UI docs: "survives across edits" was overbroad. Replace with "stable across edits for the loaded document instance" and add a Source-correlation subsection naming `wordRevisionIds.insert / .delete / .format` as the bridge for source-correlated workflows. - `TrackChangeInfo.id` JSDoc: scope identically and call out that the SuperDoc id is not the OOXML `w:id`. - E2e position-shift test: previous setup inserted AFTER the existing change, so positions didn't move. Pre-SD-3084 hash (type|from|to|...) would have passed too. Rewritten to seed an anchor, make a tracked replacement there, then insert non-tracked text BEFORE it so the change's range actually moves. Added a sanity assertion proving the positions shifted. 15/15 pass on Chromium + Firefox + WebKit. --- .../docs/editor/built-in-ui/track-changes.mdx | 6 ++- apps/docs/editor/custom-ui/track-changes.mdx | 6 ++- .../src/types/track-changes.types.ts | 22 +++++--- .../sd-3084-stable-tracked-change-id.spec.ts | 54 +++++++++++++++---- 4 files changed, 69 insertions(+), 19 deletions(-) diff --git a/apps/docs/editor/built-in-ui/track-changes.mdx b/apps/docs/editor/built-in-ui/track-changes.mdx index 5f1b53e44b..f180ba45f7 100644 --- a/apps/docs/editor/built-in-ui/track-changes.mdx +++ b/apps/docs/editor/built-in-ui/track-changes.mdx @@ -196,7 +196,7 @@ Every entry returned by `list()` and `get()` has this shape: ## Tracked-change ids -Every revision carries an `id` that survives across edits. The same id appears on every track-changes surface, so events, list entries, and decisions all refer to the same value: +Every revision carries a SuperDoc tracked-change `id`. It's stable across edits for the loaded document instance, matches the id `onCommentsUpdate` emits for the same change, and is what `decide` and `get` accept. The same id appears on every track-changes surface: - `items[i].id` from `editor.doc.trackChanges.list()` (also `address.entityId`) - `payload.changeId` (or `payload.comment.commentId`) on `onCommentsUpdate` @@ -218,6 +218,10 @@ superdoc.activeEditor.doc.trackChanges.decide({ }); ``` +### Source-correlation: not the OOXML `w:id` + +The SuperDoc id is not the `w:id` attribute from the source DOCX. Opening the same file in a fresh editor produces fresh SuperDoc ids. If you need to correlate with the source document or an external review system, read `wordRevisionIds.insert` / `.delete` / `.format` on each `TrackChangeInfo`. Those carry the original OOXML `w:id` values when present. + ## 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 827f106579..d51de8442d 100644 --- a/apps/docs/editor/custom-ui/track-changes.mdx +++ b/apps/docs/editor/custom-ui/track-changes.mdx @@ -35,7 +35,7 @@ export function ReviewPanel() { ## Tracked-change ids -Every item from `useSuperDocTrackChanges()` carries an `id` that survives across edits. The same id appears on every track-changes surface: +Every item from `useSuperDocTrackChanges()` carries a SuperDoc tracked-change `id`. It's stable across edits for the loaded document instance, matches the id `onCommentsUpdate` emits for the same change, and is what `decide` and `get` accept. The same id appears on every track-changes surface: - `editor.doc.trackChanges.list().items[i].id` and `address.entityId` - `payload.changeId` (or `payload.comment.commentId`) on `onCommentsUpdate` @@ -57,6 +57,10 @@ editor.doc.trackChanges.decide({ }); ``` +### Source-correlation: not the OOXML `w:id` + +The SuperDoc id is not the `w:id` attribute from the source DOCX. Opening the same file in a fresh editor produces fresh SuperDoc ids. If you need to correlate with the source document or an external review system, read `wordRevisionIds.insert` / `.delete` / `.format` on each `TrackChangeInfo`. Those carry the original OOXML `w:id` values when present. + ## 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 2faf36d6af..a84adc4aa3 100644 --- a/packages/document-api/src/types/track-changes.types.ts +++ b/packages/document-api/src/types/track-changes.types.ts @@ -30,15 +30,21 @@ export interface TrackChangeWordRevisionIds { export interface TrackChangeInfo { address: TrackedChangeAddress; /** - * Stable identifier for the tracked change. Safe to store and use across - * document edits. Equal to `address.entityId` and to the `commentId` - * emitted by `onCommentsUpdate` for this change. + * SuperDoc tracked-change identifier. Stable across edits while the document + * is loaded, matches the `commentId` emitted by `onCommentsUpdate` for this + * change, and is what `get` and `decide` accept as `target.id`. Equal to + * `address.entityId`. * - * Note on story scope: the id is story-local. Two changes in different - * stories (body, header, footer, footnote, endnote) may share the same - * id. When listing across stories (`list({ in: 'all' })`), use - * `address.story` together with `id` for full identity, and pass - * `{ id, story }` as the `decide` target to disambiguate. + * This is NOT the OOXML `w:id` from the source DOCX. Opening the same file + * in a fresh editor produces fresh SuperDoc ids. For source correlation + * (mapping back to the original DOCX or an external review system), read + * {@link TrackChangeInfo.wordRevisionIds} instead. + * + * Story scope: the id is story-local. Two changes in different stories + * (body, header, footer, footnote, endnote) may share the same id. When + * listing across stories (`list({ in: 'all' })`), pair the id with + * `address.story` and pass `{ id, story }` as the `decide` target to + * disambiguate. */ id: string; type: TrackChangeType; 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 index e75c394505..369c75c2d8 100644 --- 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 @@ -92,22 +92,58 @@ test.describe('SD-3084 stable tracked-change id contract', () => { } }); - test('id is stable across a position-shifting edit', async ({ superdoc }) => { + test('id is stable when positions shift due to an insertion before the change', async ({ superdoc }) => { await assertDocumentApiReady(superdoc.page); - await createTrackedInsertion(superdoc.page, 'first tracked insertion'); + // 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 before = (await listTrackChanges(superdoc.page)).changes.map((c: { id: string }) => c.id).sort(); - expect(before.length).toBeGreaterThan(0); - - await createTrackedInsertion(superdoc.page, ' more text after'); + 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 after = (await listTrackChanges(superdoc.page)).changes.map((c: { id: string }) => c.id).sort(); + 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(); - for (const id of before) { - expect(after, `id ${id} should survive a position-shifting edit`).toContain(id); + 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); } }); From d1abc7191d8e482d82170ec55eecc54dcd3901df Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 11 May 2026 17:28:16 -0300 Subject: [PATCH 6/7] docs(track-changes): tighten built-in UI id section, link to Custom UI (SD-3084) The built-in UI page is reference, not tutorial. Collapse the "Tracked-change ids" section to a compact contract note: id is SuperDoc's tracked-change id for the loaded document instance, matches address.entityId / payload.changeId / payload.comment.commentId, accepted by get() and decide(). For source correlation, use wordRevisionIds. For story-scoped ids and cached-row patterns, see the Custom UI page. Also tighten the TrackChangeInfo.id field description in the response table to match. --- .../docs/editor/built-in-ui/track-changes.mdx | 28 ++----------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/apps/docs/editor/built-in-ui/track-changes.mdx b/apps/docs/editor/built-in-ui/track-changes.mdx index f180ba45f7..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 }`. @@ -196,31 +196,9 @@ Every entry returned by `list()` and `get()` has this shape: ## Tracked-change ids -Every revision carries a SuperDoc tracked-change `id`. It's stable across edits for the loaded document instance, matches the id `onCommentsUpdate` emits for the same change, and is what `decide` and `get` accept. The same id appears on every track-changes surface: +`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()`. -- `items[i].id` from `editor.doc.trackChanges.list()` (also `address.entityId`) -- `payload.changeId` (or `payload.comment.commentId`) on `onCommentsUpdate` -- `target.id` accepted by `decide` and `get` - -Store the id when a change first appears. Edit around it, accept or reject other changes, hit undo. Pass the original id back into `decide` and it still resolves. - -The id is local to its story. The same id can appear in body and in a footnote. When listing across stories with `list({ in: 'all' })`, pair the id with `address.story`: - -```javascript -const all = superdoc.activeEditor.doc.trackChanges.list({ in: "all" }); -const item = all.items[0]; - -superdoc.activeEditor.doc.trackChanges.decide({ - decision: "accept", - target: item.address.story - ? { id: item.id, story: item.address.story } - : { id: item.id }, -}); -``` - -### Source-correlation: not the OOXML `w:id` - -The SuperDoc id is not the `w:id` attribute from the source DOCX. Opening the same file in a fresh editor produces fresh SuperDoc ids. If you need to correlate with the source document or an external review system, read `wordRevisionIds.insert` / `.delete` / `.format` on each `TrackChangeInfo`. Those carry the original OOXML `w:id` values when present. +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 From bac80c442e7acbb76061231c14c71a4a80f3ed01 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 11 May 2026 17:39:15 -0300 Subject: [PATCH 7/7] docs(track-changes): lead with id, reframe wordRevisionIds as provenance (SD-3084) Make the runtime id feel like one obvious handle and treat OOXML provenance as advanced metadata. - Replace the prose section on the Custom UI page with a "Which id do I use?" decision table and a 4-line `decide()` example using only `item.id`. - Move story-scope and source-correlation guidance into clearly-labeled subsections so the simple case is uncluttered. - Shorten `TrackChangeInfo.id` JSDoc to a 3-line summary that points at `wordRevisionIds` for source correlation. - Reframe `TrackChangeWordRevisionIds` and its fields as source provenance from the imported DOCX, not "an alternate id." --- apps/docs/editor/custom-ui/track-changes.mdx | 36 +++++++++++++------ .../src/types/track-changes.types.ts | 34 ++++++------------ 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/apps/docs/editor/custom-ui/track-changes.mdx b/apps/docs/editor/custom-ui/track-changes.mdx index d51de8442d..77954649af 100644 --- a/apps/docs/editor/custom-ui/track-changes.mdx +++ b/apps/docs/editor/custom-ui/track-changes.mdx @@ -33,17 +33,35 @@ export function ReviewPanel() { `items` mirrors `editor.doc.trackChanges.list()`. Each item carries `id` plus the full `change` record (type, author, excerpt, address). -## Tracked-change ids +## Which id do I use? -Every item from `useSuperDocTrackChanges()` carries a SuperDoc tracked-change `id`. It's stable across edits for the loaded document instance, matches the id `onCommentsUpdate` emits for the same change, and is what `decide` and `get` accept. The same id appears on every track-changes surface: +For nearly every workflow, `item.id` is the answer. -- `editor.doc.trackChanges.list().items[i].id` and `address.entityId` -- `payload.changeId` (or `payload.comment.commentId`) on `onCommentsUpdate` -- `target.id` accepted by `decide` and `get` +| 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 | -Store the id when a change first appears. Type around it, accept or reject other changes, hit undo. Pass the original id back into `decide` and it still resolves. +`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. -The id is local to its story. The same id can appear in body and in a footnote. When listing across stories with `list({ in: 'all' })`, pair the id with `address.story`: +```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' }); @@ -57,10 +75,6 @@ editor.doc.trackChanges.decide({ }); ``` -### Source-correlation: not the OOXML `w:id` - -The SuperDoc id is not the `w:id` attribute from the source DOCX. Opening the same file in a fresh editor produces fresh SuperDoc ids. If you need to correlate with the source document or an external review system, read `wordRevisionIds.insert` / `.delete` / `.format` on each `TrackChangeInfo`. Those carry the original OOXML `w:id` values when present. - ## 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 a84adc4aa3..51b12eda3c 100644 --- a/packages/document-api/src/types/track-changes.types.ts +++ b/packages/document-api/src/types/track-changes.types.ts @@ -13,42 +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; /** - * SuperDoc tracked-change identifier. Stable across edits while the document - * is loaded, matches the `commentId` emitted by `onCommentsUpdate` for this - * change, and is what `get` and `decide` accept as `target.id`. Equal to - * `address.entityId`. - * - * This is NOT the OOXML `w:id` from the source DOCX. Opening the same file - * in a fresh editor produces fresh SuperDoc ids. For source correlation - * (mapping back to the original DOCX or an external review system), read - * {@link TrackChangeInfo.wordRevisionIds} instead. - * - * Story scope: the id is story-local. Two changes in different stories - * (body, header, footer, footnote, endnote) may share the same id. When - * listing across stories (`list({ in: 'all' })`), pair the id with - * `address.story` and pass `{ id, story }` as the `decide` target to - * disambiguate. + * 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;