Blocks Editor#
The BlocksEditor is a rich-text editor built on Slate.js, used throughout the Strapi content-manager as a field input type. Its core concerns are: (1) keeping the Slate document tree schema-valid to prevent runtime crashes, and (2) ensuring the debounced editor state is fully flushed into the React form before Save/Publish reads values.
Key source files:
| File | Role |
|---|---|
BlocksEditor.tsx | Root component; owns the <Slate> provider, debounce logic, and flushPendingFormSync |
BlocksContent.tsx | Renders the <Editable>; calls flushPendingFormSync on onBlur |
DocumentActions.tsx | UpdateAction / PublishAction — blur + microtask yield before validate |
Slate Document Sanitization#
Problem: Slate's internal path-finding crashes with "Cannot find a descendant at path" when the initial document value contains malformed nodes — for example, a node with both text and children properties, or a node missing a type field. This caused hard editor crashes when loading persisted content with a hybrid or inconsistent structure.
Fix (PR #25987): A sanitizeBlocks utility is applied to the initialValue before it is passed to <Slate>. The sanitizer recursively enforces the Slate schema contract:
- A node with a
textproperty → treated as a text node; anychildrenkey is stripped. - A node with a
childrenarray → treated as an element node; anytextkey is stripped and children are sanitized recursively. - Nodes that are
null, non-objects, or otherwise invalid → replaced with{ type: 'text', text: '' }. - Non-array top-level input → returns
[].
This is a purely defensive, read-time transform: it does not modify stored data, only what gets handed to Slate on mount.
The <Slate> component in BlocksEditor is initialized with the value directly; the sanitization step runs as a pre-pass before this render.
Debounced Form Sync and the flushPendingFormSync Hook#
Slate state changes are debounced by 300 ms before being written to the Strapi form via onChange — this is intentional to avoid expensive form updates on every keystroke .
The problem: if the user clicks Save/Publish during that 300 ms window, the form reads stale values and the latest Blocks content is silently dropped.
flushPendingFormSync resolves this:
- Cancels the pending debounce timer.
- Calls
flushSync(() => onChange(...))to synchronously commit the latest Slate state to the form before any other event can read form values.
It is provided via BlocksEditorProvider context and consumed by BlocksContent, which wires it to onBlur on the <Editable> element .
Blur-and-Yield Pattern in Save/Publish Actions#
Even with flushPendingFormSync wired to onBlur, a race exists: the Save button click does not guarantee the Blocks field loses focus and flushes before validate() / getValues() are called.
UpdateAction.handleUpdate addresses this with an explicit two-step sequence :
- Blur the active element —
(globalThis.document?.activeElement as HTMLElement)?.blur()— triggeringBlocksContent'sonBlurhandler and callingflushPendingFormSync. - Yield two microtasks —
await Promise.resolve(); await Promise.resolve()— giving React time to batch and commit theflushSync-triggered state updates beforevalidatereads values.
PublishAction.performPublish applies the same idea with a single await Promise.resolve() yield before validation . The extra tick in UpdateAction is intentional (comment: "two ticks vs one gives a bit more room after focus/blur").
Form State Synchronization in Relation Modals#
When a Blocks field exists inside a Relation Modal (e.g., editing a related entry inline), the same flush requirements apply, but there is an additional concern: the modal must know whether the inner <Form> is dirty so it can gate navigation with a discard-changes dialog.
RelationModalBody lifts the form's modified flag into the modal reducer via a useEffect :
useForm('FormWatcher', state => state.modified)
→ hasUnsavedChanges = modified && !isSubmitting
→ dispatch SET_HAS_UNSAVED_CHANGES
When hasUnsavedChanges is true, any modal navigation action (GO_BACK, CLOSE_MODAL, GO_FULL_PAGE, GO_TO_RELATION) sets confirmDialogIntent instead of executing, triggering a discard-changes confirmation. On clean close, handleCloseModal calls triggerRefetchDocument to refresh the parent document from the API .
Known issue (PR #26535): Closing the relation modal after saving could discard unsaved edits on the parent form because
parentDataToUpdatewas sourced from server-fetched initial values rather than the current in-memory form values. The fix (in progress) capturesparentFormValuesat modal-open time and uses them as the merge base.