Skip to content

feat(content): ✨ live editing, autosave and presence for the Content Engine - #852

Merged
aXenDeveloper merged 25 commits into
canaryfrom
claude/content-live-editing
Oct 10, 2026
Merged

aXenDeveloper merged 25 commits into
canaryfrom
claude/content-live-editing

Conversation

@aXenDeveloper

Copy link
Copy Markdown
Owner

Improving Documentation

  • New pages: dev/content-engine/live-editing.mdx (with real AdminCP screenshots) and dev/content-engine/rich-text.mdx.
  • Updated pages:
    • fields, reference, localization, publication-and-editorial
    • dev/websocket (new Rooms section)
    • dev/events/built-in-events
    • guides/blog
  • Prettier: I formatted the two new pages. The six existing pages I touched were already failing Prettier on canary, so I left their formatting alone to keep the diff readable.

Description

What?

Live editing for every editorial Content Engine content type, used by the blog plugin. The design and phases are in plans/content-live-editing.md.

  • Autosave to a shared draft.
    • Edits land in core_content_drafts (one draft per record and language, shared by the team), never in the published record.
    • Autosave writes no revisions, emits no events and does no search reindexing or cache revalidation.
    • Save and Publish stay explicit commits.
  • Presence.
    • Avatars in the form header.
    • Small avatars next to a field showing who is in it.
    • Markers in the language switcher.
    • Messages travel over the existing /api/ws socket, and each AdminCP message checks the AdminCP session and can_edit.
  • Hard field locks.
    • Plain fields are locked to one editor per field and language: a 60 s lease in core_content_field_locks, renewed while focused.
    • Everyone else sees the field read-only, with "Anna is editing" and the value updating live.
  • Real-time co-editing of rich text.
    • Uses Yjs and Tiptap Collaboration, with name-labelled carets and per-user undo.
    • The Yjs state is persisted in core_content_documents.
  • field.richText().
    • Stores ProseMirror JSON in jsonb.
    • Rendered on the server by RichTextContent, a pure renderer. A boundaries test proves it never pulls in Tiptap, ProseMirror or Yjs.
    • Plain-text and HTML helpers feed search, SEO and AI.
  • WebSocket rooms.
    • Membership is counted per browser tab, because tabs share one socket.
    • Rooms fan out across instances over Redis.
    • Presence is merged across instances.
  • Without a socket (serverless): locks and drafts fall back to HTTP polling, and rich text falls back to a single editor guarded by a lock.
  • Blog:
    • content is now field.richText.
    • The editor shows presence.
    • AI translation writes through the shared editor in one undoable change.
  • Migration.
    • Adds the three tables.
    • Converts blog_posts_translations.content from text to jsonb. Existing HTML keeps its words as one paragraph instead of failing the cast.
    • It's canary, so there is no full HTML→JSON conversion yet; that is listed in the plan.
  • New dependencies (core): yjs, y-protocols, @tiptap/extension-collaboration, @tiptap/extension-collaboration-caret, @tiptap/y-tiptap.

Why?

Editors lost work and hit CONTENT_VERSION_CONFLICT when several people edited the same article. With live editing, a team works on one record at once: they see who is where, can't overwrite each other's fields, write the body together, and nothing is lost between saves.

Verification

  • Unit and integration tests: core tsc is clean, and vitest passes 556 files and 8949 tests with none skipped, including the Postgres integration tests run against a real Postgres 16. The type-level tests pass (376) with no type errors.
  • Plugins: the blog passes tsc, 22 tests and eslint; the example plugin passes tsc. ESLint is clean on all 102 changed core files.
  • Postgres-specific checks: of two editors racing for the same lock, exactly one wins, and concurrent draft writes to different fields merge correctly.
  • End to end: I ran apps/api and apps/web locally with two signed-in admins in Playwright.
    • Presence works in both directions.
    • The title lock shows "Test is editing" and blocks the other editor's typing.
    • The value syncs live.
    • The draft is written while the record stays unchanged.
    • Both editors typed in the same rich text at once and the documents converged, with carets.
    • The Yjs state was persisted and survived a reload.
    • Save committed the merged content as version 2.
  • Bug found and fixed by that run: an async editor slot made the collaborative editor reopen its document in a loop. Fixed, with a regression test.

Known limitations

These are listed in the plan:

  • Rate limit: the production rate limit (80 requests/min per IP) can be reached by a large team behind one IP.
  • Seeding race: if someone types into a brand-new shared document before it is filled from the record, the record's content is not filled in.
  • "Unsaved changes": the indicator doesn't cover co-edited rich text.
  • AI writes to plain fields (excerpt, alt text) are not autosaved or broadcast.
  • Translation delete doesn't reset that language's live state.
  • Data migration: the full HTML→JSON conversion script still needs to be written before the stable release.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb


Generated by Claude Code

@github-actions github-actions Bot added the 💡 Feature A new feature label Oct 8, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-10T19:42:59.821285Z c84bbaa New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

React Doctor found 16 new issues in 9 files · 16 warnings · score 82 / 100 (Needs work) · 1 fixed · vs canary

16 warnings

src/api/modules/content/cron/content-editorial-cleanup.cron.ts

  • ⚠️ L25 Independent awaits run sequentially async-parallel
  • ⚠️ L39 Sequential independent awaits server-sequential-independent-await

src/components/form/fields/file.tsx

  • ⚠️ L172 React function has high control-flow complexity no-high-complexity-react-function

src/content/server/live-store.ts

  • ⚠️ L371 await inside a loop async-await-in-loop

src/content/server/live/presence.test.ts

  • ⚠️ L386 JSON parse/stringify deep clone no-json-parse-stringify-clone

src/views/admin/views/content/lib/field-component.tsx

  • ⚠️ L203 React function has high control-flow complexity no-high-complexity-react-function

src/views/admin/views/content/live/collaborative-editor.tsx

  • ⚠️ L147 Prop derived into useState no-derived-useState
  • ⚠️ L203 Live state pushed to parent via effect no-pass-live-state-to-parent
  • ⚠️ L203 Parent kept in sync with a callback effect no-prop-callback-in-effect

src/views/admin/views/content/live/field.tsx

  • ⚠️ L62 React function has high control-flow complexity no-high-complexity-react-function
  • ⚠️ L90 Ref initializer runs on every render rerender-lazy-ref-init
  • ⚠️ L239 Live state pushed to parent via effect no-pass-live-state-to-parent

src/views/admin/views/content/live/presence.tsx

  • ⚠️ L35 Intl formatter rebuilt each call js-hoist-intl

src/views/admin/views/content/live/root.tsx

  • ⚠️ L155 Ref initializer runs on every render rerender-lazy-ref-init
  • ⚠️ L175 await inside a loop async-await-in-loop
  • ⚠️ L327 Context value from an unmemoized local literal context-provider-value-from-unmemoized-local-literal

Reviewed by React Doctor for commit c84bbaa. See inline comments for fixes.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8416934cd0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +166 to +167
const batch = [...pendingRef.current.values()];
pendingRef.current.clear();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Requeue autosave values after a failed draft write

When saveDraft returns a failure, the batch has already been removed from pendingRef and is never restored or retried. This commonly occurs when a lease expires or during a transient API failure; after the lock is reacquired, the unchanged field value does not trigger another queue operation, so navigating away loses the edit despite the autosave feature. Keep failed values pending and schedule a retry, while preserving any newer value queued for the same field.

Useful? React with 👍 / 👎.

