Skip to content
8 changes: 7 additions & 1 deletion apps/docs/editor/built-in-ui/track-changes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ Every entry returned by `list()` and `get()` has this shape:
<ResponseField name="TrackChangeInfo" type="Object">
<Expandable title="Fields" defaultOpen>
<ResponseField name="id" type="string">
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.
</ResponseField>
<ResponseField name="address" type="Object">
Entity address: `{ kind: 'entity', entityType: 'trackedChange', entityId: id }`.
Expand Down Expand Up @@ -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:
Expand Down
42 changes: 42 additions & 0 deletions apps/docs/editor/custom-ui/track-changes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 13 additions & 9 deletions packages/document-api/src/types/track-changes.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<w:ins>` element when present. */
/** Original `w:id` from the source DOCX's `<w:ins>` element. */
insert?: string;
/** Raw imported Word OOXML revision ID (`w:id`) from a `<w:del>` element when present. */
/** Original `w:id` from the source DOCX's `<w:del>` element. */
delete?: string;
/** Raw imported Word OOXML revision ID (`w:id`) from a `<w:rPrChange>` element when present. */
/** Original `w:id` from the source DOCX's `<w:rPrChange>` 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<GroupedTrackedChange, 'id'>): string {
export function deriveTrackedChangeId(editor: Editor, change: Omit<GroupedTrackedChange, 'id'>): string {
const type = resolveTrackedChangeType(change);
const excerpt = normalizeExcerpt(editor.state.doc.textBetween(change.from, change.to, ' ', '\ufffc')) ?? '';
const author = toNonEmptyString(change.attrs.author) ?? '';
Expand Down Expand Up @@ -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;
Expand All @@ -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<string, string> {
Expand Down Expand Up @@ -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
);
}
Loading
Loading