document.tsx16.6 KBView on GitHub

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>
  );
});