;
/**
* Call from `onUpdate` BEFORE serializing. Returns true when the update must
* be ignored: editor not editable, mid-programmatic-setContent, or (in collab
* mode) a remote-origin transaction. Also records the local typing time.
*/
shouldIgnoreUpdate: (transaction: Transaction) => boolean;
/**
* Call from `onUpdate` AFTER computing the markdown to emit. Returns false
* when the value must NOT be persisted yet (an empty collab doc before the
* seed has run); records it as the last-emitted value otherwise.
*/
registerEmitted: (markdown: string) => boolean;
}
/**
* The subtle seed / reconcile / lead-client logic for the shared markdown
* editor, extracted once so it can never be duplicated across embedders.
*
* Responsibilities (reproducing the Plan editor's behavior exactly):
* - Track whether THIS client is the reconcile lead (sole client always leads;
* otherwise elected via {@link isReconcileLeadClient}) and how many other
* visible human peers are present.
* - Seed an empty shared Y.Doc once from `value` — lead client only — so two
* clients opening a brand-new block don't both insert the content.
* - Reconcile authoritative external markdown (agent edit, source patch, peer
* edit mirrored to SQL) into the editor: in collab mode only the lead client
* applies it through `setContent` and Yjs propagates; in non-collab mode this
* is the original controlled-value reconcile.
* - Provide the `onUpdate` guards (`shouldIgnoreUpdate`, `registerEmitted`) so
* the component never persists remote-origin or pre-seed empty content.
*/
/** Default seed predicate: seed only when the shared doc is genuinely empty. */
function defaultShouldSeed({
currentMarkdown,
fragmentLength,
}: {
value: string;
currentMarkdown: string;
fragmentLength: number;
}): boolean {
return fragmentLength === 0 || !currentMarkdown.trim();
}
/**
* Default content writer: hand the raw markdown string to `setContent`, which
* tiptap-markdown overrides to parse the markdown into a ProseMirror doc.
*
* IMPORTANT: do NOT pass `parseOptions: { preserveWhitespace: "full" }` here.
* In tiptap v3 the core `setContent` command routes `preserveWhitespace: "full"`
* through `insertContentAt`, which tiptap-markdown ALSO overrides to re-run its
* markdown parser. That double-parse stringifies the already-parsed PM doc and
* re-parses it as HTML, so a clean heading/list/code block comes back as the
* escaped, non-idempotent `<h1>…` — which then escalates every reconcile
* cycle (`` → `<p>` → `<p>` …). Letting the markdown
* override parse the string directly (no `parseOptions`) round-trips byte-stably
* for the GFM corpus, including code-block and empty-line whitespace. Content's
* NFM path supplies its own `setContent` (it passes a pre-parsed PM doc) and is
* unaffected by this default.
*/
function defaultSetContent(
editor: Editor,
value: string,
options: { emitUpdate?: boolean; addToHistory?: boolean },
): void {
if (options.addToHistory === false) {
editor
.chain()
.command(({ tr }) => {
// addToHistory:false so cmd+z (or Yjs undo) doesn't erase
// externally-loaded content.
tr.setMeta("addToHistory", false);
return true;
})
.setContent(value, { emitUpdate: options.emitUpdate })
.run();
return;
}
editor.commands.setContent(value);
}
export function useCollabReconcile({
editor,
ydoc = null,
collabSynced = true,
awareness = null,
value,
contentUpdatedAt,
editable,
getMarkdown = getEditorMarkdown,
setContent = defaultSetContent,
parseValue,
normalizeValue = (v) => v,
shouldSeed = defaultShouldSeed,
initialAppliedUpdatedAt,
}: UseCollabReconcileOptions): UseCollabReconcileResult {
const collab = !!ydoc;
const isSettingContentRef = useRef(false);
const lastEmittedRef = useRef("");
// Ring of recent local emissions (see pushEmittedRing). Lets the reconcile
// recognize a stale-but-recent echo of our OWN (possibly partial, debounced)
// save so a lagging poll never clobbers freshly-typed text.
const recentEmittedRef = useRef([]);
const lastTypedAtRef = useRef(0);
// The raw authoritative `value` string the reconcile last applied. When the
// SAME raw string is re-fetched (a lagging poll, or a source-sync that keeps
// re-supplying the same stored markdown), applying it again would only
// reproduce the doc we already hold — and if `value` is NON-idempotent
// (serialize(parse(value)) !== value) re-applying compounds the divergence
// every cycle (`` → `<p>` → `<p>` …). Tracked so the
// identical re-fetch is recognized and skipped.
const lastAppliedValueRef = useRef(null);
// The editor's SERIALIZED output captured right AFTER the last reconcile/seed
// apply (`getMarkdown(editor)` once the content settled). For non-idempotent
// input this is what autosave actually persists, so the NEXT poll hands it
// back as the new `value`. Comparing the incoming value against this lets the
// reconcile recognize its own echo even when the raw string changed once, so
// it never re-parses content the editor already represents. This is the
// doc-equivalence guard that breaks the escalation loop.
const lastAppliedSerializedRef = useRef(null);
const lastAppliedUpdatedAtRef = useRef(
initialAppliedUpdatedAt !== undefined
? initialAppliedUpdatedAt
: (contentUpdatedAt ?? null),
);
// Whether THIS client is the one that seeds the empty shared doc / applies an
// authoritative external snapshot into it. Exactly one client does, so the
// content isn't inserted once per open editor. A sole client always leads.
const [isLeadClient, setIsLeadClient] = useState(true);
// Count of OTHER visible human collaborators. When >0, a peer's edit also
// arrives via Yjs, so external markdown reconcile must defer (avoid applying
// the same change through both Yjs and setContent).
const peerCountRef = useRef(0);
// Briefly gates the first empty-SQL reconcile while Collaboration projects
// a nonempty fragment. `seededRef` is still released immediately so a real
// first keystroke can persist; this ref only postpones the ambiguous clear
// decision until we can distinguish an active/local edit from stale CRDT.
const emptySnapshotDecisionPendingRef = useRef(false);
useEffect(() => {
if (!collab || !awareness || !ydoc) {
setIsLeadClient(true);
peerCountRef.current = 0;
return;
}
const update = () => {
setIsLeadClient(isReconcileLeadClient(awareness, ydoc.clientID));
let peers = 0;
awareness.getStates().forEach((state, clientId) => {
if (clientId === ydoc.clientID) return; // self
if (clientId === AGENT_CLIENT_ID) return; // agent isn't a Yjs editor
const s = state as { user?: unknown; visible?: boolean };
if (s && s.user && s.visible !== false) peers += 1;
});
peerCountRef.current = peers;
};
update();
awareness.on("change", update);
document.addEventListener("visibilitychange", update);
return () => {
awareness.off("change", update);
document.removeEventListener("visibilitychange", update);
};
}, [collab, awareness, ydoc]);
// Collab seed: populate an empty shared Y.Doc from the markdown `value` once.
// The Collaboration extension does NOT auto-seed; only the lead client does,
// so two clients opening a brand-new block at once don't both seed (which
// would duplicate the content via concurrent inserts at the same position).
const seededRef = useRef(false);
useEffect(() => {
if (!collab || !editor || editor.isDestroyed || !ydoc) return;
if (seededRef.current) return;
if (!collabSynced) return;
// An empty SQL value has nothing to seed. Release the first real keystroke
// immediately, but when a fragment already exists defer the ambiguous
// reconcile decision for one task: active-peer or just-emitted local content
// is live and must be adopted; stale persisted CRDT with no active writer is
// cleared by the canonical SQL snapshot.
if (!value.trim()) {
seededRef.current = true;
const fragment = ydoc.getXmlFragment("default");
const currentMarkdown = getMarkdown(editor);
if (fragment.length === 0 && !currentMarkdown.trim()) return;
emptySnapshotDecisionPendingRef.current = true;
let cancelled = false;
const adoptTimer = setTimeout(
() => {
if (cancelled || editor.isDestroyed) return;
const projectedMarkdown = getMarkdown(editor);
const isOwnFreshEdit =
projectedMarkdown.trim() &&
(projectedMarkdown === lastEmittedRef.current ||
recentEmittedRef.current.includes(projectedMarkdown));
if (
projectedMarkdown.trim() &&
(peerCountRef.current > 0 || isOwnFreshEdit)
) {
lastAppliedValueRef.current = value;
lastAppliedSerializedRef.current = projectedMarkdown;
if (contentUpdatedAt) {
lastAppliedUpdatedAtRef.current = contentUpdatedAt;
}
}
emptySnapshotDecisionPendingRef.current = false;
},
awareness ? PEER_SETTLE_MS : 0,
);
return () => {
cancelled = true;
clearTimeout(adoptTimer);
emptySnapshotDecisionPendingRef.current = false;
};
}
if (!isLeadClient) return;
let cancelled = false;
// Defer via a timer task (NOT a microtask — microtasks can still run
// inside React's commit and trigger flushSync-from-lifecycle warnings).
// Read the editor and fragment INSIDE that task. The collaboration
// extension can finish projecting an already-populated Y.Doc into
// ProseMirror after this effect is scheduled. Capturing the pre-projection
// empty editor here would incorrectly seed the SQL snapshot alongside the
// existing CRDT content, duplicating the whole document after reload.
const seedTimer = setTimeout(() => {
if (cancelled || editor.isDestroyed) return;
const fragment = ydoc.getXmlFragment("default");
const currentMarkdown = getMarkdown(editor);
// Seed only when the shared doc is genuinely empty — either the fragment
// has no nodes yet, or it holds no semantic markdown (an empty paragraph,
// or an app's sentinel-empty filler via a custom `shouldSeed`).
if (
!shouldSeed({
value,
currentMarkdown,
fragmentLength: fragment.length,
})
) {
seededRef.current = true;
return;
}
isSettingContentRef.current = true;
try {
setContent(editor, value, {});
} finally {
isSettingContentRef.current = false;
}
const serialized = getMarkdown(editor);
lastEmittedRef.current = serialized;
pushEmittedRing(recentEmittedRef.current, serialized);
lastAppliedValueRef.current = value;
lastAppliedSerializedRef.current = serialized;
if (contentUpdatedAt) lastAppliedUpdatedAtRef.current = contentUpdatedAt;
seededRef.current = true;
}, 0);
return () => {
cancelled = true;
clearTimeout(seedTimer);
};
}, [
collab,
collabSynced,
editor,
ydoc,
value,
isLeadClient,
contentUpdatedAt,
getMarkdown,
setContent,
shouldSeed,
]);
// Reconcile authoritative external markdown (agent edit, source patch, or a
// peer edit mirrored to SQL) into the live editor. In collab mode only the
// lead client applies it through setContent; Yjs propagates the result to
// every other client. In non-collab mode this is the original controlled-value
// reconcile, unchanged.
useEffect(() => {
if (!editor || editor.isDestroyed) return;
let cancelled = false;
let retry: ReturnType | null = null;
// With peers present, a peer's edit also arrives via Yjs. Defer one poll
// cycle (+margin) and re-check before applying via setContent so the same
// change isn't inserted twice (Yjs + setContent → duplicated region).
const apply = (deferred = false) => {
if (cancelled || editor.isDestroyed) return;
// In collab mode, defer all reconcile until the shared doc is seeded so we
// never setContent over an unseeded fragment.
if (collab && !collabSynced) {
retry = setTimeout(() => apply(deferred), 300);
return;
}
// The seed decision itself is deferred one task so Collaboration can
// project persisted Y.Doc state before we inspect it. Poll that short
// handoff promptly: waiting the full provider cadence here makes a newer
// canonical SQL snapshot visibly stale after reload.
if (collab && !seededRef.current) {
retry = setTimeout(() => apply(deferred), 25);
return;
}
if (collab && emptySnapshotDecisionPendingRef.current) {
retry = setTimeout(() => apply(deferred), 50);
return;
}
const currentMarkdown = getMarkdown(editor);
// Compare against the canonical form the editor would emit so a serializer
// that re-normalizes (Content's NFM) still recognizes "already in sync".
const normalizedValue = normalizeValue(value);
// Whether the editor still holds exactly what THIS hook last applied (the
// user hasn't edited since). Only then are the round-trip echo guards
// below safe: if the user has since edited away from the applied content,
// an external snapshot equal to a previously-applied value is a real
// revert and must NOT be swallowed as an echo.
const editorUnchangedSinceApply =
lastAppliedSerializedRef.current !== null &&
currentMarkdown === lastAppliedSerializedRef.current;
const typingRecently =
editor.isFocused && Date.now() - lastTypedAtRef.current < 1500;
// Doc-equivalence skip. Never re-apply content the editor already
// represents — comparing by DOC EQUIVALENCE, not raw strings/timestamps:
// 1. `currentMarkdown === normalizedValue` — the editor's CURRENT
// serialized doc already equals the (normalized) incoming value.
// 2. `value === lastEmittedRef.current` — the incoming value is our own
// just-emitted markdown echoing back.
// 3. `value === lastAppliedValueRef.current` — the SAME raw value we
// already applied is being re-supplied (a lagging poll or a
// source-sync re-handing the same stored markdown). Applying it again
// would only reproduce the doc we hold; for NON-idempotent input it
// would compound divergence. Guarded by `editorUnchangedSinceApply`
// so a deliberate revert-to-previous after a local edit still lands.
// 4. `normalizedValue === lastAppliedSerializedRef.current` — the
// incoming value round-trips to the serialized output we last
// produced (our own autosaved echo coming back from SQL). For
// non-idempotent input the raw string differs from what we were
// handed, but it is doc-equivalent to what the editor already shows,
// so re-parsing it must be skipped. This is the guard that stops the
// `` → `<p>` → `<p>` escalation.
if (
currentMarkdown === normalizedValue ||
(typingRecently &&
// A stale echo of our own (possibly partial) save while the user is
// actively typing would clobber the fresh tail. Outside active
// typing, the same bytes can be a deliberate newer external revert
// (history restore / provider conflict choice) and must be allowed
// through even if mount-time normalization emitted them.
(value === lastEmittedRef.current ||
recentEmittedRef.current.includes(value) ||
recentEmittedRef.current.includes(normalizedValue))) ||
(editorUnchangedSinceApply &&
(value === lastAppliedValueRef.current ||
normalizedValue === lastAppliedSerializedRef.current))
) {
if (contentUpdatedAt) {
lastAppliedUpdatedAtRef.current = contentUpdatedAt;
}
return;
}
const externalNewer =
!lastAppliedUpdatedAtRef.current ||
!contentUpdatedAt ||
contentUpdatedAt > lastAppliedUpdatedAtRef.current;
// Only the lead client applies an authoritative snapshot into the shared
// Y.Doc; peers receive it through Yjs sync.
if (collab && !isLeadClient) {
if (contentUpdatedAt && !externalNewer) {
lastAppliedUpdatedAtRef.current = contentUpdatedAt;
}
return;
}
// Never clobber an in-progress edit. While the user is actively typing
// (focused and a keystroke landed within the window) defer and re-check —
// applying external content now would yank text out from under them and,
// for non-idempotent input, fight every keystroke. Newer external content
// retries so it still lands once they pause; older-or-equal content is a
// stale poll and is dropped outright while focused.
if (typingRecently) {
if (externalNewer) {
retry = setTimeout(() => apply(deferred), 700);
}
return;
}
// Older-or-equal content is a stale poll / lagging echo. Drop it while
// focused (a peer/agent edit would be NEWER and retries above). In
// NON-COLLAB mode there is no peer, so older-or-equal external content is
// ALWAYS stale — dropping it regardless of focus stops a lagging
// `get-visual-plan` poll from reverting a just-applied local structural
// change (drag-to-columns) while the editor is blurred (the drag grips the
// handle, not the prose, so `isFocused` is false at drop time). Gated on
// `lastAppliedSerializedRef` so the very first seed (nothing applied yet,
// also not-newer) still lands.
const seeded = lastAppliedSerializedRef.current !== null;
if (!externalNewer && (editor.isFocused || (!collab && seeded))) return;
// Race guard: with peers present, let Yjs deliver a peer's edit first.
// Defer once and re-check — a peer edit makes the equality check above
// no-op next pass; an agent/source edit still differs and applies.
if (collab && externalNewer && !deferred && peerCountRef.current > 0) {
retry = setTimeout(() => apply(true), PEER_SETTLE_MS);
return;
}
const applyTimer = setTimeout(() => {
if (cancelled || editor.isDestroyed) return;
// Re-check doc-equivalence at apply time. Between the decision above and
// this task a peer/Yjs edit (or our own prior apply) may have made
// the editor already represent this value — re-applying would be a
// wasted setContent that, for non-idempotent input, re-triggers the
// loop. Skip when the editor's current serialization already matches the
// normalized value, or the value round-trips to what we last produced.
const beforeMarkdown = getMarkdown(editor);
const normalized = normalizeValue(value);
const unchangedSinceApply =
lastAppliedSerializedRef.current !== null &&
beforeMarkdown === lastAppliedSerializedRef.current;
if (
beforeMarkdown === normalized ||
(unchangedSinceApply &&
normalized === lastAppliedSerializedRef.current)
) {
lastAppliedValueRef.current = value;
if (contentUpdatedAt) {
lastAppliedUpdatedAtRef.current = contentUpdatedAt;
}
return;
}
isSettingContentRef.current = true;
// Surgical path first: replace only the changed top-level run so
// unchanged block NodeViews are never torn down and (under
// Collaboration) Yjs sees a minimal edit instead of a full-fragment
// rewrite. Falls back to the classic whole-document setContent when no
// parser is available or the targeted transaction cannot be applied.
let appliedSurgically = false;
if (parseValue !== false) {
const parse = parseValue ?? defaultParseValue;
const parsedDoc = parse(editor, value);
if (parsedDoc) {
const result = applyDocSurgically(editor, parsedDoc);
appliedSurgically = result === "applied" || result === "noop";
}
}
if (!appliedSurgically) {
setContent(editor, value, { emitUpdate: false, addToHistory: false });
}
isSettingContentRef.current = false;
// Capture the SERIALIZED result, not the raw value. For non-idempotent
// input these differ; recording the serialized output is what lets the
// next poll (which returns this serialized form) be recognized as our
// own echo and skipped — stabilizing the doc after exactly one apply.
const serialized = getMarkdown(editor);
lastEmittedRef.current = serialized;
pushEmittedRing(recentEmittedRef.current, serialized);
lastAppliedValueRef.current = value;
lastAppliedSerializedRef.current = serialized;
if (contentUpdatedAt) {
lastAppliedUpdatedAtRef.current = contentUpdatedAt;
}
}, 0);
retry = applyTimer;
};
apply();
return () => {
cancelled = true;
if (retry) clearTimeout(retry);
};
}, [
contentUpdatedAt,
editor,
value,
collab,
collabSynced,
isLeadClient,
getMarkdown,
setContent,
parseValue,
normalizeValue,
]);
const shouldIgnoreUpdate = (transaction: Transaction): boolean => {
if (!editable || isSettingContentRef.current) return true;
// Collaboration can emit schema/default-paragraph transactions while the
// persisted Y.Doc is still loading and before the authoritative value has
// seeded/reconciled. Never let those transient pre-seed updates reach an
// app's local mirror or autosave path (Content serializes an empty paragraph
// as ``, which is non-empty text and bypasses the generic
// registerEmitted empty-string guard).
if (collab && !seededRef.current) {
// Passive effects normally mark a synced empty document initialized
// before a person can type. Keep the event boundary correct too: if a
// genuine local edit wins that tiny race, it is itself proof that the
// synced empty document is ready. Remote Yjs transactions stay ignored.
const firstSyncedEmptyUserEdit =
collabSynced &&
!value.trim() &&
transaction.docChanged &&
Boolean(transaction.getMeta("uiEvent")) &&
!isChangeOrigin(transaction);
if (!firstSyncedEmptyUserEdit) return true;
seededRef.current = true;
}
if (transaction.getMeta(RICH_MARKDOWN_PROGRAMMATIC_TRANSACTION)) {
return true;
}
// In collab mode, never persist remote-originated changes (the initial Yjs
// state load or a peer's edit arriving via sync). Each client saves only its
// OWN local edits; a peer's edit is saved by that peer. Without this, a
// lagging Y.Doc load would write stale markdown over newer SQL.
if (collab && transaction && isChangeOrigin(transaction)) return true;
lastTypedAtRef.current = Date.now();
return false;
};
const registerEmitted = (markdown: string): boolean => {
// Don't persist an empty doc before Collaboration has seeded — that would
// clobber the saved block content with an empty string.
if (collab && !markdown.trim()) return false;
lastEmittedRef.current = markdown;
pushEmittedRing(recentEmittedRef.current, markdown);
return true;
};
return {
collab,
isSettingContentRef,
shouldIgnoreUpdate,
registerEmitted,
};
}