Introduced 1 production defect in 180 days, median 26 days to fix.
49 error spans on 2 routes in the last 7 days.
'use client';
/**
* `<Document />` — shared Y.js-backed editor primitive.
*
* Wraps the common plumbing every doc surface needs:
* - Refcounted Y.js provider lifecycle (acquireProvider / releaseProvider)
* with IDB-first warm/cold loading: warm skips server fetch, cold fetches
* the Y.js blob and seeds the doc imperatively
* - `useDocEvents` SSE subscription for live agent/remote updates
* - `MarkdownEditor` with `Collaboration` + `EnsureNodeIds` pre-wired
*
* Document is route-agnostic — callers pass only `documentId`. The component
* handles the initial Y.js fetch internally.
* Comments, mentions, slash commands, breadcrumbs, delete/refresh buttons,
* attachment views — all live in the caller. Document just owns the editor body.
*
* Used by:
* - `OverviewDocTab` (conversation docs)
* - `FileEditor` in `/brain` (id-keyed docs)
*
* The agenda surfaces have meaningfully different chrome and stay separate.
*/
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
type ReactNode,
} from 'react';
import type { FlushSuccess, FlushError } from '@/modules/documents/yjs';
import { Collaboration } from '@tiptap/extension-collaboration';
import type { AnyExtension, Editor } from '@tiptap/core';
import type { EditorView } from '@tiptap/pm/view';
import * as Y from 'yjs';
import {
MarkdownEditor,
type MarkdownEditorHandle,
} from '@/components/markdown-editor';
import type { SlashCommandItem } from '@/components/slash-command/types';
import {
EnsureNodeIds,
createTrpcApplyUpdateClient,
acquireProvider,
releaseProvider,
getProvider,
base64ToUint8Array,
useDocEvents,
} from '@/modules/documents/yjs';
import { useTRPCClient } from '@/providers/query-provider';
export interface DocumentHandle {
/** Stable id of the doc currently bound, or null if Document hasn't received one yet. */
documentId: string | null;
/** Y.Doc the editor is bound to. Use this for `useYDocComments(ydoc)` etc. */
ydoc: Y.Doc | null;
/** Underlying TipTap editor instance. */
editor: Editor | null;
/** Current markdown serialization of the editor content. */
getMarkdown(): string;
/** Drain any debounced Y.js updates immediately (tab-close safety net). */
forceFlush(): Promise<void>;
/** Force a server fetch and re-seed the Y.Doc, bypassing the warm IDB check. */
refreshFromServer(): Promise<void>;
}
export interface DocumentReadyInfo {
documentId: string;
ydoc: Y.Doc;
editor: Editor;
}
export interface DocumentProps {
/**
* Stable doc id — drives provider registry lookup and SSE subscription.
* Pass `null` while loading; Document mounts an empty editor as a placeholder.
*/
documentId: string | null;
/**
* An org admin/owner reading a teammate's document. Forwarded to `documents.getDoc`
* (both the initial seed fetch and `refreshFromServer`) — this is what lets the
* caller's own scoping (e.g. `AgentInstructionsSection`'s `targetUserId`) actually
* reach the Y.Doc content fetch, which otherwise runs as the session user regardless
* of what the surrounding page is viewing. The `files.applyUpdate` write path and the
* `/api/doc-events` SSE subscription need no equivalent: both derive the document's
* owner from the document row itself, not from a client-supplied id.
*/
targetUserId?: string;
/** Extra TipTap extensions beyond StarterKit + EnsureNodeIds + Collaboration. */
extraExtensions?: AnyExtension[];
/** Slash-command items appended to the default `/` menu. */
extraSlashCommands?: SlashCommandItem[];
placeholder?: string;
className?: string;
/** Defaults to false for the same reason FileEditor sets it: defer hydration until provider attaches. */
immediatelyRender?: boolean;
/** Forwarded to `MarkdownEditor` — enables the Gapcursor (arrow-past non-selectable blocks). */
enableGapcursor?: boolean;
handleKeyDown?: (view: EditorView, event: KeyboardEvent) => boolean;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
/**
* Called once per `documentId` when the editor instance is created and the
* Y.Doc is bound. Use this to wire `useYDocComments`, selection-bubble menus,
* or anything else that needs the live `Editor` + `Y.Doc` references.
*/
onReady?: (info: DocumentReadyInfo) => void;
/** Forwarded to `MarkdownEditor`. Fires on every keystroke with the serialized markdown. */
onChange?: (markdown: string) => void;
/** Children rendered inside the editor wrapper (BubbleMenus, CommentPopovers, etc.). */
children?: ReactNode;
/**
* Optional onError forwarded to the Y.js provider. Defaults to console.error.
*/
onProviderError?: (err: unknown) => void;
/**
* When true, the Y.js provider's debounce auto-save is disabled. Saves only
* happen when `forceFlush()` is called on the DocumentHandle. Use together
* with `onDirtyChange` and a manual save button.
*/
manualFlushOnly?: boolean;
/**
* Called whenever the document's dirty state changes (true = unsaved local
* edits exist; false = all changes are flushed to the server).
*/
onDirtyChange?: (isDirty: boolean) => void;
/**
* Called after every flush attempt (success or error). Mirrors the provider's
* `onFlushEnd` option — useful for reading compile hook results on save.
*/
onFlushEnd?: (result: FlushSuccess | FlushError) => void;
/**
* The document's Y.js state, base64, when the CALLER already fetched it.
*
* A caller that resolves the document itself — the agenda looks its row up by
* path — otherwise makes this component fetch the very same row a second time
* by id. Both calls run the type's full reconcile server-side, so the page
* paid for two of them, serially, to render one document.
*
* `undefined` means "not provided, fetch it yourself"; `null` means "provided,
* and the document is empty". Read once, when the provider for a given
* `documentId` is created — later values are delivered over SSE, not by
* re-seeding, because re-applying initial state into a live Y.Doc yanks the
* cursor to the end of the document.
*/
initialContentYjs?: string | null;
}
/**
* If IndexedDB hasn't hydrated within this window, give up on it: seed the editor
* from the server AND delete the local store so it rebuilds clean next open.
*
* 2s is deliberately aggressive. A *valid* store — even one holding unsynced
* offline edits — settles in well under a second, so this only ever fires for a
* genuinely poisoned/hung store (whose replay threw, so its data is unreadable
* anyway). And IndexedDB is only a load-speed cache over the authoritative
* server, so a wrongly-cleared store costs at most a one-time server refetch.
*/
const IDB_HYDRATE_TIMEOUT_MS = 2000;
export const Document = forwardRef<DocumentHandle, DocumentProps>(function Document(
{
documentId,
targetUserId,
extraExtensions = [],
extraSlashCommands = [],
placeholder,
className,
immediatelyRender = false,
enableGapcursor = false,
handleKeyDown,
onClick,
onReady,
onChange,
children,
onProviderError,
manualFlushOnly = false,
onDirtyChange,
onFlushEnd,
initialContentYjs,
},
ref,
) {
const editorRef = useRef<MarkdownEditorHandle>(null);
// Read via ref so a refetch upstream doesn't retrigger the provider effect
// (which would re-hydrate + re-apply initial state on every keystroke).
const initialContentYjsRef = useRef(initialContentYjs);
initialContentYjsRef.current = initialContentYjs;
// Same ref-not-dependency treatment as initialContentYjsRef above: a target switch
// always arrives with a new documentId (a different user's copy of the doc), so this
// only needs to be current when the provider effect reads it, not a trigger to rebuild it.
const targetUserIdRef = useRef(targetUserId);
targetUserIdRef.current = targetUserId;
// Keep the latest error handler in a ref so the provider effect below can
// read it without listing `onProviderError` as a dependency. Callers
// routinely pass an inline arrow (new identity every render); depending on
// it would tear down and rebuild the provider on every keystroke — which
// re-hydrates IDB, re-fetches getDoc, and re-applies initial state into the
// live Y.Doc, yanking the cursor to the end of the document on each edit.
const onProviderErrorRef = useRef(onProviderError);
onProviderErrorRef.current = onProviderError;
const onDirtyChangeRef = useRef(onDirtyChange);
onDirtyChangeRef.current = onDirtyChange;
const onFlushEndRef = useRef(onFlushEnd);
onFlushEndRef.current = onFlushEnd;
// Use the context-aware tRPC client (includes X-Admin-View-User header when
// an admin is viewing another user) rather than the module-level singleton.
const trpcClientCtx = useTRPCClient();
const trpcClientCtxRef = useRef(trpcClientCtx);
trpcClientCtxRef.current = trpcClientCtx;
// Always bind to a fresh empty Y.Doc; the provider effect seeds it via
// applyInitialState so y-prosemirror's incremental observer streams nodes
// in. Resetting on documentId change keeps the binding stable when the
// same component is reused across documents (e.g. file picker → next doc).
// See apps/mail/docs/yjs-local-persistence.md.
const ydocRef = useRef<Y.Doc | null>(null);
const prevDocIdRef = useRef<string | null>(null);
if (!ydocRef.current || prevDocIdRef.current !== documentId) {
prevDocIdRef.current = documentId;
ydocRef.current = new Y.Doc();
}
// Lifecycle: acquire/release the refcounted provider for this documentId,
// then always seed from the server. A warm IDB only proves the client has
// every edit *it has ever observed* — not every edit, because agents and
// other tabs may have written to the doc while this client wasn't watching.
// `applyInitialState` is a Y.js CRDT merge, so any unflushed offline edits
// already in the local Y.Doc are preserved and the next flush sends them up.
useEffect(() => {
if (!documentId || !ydocRef.current) return;
const provider = acquireProvider({
documentId,
client: createTrpcApplyUpdateClient(),
ydoc: ydocRef.current,
debounceMs: manualFlushOnly ? 0x7fffffff : undefined,
onError: (err) =>
onProviderErrorRef.current
? onProviderErrorRef.current(err)
: console.error('[Document] provider error', err),
onFlushEnd: (result) => {
if (!('error' in result)) {
// Successful flush — document is no longer dirty.
onDirtyChangeRef.current?.(false);
}
onFlushEndRef.current?.(result);
},
});
let cancelled = false;
// Track whether IDB hydration actually settled. A corrupt/incompatible local
// Y.Doc makes y-indexeddb's replay throw SYNCHRONOUSLY inside its own IDB
// success handler, so `whenSynced` never settles and `idbReady` hangs forever
// — neither resolving nor rejecting. That left a permanently empty editor on
// warm opens (incognito was fine: no replay to trip). A plain `.catch()`
// can't help because there's no rejection; we must time out instead.
let idbSettled = false;
void provider.idbReady.then(
() => {
idbSettled = true;
},
() => {
idbSettled = true;
},
);
// Start the server seed NOW, not after IndexedDB settles. The two are
// independent — IDB is a local cache, the fetch is a network round trip —
// and the only reason they were ordered is the poisoned-store cleanup
// below, which must run before `applyInitialState` writes through to the
// store. So the fetch overlaps the replay and only the APPLY waits.
//
// Resolves immediately when the caller already handed us the bytes, which
// is the whole point of `initialContentYjs`: no second request at all.
const seedPromise: Promise<string | null> =
initialContentYjsRef.current !== undefined
? Promise.resolve(initialContentYjsRef.current)
: trpcClientCtxRef.current.documents.getDoc
.query({ documentId, targetUserId: targetUserIdRef.current })
.then((doc) => doc.contentYjs ?? null);
// Mark it handled up front. It is only awaited after the IDB race settles,
// and a rejection arriving before that would otherwise be reported as an
// unhandled rejection. The await below still sees the rejection.
seedPromise.catch(() => {});
void Promise.race([
provider.idbReady.catch(() => {
/* hang/rejection is handled via the idbSettled flag below */
}),
new Promise<void>((resolve) => setTimeout(resolve, IDB_HYDRATE_TIMEOUT_MS)),
])
.then(async () => {
if (cancelled) return;
if (!idbSettled) {
// IDB is still unsettled after the timeout — a poisoned/hung store (a
// valid one settles in well under a second). Delete it so it rebuilds
// clean on the next open; this session renders from the server seed
// below regardless.
console.error('[Document] IndexedDB hydration stalled; clearing local store for', documentId);
try {
indexedDB.deleteDatabase(`cedar-doc-${documentId}`);
} catch {
/* best effort */
}
}
const contentYjs = await seedPromise;
if (cancelled || !contentYjs) return;
try {
provider.applyInitialState(base64ToUint8Array(contentYjs));
} catch (persistErr) {
// applyInitialState updates the in-memory Y.Doc BEFORE its IDB persist
// observer runs, so the editor still renders; a throw here is only that
// observer hitting the store we just cleared — not a hydration failure.
console.warn('[Document] applyInitialState persist warning (render unaffected)', documentId, persistErr);
}
})
// Surface getDoc failures instead of swallowing them — a silent rejection
// here is what leaves a permanently empty editor.
.catch((err) => {
if (cancelled) return;
console.error('[Document] failed to hydrate Y.Doc from server', documentId, err);
onProviderErrorRef.current?.(err);
});
return () => {
cancelled = true;
void releaseProvider(documentId);
};
}, [documentId, manualFlushOnly]);
// Live remote updates over SSE (agent writes, other tabs/users).
useDocEvents(documentId);
// Imperative handle.
useImperativeHandle(
ref,
() => ({
documentId,
// Live getters, not snapshots: the TipTap editor (and its Y.Doc) are
// created after this handle's factory runs, and the factory only re-runs
// when `documentId` changes — which it never does once mounted. A static
// `editor: editorRef.current?.editor` would therefore capture `null` at
// mount and stay null forever, silently breaking every consumer that
// reads `handle.editor` (agenda delete/snooze/add-task, etc.).
get ydoc() {
return ydocRef.current;
},
get editor() {
return editorRef.current?.editor ?? null;
},
getMarkdown: () => editorRef.current?.getMarkdown() ?? '',
forceFlush: async () => {
if (!documentId) return;
await getProvider(documentId)?.forceFlush();
},
refreshFromServer: async () => {
if (!documentId) return;
const provider = getProvider(documentId);
if (!provider) return;
await provider.forceFlush();
const seed = await trpcClientCtxRef.current.documents.getDoc.query({
documentId,
targetUserId: targetUserIdRef.current,
});
if (!seed.contentYjs) return;
provider.applyInitialState(base64ToUint8Array(seed.contentYjs));
},
}),
[documentId],
);
const composedExtensions = useMemo<AnyExtension[]>(
() => [
Collaboration.configure({
document: ydocRef.current!,
field: 'prosemirror',
yUndoOptions: { trackedOrigins: ['agent'] },
}),
EnsureNodeIds,
...extraExtensions,
],
[extraExtensions],
);
return (
<MarkdownEditor
key=[redacted] ?? 'loading'}
ref={editorRef}
placeholder={placeholder}
className={className}
immediatelyRender={immediatelyRender}
enableGapcursor={enableGapcursor}
extraExtensions={composedExtensions}
extraSlashCommands={extraSlashCommands}
handleKeyDown={handleKeyDown}
onClick={onClick}
onChange={(md) => {
if (onDirtyChangeRef.current) onDirtyChangeRef.current(true);
onChange?.(md);
}}
onEditorCreated={(editor) => {
if (documentId && ydocRef.current) {
onReady?.({ documentId, ydoc: ydocRef.current, editor });
}
}}
>
{children}
</MarkdownEditor>
);
});