Repository navigation
Blocks: undo and redo don't work for SCF block fields, and get stuck at SCF blocks #577
Description
Environment
- Secure Custom Fields 6.9.7, and
trunkat 31ead85 (the Blocks V3 and V2 sources are the same) - WordPress 7.1.3 (Docker, official
wordpressimage) - Chromium, Firefox and WebKit (Playwright 1.63), Linux, so the shortcuts are Ctrl+Z and Ctrl+Shift+Z
Steps to reproduce
-
Register a Blocks V3 block. Minimal block (the field group has a
textfieldtitle, atextareafieldtextand aurlfieldlink, location "Block is equal to acf/undo-probe-v3"):block.json{ "apiVersion": 3, "name": "acf/undo-probe-v3", "title": "Undo probe v3", "category": "text", "icon": "undo", "acf": { "blockVersion": 3, "mode": "preview", "renderTemplate": "render.php" } }render.php<?php $t = (string) ( get_field( 'title' ) ?? '' ); $x = (string) ( get_field( 'text' ) ?? '' ); $l = (string) ( get_field( 'link' ) ?? '' ); $inline = static fn( string $f ): string => ( $is_preview && function_exists( 'acf_inline_text_editing_attrs' ) ) ? acf_inline_text_editing_attrs( $f ) : ''; ?> <div <?php echo get_block_wrapper_attributes( array( 'class' => 'undo-probe' ) ); ?>> <h3 class="undo-probe__title" <?php echo $inline( 'title' ); ?>><?php echo esc_html( $t ); ?></h3> <p class="undo-probe__text" <?php echo $inline( 'text' ); ?>><?php echo esc_html( $x ); ?></p> <p class="undo-probe__link"><?php echo esc_html( $l ); ?></p> </div>
The V2 variant (
acf/undo-probe-v2) is the same block with"blockVersion": 2. -
Add a paragraph
Paraand the block, with the titleHello, to a post. -
Select the block, click into the Title field in the block sidebar, press End and type
XYZ. Wait a second. -
Press Ctrl+Z.
-
Press Ctrl+Shift+Z.
Expected
Ctrl+Z brings the title back to Hello in the field, in the preview and in the block's data. Ctrl+Shift+Z brings back HelloXYZ. This is what a core paragraph does: in the same post, typing xyz in the paragraph, Ctrl+Z and Ctrl+Shift+Z undo and redo it.
Actual
- Ctrl+Z reverts the block's
dataattribute toHello, but the sidebar field and the preview keep showingHelloXYZ. - About 220 ms later SCF writes
HelloXYZback intodata. This is a new change, so the editor clears the redo stack. - Ctrl+Shift+Z then does nothing. Pressing Ctrl+Z again repeats the same cycle, so the edit can't be undone at all.
Our script reads the sidebar field, the preview and getBlockAttributes( clientId ).data right after each key press and 1.9 s later:
typed form.title=HelloXYZ data={"field_up_v3_title":"HelloXYZ",…} preview=HelloXYZ hasRedo=false
undo +0ms form.title=HelloXYZ data={"title":"Hello",…} preview=HelloXYZ hasRedo=true
undo +1.9s form.title=HelloXYZ data={"field_up_v3_title":"HelloXYZ",…} preview=HelloXYZ hasRedo=false
redo +1.9s form.title=HelloXYZ data={"field_up_v3_title":"HelloXYZ",…} preview=HelloXYZ hasRedo=false
Because SCF writes its form back after every undo that reaches its block, the undo history gets stuck at that block. Edits made before it can no longer be undone, and edits undone before it can no longer be redone. Merely selecting the block is enough to cause this.
| Case | What happens on Ctrl+Z / Ctrl+Shift+Z |
|---|---|
Core paragraph (baseline): type xyz |
Undo and redo work. |
| V3, Title field in the block sidebar | data reverts, the field and the preview don't. About 220 ms later the old form is written back and the redo stack is gone. Same in Firefox and WebKit. |
| V3, Title field in the "Edit block" modal | Same as the sidebar. |
| V3, inline editing of the title in the canvas | Same: data reverts, the canvas text doesn't, and the text is written back. (The typed text ends up as QQHelloXYZ in our log because of the unrelated caret jump in #574.) |
| V3, the editor's Undo button in the header instead of Ctrl+Z | The first click changes nothing visible. Leaving the field fired change, and SCF's _acf_changed post meta edit became its own undo level, which this click undoes. |
V3: type in the block's Title field, then type para in the paragraph, then Ctrl+Z three times |
1st: para is undone. 2nd: nothing visible (the _acf_changed level). 3rd: the Title reverts and is written back, and the redo stack, including para, is gone. |
V3: type para in the paragraph, then XYZ in the Title field, then Ctrl+Z three times |
Every Ctrl+Z hits the SCF block and is written back. The paragraph's para can't be undone. |
V3: type one in the paragraph, select the SCF block without editing it, type two, then Ctrl+Z four times |
1st: two is undone. 2nd to 4th: each one reverts the write SCF made when the block was selected, which SCF then writes again. one can't be undone and two can't be redone. |
V3, no keyboard: updateBlockAttributes( clientId, { data: { title: 'External', … } } ) |
About 220 ms later SCF writes the form's values back over it. |
| V2, Title field in the block sidebar | data reverts and the preview briefly shows Hello. About 340 ms later the old form is written back and the redo stack is gone. |
V2, no keyboard: updateBlockAttributes() as above |
data and the preview keep the new value, but the sidebar form still shows the old one. The next keystroke in the form writes all of it back. |
Cause
Undo itself reaches SCF. Gutenberg's global undo shortcut handles Ctrl+Z in the sidebar and modal fields too (keydown is defaultPrevented in all three browsers). So the browser's own undo inside the input never runs, and the editor's undo reverts the block's data attribute. That is the intended path for fields synced to block attributes (WordPress/gutenberg#80768: "Everything else is unchanged, including plugin fields synced to block attributes."). SCF just never follows data back into its form.
Blocks V3 (assets/src/js/pro/blocks-v3/components/):
- The form HTML is set once.
BlockFormcopiesblockFormHtmlinto state on mount (block-form.jsline 39) and only takes a new one when it has none (lines 63 to 67). Nothing re-renders the form, or sets its field values, fromdata. - Any difference between the form and
datais taken to be a form edit. The debounced effect inblock-edit.js(lines 617 to 660) re-runs when the attributes change. It compares the form's serialization withJSON.stringify( data )(lines 634 to 644) and, when they differ, writes the form intodata(lines 647 to 651). After an undo,datais the older value and the form still holds the newer one. So the form is written back 200 ms later as a new change, which cancels the undo and clears the redo stack. - The comparison is a string comparison of two different shapes. Stored
datais keyed by field name, and the form serializes by field key. So they never match until SCF has rewrittendatawith keys. That happens as soon as the form mounts, which means selecting the block (withhasAcfError, lines 581 to 585). This is Blocks V3: opening a post marks it as changed, though nothing was edited #556, and Blocks V3: stop marking the post as changed on load #571 changes this same comparison. For undo it means each of those no-op writes is an undo level. Undoing one brings back the name-keyeddata, which again looks different, so it is written again. That is the select-only row of the table. _acf_changedadds invisible undo levels. On everychangeof an.acf-field,fieldsEventManager.onChange()inassets/src/js/_acf-fields.jscallseditPost( { meta: { _acf_changed: 1 } } )(line 406) without{ undoIgnore: true }. That edit isn't something a user would undo, but the header's Undo button and Ctrl+Z have to step over it.
Blocks V2 (assets/src/js/pro/_acf-blocks.js): the form is also fetched once and never re-rendered from data. componentDidAppend() listens to change keyup on the form (line 1637) and, 300 ms later, writes the serialized form into data (serializeData(), line 1612, setAttributes() at line 1618). The keyup of Ctrl+Z itself appears to trigger that write. The form overwrites the undone data about 340 ms after the key press, and a data change made without the keyboard is not written back (last row of the table).
Related
- Blocks V3: opening a post marks it as changed, though nothing was edited #556 (opening a post marks it as changed) and Blocks V3: stop marking the post as changed on load #571, which rewrites the comparison in point 3 (
hasAcfErroronly when it changes, and comparing the form with its own first state). Judging from its diff (not tested here), it removes the no-op write on selection. It doesn't remove the write-back after an undo, because the form still never followsdata. - Blocks V3 inline text editing: the caret jumps to the start of the field after the preview re-renders #574 / Blocks V3: keep the caret in inline text editing when the preview re-renders #575 and Blocks V3 inline text editing: characters typed while the preview is being fetched are lost #576 (characters typed while the preview is fetched are lost): the same
block-edit.jscode path. - Editor: leave undo to the browser in fields that handle their own undo gutenberg#80768 added a private
useNativeUndoopt-in for fields that should keep the browser's own undo. Undo / redo (keyboard shortcuts) affecting the whole document rather than the focussed TextControl gutenberg#18755 is the umbrella issue. There, on 2026-10-03, t-hamano proposed a public API so extenders "(e.g. ACF)" can opt their fields in, and on 2026-10-22 suggested reverting #80768 for 7.2 instead. Make undo and redo from the browser menu use the editor history gutenberg#84868 routes the browser menu's Undo and Redo to the editor history too. These change how Ctrl+Z reaches a field, but none of them would make SCF followdata. - Until then, a site can let the browser undo typing inside SCF's inputs by stopping Ctrl+Z from reaching Gutenberg's handler. That only undoes text within the focused field, and it doesn't fix the stuck history. It's a workaround, not a fix.
Possible fix
We have a prototype for Blocks V3, built against trunk, which changes the effect in block-edit.js:
- Tell form edits from outside changes. Keep the last form serialization in a ref. If the form hasn't changed since the last run but
datano longer matches it,datawas changed outside the form (undo, redo, another script). In that case, don't write the form back. Fetch the form and preview for the currentdata(skipping the preload cache), re-mount the form with them (akeyonBlockForm), and put the focus back into the field that had it. - Compare by value. Map
data's field names to keys (fromblockFieldInfo) before comparing, so a form that only renames the keys doesn't write. undoIgnore. Pass{ undoIgnore: true }with the_acf_changededitPost(). The prototype wrapseditPostfrom outside for this.
Results with the prototype, in Chromium, in the same scenarios:
- Sidebar and modal: Ctrl+Z reverts the field, the preview and
data, and Ctrl+Shift+Z restores them. Typing after a redo works. - Inline editing: the first Ctrl+Z works and keeps redo.
- SCF block then paragraph: the history no longer stops at the invisible level, and the SCF edit is undone with redo kept.
- Paragraph then SCF block: Ctrl+Z now gets past the SCF block to the paragraph.
- Selecting the block no longer writes
data.
Known limits of the prototype:
- When undo goes back past the first write SCF made to a block (to
datastill keyed by field name), something still writesdatawith keys and clears redo. This shows up as the last step in the inline, mixed, select-only and trap runs. We haven't found which write does it. - Every undo or redo that reaches an SCF block costs a
fetch-blockround trip and re-mounts the form. Focus is restored by blurring and re-focusing the field with the caret at the end, so the field's position within the text is lost. - Only top-level fields are mapped from name to key (no repeater, group or flexible content), and only
text,textareaandurlfields were tried. - Blocks V2 isn't covered.
If this direction is welcome, we can open a PR for Blocks V3 built on #571, with end-to-end tests for these scenarios (undo and redo in the sidebar, the modal and inline editing, undo past an SCF block, and no undo level from selecting a block).
Activity