Comment on lines +913 to +917
return upsertLangValue(
current as MultiLangValue | undefined,
locale,
typeof value === "string" ? value : "",
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve localized rich-text documents during polling

In the no-WebSocket/serverless fallback, rich-text fields use locks and receive other editors' autosaved drafts through polling. Those draft values are ProseMirror objects, but this branch converts every non-string localized value to ""; consequently another editor's valid localized rich-text autosave clears the local form instead of applying the document, and a subsequent Save can commit the blank value. Handle richText by inserting the document object into the localized value.

Useful? React with 👍 / 👎.

Comment on lines +5 to +6
/** The largest Yjs or awareness update one message may carry, decoded. */
export const CONTENT_LIVE_MAX_UPDATE_BYTES = 2 * 1024 * 1024;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow valid large rich-text documents to synchronize

Fields may configure maxBytes up to 16 MB, but the live protocol rejects every decoded Yjs update over 2 MB. Seeding a valid document larger than this is performed as one setContent transaction, so its update is rejected as INVALID_MESSAGE and the shared document remains unseeded; similarly, a sufficiently large edit cannot reach collaborators. Align the protocol limit with the supported document ceiling or chunk large updates.

Useful? React with 👍 / 👎.

claude and others added 18 commits October 10, 2026 10:14
…protocol

Rooms count members per browser tab, since tabs share one socket, and fan
out across instances over the existing Redis pub/sub channel. The content
live protocol is the shared contract for presence, field locks, drafts and
collaborative rich text documents. Adds Yjs and the Tiptap collaboration
packages, and the plan in plans/content-live-editing.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb
Editorial content types gain the HTTP half of live editing: per-field,
per-language lock leases (atomic upsert, 409 naming the holder) and one
shared draft per record and language that autosave merges into, never
touching the record. Every change is broadcast to the record's room, a
reset (revision restore, delete) throws the working state away, and a
user leaving the room gives their locks back.

The AdminCP form joins the room for an existing record, opens on the
draft, locks a field while it has focus, shows "Anna is editing" on
fields someone else holds, autosaves the fields it holds and reports
"Saving…", "Draft saved at …" and "Unsaved changes". Without a socket it
polls the locks and the draft instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb
- field.richText(): ProseMirror JSON in a jsonb column, localizable,
  validated by a recursive Zod schema with depth and size limits
- RichTextContent (@vitnode/core/components/rich-text): pure React
  renderer with link/style allowlists and node/mark overrides, never
  reaching Tiptap, ProseMirror or Yjs
- richTextToPlainText, richTextToHtml, isRichTextEmpty for search, SEO,
  revision diffs and AI prompts
- Tiptap editor JSON mode (format="json"), plus extensions/undoRedo props
- Blog article content switched to rich text

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb
…ext JSON

The blog content column moves from HTML text to ProseMirror JSON. Existing
rows keep their words as a single paragraph; a full HTML conversion ships
before the stable release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb
- Yjs document provider over the live channel: opens on every (re)join and
  on NOT_JOINED, exchanges state vectors, relays awareness, seeds once, goes
  quiet on reset, filters by document and clientId for tabs sharing a socket
- Co-edited rich text editor (lazy): Collaboration + CollaborationCaret with
  carets in the member's avatar color, Yjs undo, skeleton until synced, form
  value follows the shared document; no field lock and no draft autosave
  while co-editing (sticky through a dropped socket)
- Presence UI: ContentLivePresence (header, page and dialog), field avatars
  beside labels, language markers on MultiLangSelect
- Editor registry (useContentRichTextEditor / useContentRichTextReplace) so
  layouts write through the shared editor
- Field locks follow the language a field's own switcher shows; focus keeps
  the editor's language for shared fields
- Session re-joins on NOT_JOINED instead of falling back to polling, and a
  document's error no longer costs the seat

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb
- Article editor header shows who else is in the article; title and slug
  show the people in them, the translate menu who works in each language
- AI translations of the content go through the shared editor when the
  article is co-edited, so everyone gets them; the remount stays for solo
  editing
- The language on screen is sent as the editor's language

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb
…ment in a loop

The editor slot was an async component, so every render handed React a new
promise: the field suspended, remounted and closed and reopened its shared
document forever. Found by running two editors against the real socket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…removed

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ns and node test env

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@aXenDeveloper
aXenDeveloper force-pushed the claude/content-live-editing branch from 8416934 to 6042b31 Compare October 10, 2026 08:35

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6042b317f6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +45 to +49
'type', 'paragraph',
'content', jsonb_build_array(jsonb_build_object(
'type', 'text',
'text', btrim(regexp_replace(regexp_replace("content", '<[^>]*>', ' ', 'g'), '\s+', ' ', 'g'))
))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve formatting during the rich-text migration

For every existing blog article containing editor-generated HTML, this conversion removes all tags and stores the remaining words in one paragraph. Headings, lists, links, tables, media, and inline formatting are therefore irreversibly lost as soon as the migration runs; convert the HTML structure to equivalent ProseMirror JSON instead of stripping it.

Useful? React with 👍 / 👎.

Comment on lines +54 to +58
const overlaid: TData = drafts.shared
? {
...data,
...drafts.shared.values,
labels: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject stale drafts before overlaying them

When a record is updated outside this draft—for example through an API integration—the retained draft may have an older baseVersion, but this overlay applies its values unconditionally. The form then uses the newly loaded record version as expectedVersion, so pressing Save succeeds and silently restores the stale field values instead of reporting a conflict; compare each draft's baseVersion with the corresponding current version before applying it.

Useful? React with 👍 / 👎.

logError("Content document seed release error:", error);
}
}
if (entry.members.size === 0) unload(entry);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Retain dirty documents when the final persist fails

