Skip to content

Blocks: undo and redo don't work for SCF block fields, and get stuck at SCF blocks #577

Open

Description

@wojtekpiskorz

Environment

  • Secure Custom Fields 6.9.7, and trunk at 31ead85 (the Blocks V3 and V2 sources are the same)
  • WordPress 7.1.3 (Docker, official wordpress image)
  • Chromium, Firefox and WebKit (Playwright 1.63), Linux, so the shortcuts are Ctrl+Z and Ctrl+Shift+Z

Steps to reproduce

  1. Register a Blocks V3 block. Minimal block (the field group has a text field title, a textarea field text and a url field link, 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.

  2. Add a paragraph Para and the block, with the title Hello, to a post.

  3. Select the block, click into the Title field in the block sidebar, press End and type XYZ. Wait a second.

  4. Press Ctrl+Z.

  5. 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 data attribute to Hello, but the sidebar field and the preview keep showing HelloXYZ.
  • About 220 ms later SCF writes HelloXYZ back into data. 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/):

  1. The form HTML is set once. BlockForm copies blockFormHtml into state on mount (block-form.js line 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, from data.
  2. Any difference between the form and data is taken to be a form edit. The debounced effect in block-edit.js (lines 617 to 660) re-runs when the attributes change. It compares the form's serialization with JSON.stringify( data ) (lines 634 to 644) and, when they differ, writes the form into data (lines 647 to 651). After an undo, data is 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.
  3. The comparison is a string comparison of two different shapes. Stored data is keyed by field name, and the form serializes by field key. So they never match until SCF has rewritten data with keys. That happens as soon as the form mounts, which means selecting the block (with hasAcfError, 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-keyed data, which again looks different, so it is written again. That is the select-only row of the table.
  4. _acf_changed adds invisible undo levels. On every change of an .acf-field, fieldsEventManager.onChange() in assets/src/js/_acf-fields.js calls editPost( { 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

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 data no longer matches it, data was changed outside the form (undo, redo, another script). In that case, don't write the form back. Fetch the form and preview for the current data (skipping the preload cache), re-mount the form with them (a key on BlockForm), and put the focus back into the field that had it.
  • Compare by value. Map data's field names to keys (from blockFieldInfo) before comparing, so a form that only renames the keys doesn't write.
  • undoIgnore. Pass { undoIgnore: true } with the _acf_changed editPost(). The prototype wraps editPost from 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 data still keyed by field name), something still writes data with 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-block round 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, textarea and url fields 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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions