Repository navigation
feat(content): ✨ live editing, autosave and presence for the Content Engine - #852
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
React Doctor found 16 new issues in 9 files · 16 warnings · score 82 / 100 (Needs work) · 1 fixed · vs 16 warnings
Reviewed by React Doctor for commit |
There was a problem hiding this comment.
💡 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".
| const batch = [...pendingRef.current.values()]; | ||
| pendingRef.current.clear(); |
There was a problem hiding this comment.
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 👍 / 👎.
| return upsertLangValue( | ||
| current as MultiLangValue | undefined, | ||
| locale, | ||
| typeof value === "string" ? value : "", | ||
| ); |
There was a problem hiding this comment.
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 👍 / 👎.
| /** The largest Yjs or awareness update one message may carry, decoded. */ | ||
| export const CONTENT_LIVE_MAX_UPDATE_BYTES = 2 * 1024 * 1024; |
There was a problem hiding this comment.
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 👍 / 👎.
…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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb
…documents 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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb
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
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
…uide Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb
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>
8416934 to
6042b31
Compare
There was a problem hiding this comment.
💡 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".
| 'type', 'paragraph', | ||
| 'content', jsonb_build_array(jsonb_build_object( | ||
| 'type', 'text', | ||
| 'text', btrim(regexp_replace(regexp_replace("content", '<[^>]*>', ' ', 'g'), '\s+', ' ', 'g')) | ||
| )) |
There was a problem hiding this comment.
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 👍 / 👎.
| const overlaid: TData = drafts.shared | ||
| ? { | ||
| ...data, | ||
| ...drafts.shared.values, | ||
| labels: { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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 👍 / 👎.
| <fieldset | ||
| aria-describedby={describedBy} | ||
| className="min-w-0" | ||
| disabled={lockedByOther} |
There was a problem hiding this comment.
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 👍 / 👎.
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>
…s for collaborative editing
There was a problem hiding this comment.
💡 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)) { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 👍 / 👎.
| await runAll(resetListeners, { c, reason, room }); | ||
| broadcastContentLive(room, { reason, room, type: "reset" }); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
💡 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".
| 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); |
There was a problem hiding this comment.
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 👍 / 👎.
| const rows = await db | ||
| .delete(locks) | ||
| .where(and(lockKey(key), eq(locks.userId, userId))) | ||
| .returning({ id: locks.id }); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
💡 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".
| 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; |
There was a problem hiding this comment.
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 👍 / 👎.
| const removed = await c | ||
| .get("db") | ||
| .delete(table) | ||
| .where(lt(table.updatedAt, before)) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
💡 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".
| onCommitted(() => { | ||
| void reloadDrafts(); |
There was a problem hiding this comment.
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 👍 / 👎.
| await resetContentLiveRecord( | ||
| c, | ||
| { contentTypeId: definition.id, itemId: id }, | ||
| "restored", | ||
| ); |
There was a problem hiding this comment.
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 👍 / 👎.
| for (const event of draftEvents(drafts)) { | ||
| if ( | ||
| seenDraftsRef.current.get(draftKey(event.locale)) !== event.updatedAt | ||
| ) { | ||
| emitDraft(event); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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 👍 / 👎.
| }); | ||
|
|
||
| const live = await cleanupStaleContentDrafts(c); | ||
| const documents = await cleanupStaleContentDocuments(c); |
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| const locale = localeOf(draft.language); | ||
| const record = await entry.model.service(c).findRowById(draft.itemId); |
There was a problem hiding this comment.
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.
| ) => | ||
| (snapshot: ContentLivePresenceSnapshot) => { | ||
| target()?.server.onInstanceMessage({ | ||
| data: JSON.parse(JSON.stringify(snapshot)), |
There was a problem hiding this comment.
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.
| userRef.current = user; | ||
| }, [user]); | ||
|
|
||
| const [seedValue] = React.useState(value); |
There was a problem hiding this comment.
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.
| React.useEffect(() => { | ||
| if (!editor || !binding) return; | ||
|
|
||
| emit(editor); |
There was a problem hiding this comment.
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
|
|
||
| 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]); |
There was a problem hiding this comment.
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
| const locale = useLocale(); | ||
|
|
||
| return (members: readonly ContentLiveMember[]): string => | ||
| new Intl.ListFormat(locale, { type: "conjunction" }).format( |
There was a problem hiding this comment.
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
|
|
||
| const pendingRef = React.useRef(new Map<string, PendingValue>()); | ||
| const timerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined); | ||
| const chainRef = React.useRef<Promise<void>>(Promise.resolve()); |
There was a problem hiding this comment.
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.
| let savedAt: null | string = null; | ||
|
|
||
| for (const [locale, values] of byLocale) { | ||
| const result = await transport.saveDraft(contentTypeId, itemId, { |
There was a problem hiding this comment.
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.
| }; | ||
|
|
||
| return ( | ||
| <ContentLiveContext value={value}> |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
💡 Codex Review
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.
vitnode/packages/vitnode/src/views/admin/views/content/live/field.tsx
Lines 249 to 251 in c84bbaa
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.
vitnode/packages/vitnode/src/content/server/live-store.ts
Lines 303 to 304 in c84bbaa
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".
Improving Documentation
dev/content-engine/live-editing.mdx(with real AdminCP screenshots) anddev/content-engine/rich-text.mdx.fields,reference,localization,publication-and-editorialdev/websocket(new Rooms section)dev/events/built-in-eventsguides/blogcanary, 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.core_content_drafts(one draft per record and language, shared by the team), never in the published record./api/wssocket, and each AdminCP message checks the AdminCP session andcan_edit.core_content_field_locks, renewed while focused.core_content_documents.field.richText().jsonb.RichTextContent, a pure renderer. A boundaries test proves it never pulls in Tiptap, ProseMirror or Yjs.contentis nowfield.richText.blog_posts_translations.contentfrom text tojsonb. Existing HTML keeps its words as one paragraph instead of failing the cast.yjs,y-protocols,@tiptap/extension-collaboration,@tiptap/extension-collaboration-caret,@tiptap/y-tiptap.Why?
Editors lost work and hit
CONTENT_VERSION_CONFLICTwhen 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
tscis 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.tsc, 22 tests and eslint; the example plugin passestsc. ESLint is clean on all 102 changed core files.apps/apiandapps/weblocally with two signed-in admins in Playwright.asynceditor slot made the collaborative editor reopen its document in a loop. Fixed, with a regression test.Known limitations
These are listed in the plan:
🤖 Generated with Claude Code
https://claude.ai/code/session_01FenWQLWGLpX7wiqrShbotb
Generated by Claude Code