When the last local document member closes during a transient database failure, persist() catches the failed save and marks the entry dirty, but this unconditional unload immediately destroys the Yjs document and removes it from docs. No retry can then occur, so the collaborators' rich-text edits are lost despite having appeared autosaved; only unload after persistence succeeds, or retain the entry for retry.

Useful? React with 👍 / 👎.

Comment on lines +364 to +367
<fieldset
aria-describedby={describedBy}
className="min-w-0"
disabled={lockedByOther}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Disable fields while lock acquisition is pending

When the local lock list is stale and another editor already owns the field, the field remains enabled while the acquire request and subsequent lock refresh are pending. Text entered during that window stays in the form even after the field becomes disabled, and the ordinary Save route does not enforce field locks, so the user can commit and overwrite the lock holder's work; keep the control disabled until acquisition succeeds and discard or prevent edits after a rejected acquisition.

Useful? React with 👍 / 👎.

aXenDeveloper and others added 3 commits October 10, 2026 10:46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The article editor crashed with "textOf(...).trim is not a function" once
content became a rich text document. Freshness now fingerprints a document
by its HTML and treats an empty document as missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cb9005858f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const gone = new Map<string, LocalMember>();
for (const entry of entries) {
const key = `${contentLiveRoom(entry.room)}|${entry.member.userId}`;
if (!presence.hasUser(entry.room, entry.member.userId)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Check remote presence before releasing a user's locks

In a Redis-backed multi-instance deployment, when a user has tabs routed to two API instances and closes the last tab on this instance, hasUser checks only local members. This calls userLeft, whose live-store listener deletes all of that user's locks from the shared database even though their remote tab is still active; its subsequent draft writes then receive CONTENT_FIELD_NOT_LOCKED. Consult the union of local and remote presence before releasing the user's locks.

Useful? React with 👍 / 👎.


// Exact, not case-insensitive: the locale is part of the document's room, so
// "EN" and "en" would otherwise be two documents of one field.
return (await findContentLanguage(c, doc.locale))?.locale === doc.locale;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject disabled locales for collaborative documents

When a locale exists in core_languages but is configured with enabled: false, findContentLanguage still returns it, so this equality accepts the document even though disabled languages are read-only everywhere else, including the new lock and draft routes. A staff client can consequently open and persist Yjs updates for a disabled translation, and that hidden working state becomes visible if the locale is later re-enabled. Require language.isEnabled as well as the canonical locale match.

Useful? React with 👍 / 👎.

}),
);

return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record rich-text translations in freshness metadata

After a successful AI translation of content, this early return skips freshness.rememberAiTranslation(to, field), while every text-field translation still records it. A previously outdated article body therefore remains marked outdated after being regenerated, and a newly generated body has no source fingerprint with which to detect later source edits. Record the translation before returning from this branch.

Useful? React with 👍 / 👎.

Comment on lines +80 to +81
await runAll(resetListeners, { c, reason, room });
broadcastContentLive(room, { reason, room, type: "reset" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop broadcasting resets when live-state deletion fails

If either reset listener fails—for example, deleting core_content_documents fails after a revision restore—runAll logs and suppresses the rejection, and this code still broadcasts the reset. Clients then discard their in-memory document, reopen, and load the stale Yjs state that was not deleted, allowing old rich text to replace the restored revision on the next save. Do not announce a successful reset until the persistent live state has actually been cleared.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dde84ba4df

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +169 to +172
const promise = (async () => {
const state = await store.load(ref);
const doc = new Y.Doc({ gc: true });
if (state && state.byteLength > 0) Y.applyUpdate(doc, state);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Synchronize newly loaded documents before serving them

In a Redis-backed multi-instance deployment, an instance ignores relayed updates while this document is not loaded, so opening it during another instance's 2-second persistence window reads stale database state here. If editors then make changes on both instances, each persists a full Yjs snapshot with an upsert, and the stale instance can overwrite the snapshot containing the earlier update, permanently losing accepted edits. Newly loaded documents need a peer synchronization handshake or persistence that atomically merges Yjs updates before they are served.

Useful? React with 👍 / 👎.

Comment on lines +162 to +165
const rows = await db
.delete(locks)
.where(and(lockKey(key), eq(locks.userId, userId)))
.returning({ id: locks.id });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track lock ownership per browser tab

When one user focuses the same field in two tabs, both acquisitions succeed because ownership is keyed only by userId; when either tab blurs, this deletion releases the shared lock even though the other tab is still editing. Another user can then acquire the field, while the remaining tab continues accepting input until its next renewal and may submit it through the ordinary Save route. Include the tab/client identity in lock ownership or avoid releasing while another tab from the same user still holds the field.

Useful? React with 👍 / 👎.

…ile handling

feat(ai-review): ✨ implement AI review functionality with tests for article suggestions
feat(passage-fix): ✨ create passage fix utility with tests for text replacement
feat(readiness-actions): ✨ add readiness actions component for article checks
feat(seo-audit): ✨ implement SEO audit component for article readiness checks

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b16034cfec

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +89 to +96
const [row] = await c
.get("db")
.select({ state: table.state })
.from(table)
.where(whereDoc(doc))
.limit(1);

return row && row.state.byteLength > 0 ? row.state : null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject documents whose base version is stale

When a rich-text field is updated outside the live editor after a Yjs document was persisted, load returns the stored state without reading or comparing its baseVersion to the current record or translation version. The next editor therefore receives the obsolete Yjs document instead of being asked to seed from the newly committed JSON, and a subsequent Save can silently overwrite the external update. Load only state based on the current version, deleting or reseeding stale documents; localized documents must be compared with the translation version rather than the base row version.

Useful? React with 👍 / 👎.

Comment on lines +132 to +135
const removed = await c
.get("db")
.delete(table)
.where(lt(table.updatedAt, before))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve uncommitted documents during stale cleanup

For a rich-text document that was autosaved but never committed with Save, its stored baseVersion still equals the current record version; nevertheless this query deletes it solely because it has been untouched for 30 days. Reopening then seeds from the older record JSON, permanently losing the autosaved collaborative work. As cleanupStaleContentDrafts already does for plain-field drafts, only remove an aged document after proving its record or translation moved past baseVersion or no longer exists.

Useful? React with 👍 / 👎.

- Removed unused comments and simplified logic in `presence-model.ts`.
- Refactored color handling and label color calculation.
- Cleaned up tests in `presence.test.tsx` to remove outdated comments.
- Updated `presence.tsx` to enhance readability and maintainability.
- Consolidated rich text field handling in `rich-text-field.tsx` and related files.
- Improved overall code clarity by removing unnecessary comments and restructuring functions.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f1f71dec50

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +147 to +148
onCommitted(() => {
void reloadDrafts();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh the persisted version after peer commits

When another collaborator presses Save, this handler only reloads the draft rows; it never reloads the persisted row or translations. The form therefore retains its old expectedVersion, so that collaborator's next Save is rejected with CONTENT_VERSION_CONFLICT even though the live-editing session already applied the peer's changes. Reload the committed record and translation versions when processing this event.

Useful? React with 👍 / 👎.

Comment on lines +643 to +647
await resetContentLiveRecord(
c,
{ contentTypeId: definition.id, itemId: id },
"restored",
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope revision resets to the restored translation

Restoring one locale calls the room-wide reset, which deletes every draft, lock, and collaborative document for the record and tells all editors to reset. If another editor has unsaved work in the shared fields or a different locale, restoring this translation irreversibly discards that unrelated work; the reset must be scoped to target rather than the entire record.

Useful? React with 👍 / 👎.

Comment on lines +141 to +145
for (const event of draftEvents(drafts)) {
if (
seenDraftsRef.current.get(draftKey(event.locale)) !== event.updatedAt
) {
emitDraft(event);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detect draft removal in the polling fallback

When WebSockets are unavailable and another editor saves, the server deletes the committed draft row. This loop only emits drafts present in the latest response, so an empty response never clears the locale recorded in seenDraftsRef or notifies onCommitted; the polling editor can consequently keep displaying the obsolete working copy indefinitely. Compare previous locale keys with the response and reload when one disappears.

Useful? React with 👍 / 👎.

field.value,
locale,
);
if (converted) autosave.queue(name, locale, converted.value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove queued autosaves when a field is cleared

For values that intentionally convert to undefined, notably a cleared localized slug that should be derived from its source, this branch leaves any previously queued value untouched. If the user types a slug and clears it before the 1.5-second timer fires, the earlier slug is still autosaved and reappears after reopening the form; clearing must cancel the pending entry or be represented explicitly in the draft.

Useful? React with 👍 / 👎.

@aXenDeveloper
aXenDeveloper merged commit 02e5a4c into canary Oct 10, 2026
4 checks passed
@aXenDeveloper
aXenDeveloper deleted the claude/content-live-editing branch October 10, 2026 19:38
});

const live = await cleanupStaleContentDrafts(c);
const documents = await cleanupStaleContentDocuments(c);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/server-sequential-independent-await (warning)

This awaited initializer does not read the previous result. If the operations are independent asynchronous work, they may overlap; await syntax alone does not establish a speedup.

Fix → Consider Promise.all([...]) only for independent asynchronous work. Shared queues or synchronous work may not benefit. Preserve resource limits, transaction ordering, and failure/cancellation semantics.

Docs

}

const locale = localeOf(draft.language);
const record = await entry.model.service(c).findRowById(draft.itemId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/async-await-in-loop (warning)

This for…of loop waits before starting the next iteration. If iterations perform independent asynchronous work, consider bounded concurrency; await syntax alone does not establish a speedup.

Fix → Consider concurrent calls only for independent asynchronous work. Shared queues or synchronous work may not benefit. Preserve resource limits, transaction ordering, and failure/cancellation semantics; observe all callback promises.

Docs

) =>
(snapshot: ContentLivePresenceSnapshot) => {
target()?.server.onInstanceMessage({
data: JSON.parse(JSON.stringify(snapshot)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-json-parse-stringify-clone (warning)

JSON.parse(JSON.stringify(x)) deep-clones by re-serializing: it is slow on large objects and silently drops undefined, functions, Date/Map/Set, and cyclic references. Use structuredClone(x).

Fix → Replace JSON.parse(JSON.stringify(value)) with structuredClone(value). It is faster and preserves Dates, Maps, Sets, and cyclic references.

Docs

userRef.current = user;
}, [user]);

const [seedValue] = React.useState(value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-derived-useState (warning)

Your users see a stale value when prop "value" changes because useState copies it once.

Fix → Compute the value inline so prop changes do not leave useState holding a stale copy.

Docs

React.useEffect(() => {
if (!editor || !binding) return;

emit(editor);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-pass-live-state-to-parent (warning)

Pushing state up to a parent from a useEffect costs your users an extra render.

Fix → Move the state up to the parent (or return it from the hook), instead of handing it back up through a prop callback in a useEffect. See https://react.dev/learn/you-might-not-need-an-effect#notifying-parent-components-about-state-changes

Docs


const { onDraft } = session;
// eslint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent -- a subscription to the session's draft events, nothing handed upwards
React.useEffect(() => onDraft(applyDraft), [onDraft]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-pass-live-state-to-parent (warning)

Pushing state up to a parent from a useEffect costs your users an extra render.

Fix → Move the state up to the parent (or return it from the hook), instead of handing it back up through a prop callback in a useEffect. See https://react.dev/learn/you-might-not-need-an-effect#notifying-parent-components-about-state-changes

Docs

const locale = useLocale();

return (members: readonly ContentLiveMember[]): string =>
new Intl.ListFormat(locale, { type: "conjunction" }).format(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/js-hoist-intl (warning)

This is slow because new Intl.ListFormat() rebuilds on every call inside a function, so move it to the top of the file, or wrap it in useMemo

Fix → Move new Intl.NumberFormat(...) to the top of the file or wrap it in useMemo. Building one is slow, so don't redo it on every call

Docs


const pendingRef = React.useRef(new Map<string, PendingValue>());
const timerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined);
const chainRef = React.useRef<Promise<void>>(Promise.resolve());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/rerender-lazy-ref-init (warning)

useRef(resolve()) rebuilds this value on every render & throws it away.

Fix → Initialize the ref lazily so expensive values are not rebuilt and discarded on every render.

Docs

let savedAt: null | string = null;

for (const [locale, values] of byLocale) {
const result = await transport.saveDraft(contentTypeId, itemId, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/async-await-in-loop (warning)

This for…of loop waits before starting the next iteration. If iterations perform independent asynchronous work, consider bounded concurrency; await syntax alone does not establish a speedup.

Fix → Consider concurrent calls only for independent asynchronous work. Shared queues or synchronous work may not benefit. Preserve resource limits, transaction ordering, and failure/cancellation semantics; observe all callback promises.

Docs

};

return (
<ContentLiveContext value={value}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/context-provider-value-from-unmemoized-local-literal (warning)

Every consumer of this context redraws on each render because its value is a fresh object/array/function rebuilt each render — memoize it in component scope (extract mapped providers into a child component first), or move it outside the component.

Fix → Memoize the context value in component scope so consumers do not redraw every render. For mapped providers, extract each item into a child component and memoize there.

Docs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review


P1 Badge Persist queued autosaves before page unload

When an editor closes or reloads the tab within the 1.5-second debounce window, this cleanup is the only attempt to persist the pending value. React cleanup is not guaranteed to finish during document unload, and the fire-and-forget request does not use a keepalive request or beacon, so the browser can terminate it and lose the latest edit. Handle pagehide with an unload-safe transport or warn while the queue is dirty.


const onBlurCapture = (event: React.FocusEvent<HTMLFieldSetElement>) => {
const next = event.relatedTarget;
if (next instanceof Node && event.currentTarget.contains(next)) return;

P2 Badge Keep locks while using portaled field controls

When focus moves from the field into a portaled control, such as the user picker or date/combobox popup, currentTarget.contains(next) is false because the popup is rendered outside the fieldset. After 1.5 seconds this releases the lock while the user is still interacting; another editor can acquire it, and the portaled control remains outside the newly disabled fieldset and can still change the form without autosaving. Treat the field's open portal as part of its focus boundary before releasing the lock.


if (committed.length > 0) {
broadcastContentLive(room, { room, type: "committed" });

P2 Badge Broadcast rich-text-only commits

When a collaborator saves only a co-edited rich-text field, no core_content_drafts row exists because rich text bypasses plain-field autosave, so committed.length is zero and this suppresses the only commit notification. Other active editors therefore retain the previous expectedVersion and their next Save conflicts even though they participated in the same live document. Broadcast the successful update independently of whether a plain draft row happened to be deleted.

AGENTS.md reference: AGENTS.md:L61-L61

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💡 Feature A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants