Skip to content

Blocks V3 inline text editing: characters typed while the preview is being fetched are lost #576

Open

Description

@wojtekpiskorz

Environment

  • Secure Custom Fields 6.9.7, and trunk at 31ead85 (the Blocks V3 sources are the same)
  • WordPress 7.1.3 (Docker, official wordpress image), React 18.3.1 (the copy WordPress ships)
  • Chromium (Playwright 1.63). Only Chromium was tested.

Steps to reproduce

  1. Register a Blocks V3 block whose template puts acf_inline_text_editing_attrs() on an element. Minimal block (the field group is one text field named title, location "Block is equal to acf/lost-chars"). It's the block from Blocks V3 inline text editing: the caret jumps to the start of the field after the preview re-renders #574 under another name:

    block.json

    {
    	"apiVersion": 3,
    	"name": "acf/lost-chars",
    	"title": "Lost characters repro",
    	"category": "text",
    	"acf": { "blockVersion": 3, "mode": "preview", "renderTemplate": "render.php" }
    }

    render.php

    <h2 <?php echo acf_inline_text_editing_attrs( 'title' ); ?>><?php echo esc_html( get_field( 'title' ) ); ?></h2>
  2. Add the block to a post and give the title a value, e.g. Accessible.

  3. Click into the title in the editor canvas, press End and type A.

  4. Wait until the block preview request (acf/ajax/fetch-block) starts, about 450 ms later, and type B before its response arrives.

On a fast local site the response often arrives before you can type again. A slower host makes this easy to hit. To make it reproducible, our script lets the server answer the fetch-block request carrying AccessibleA but holds the response back from the page with Playwright's page.route(). It types B, then releases the response.

Expected

The title reads AccessibleAB, in the canvas and in the block's data.

Actual

The title reads AccessibleA. The B disappears from the canvas, from the field in the sidebar and from the block's data. Here is the script's output. Each line shows the canvas text, the sidebar input's value and getBlockAttributes( clientId ).data, and the [write] line comes from a wrapped Node.prototype.nodeValue setter in the canvas:

 1688 ms before                             canvas="Accessible" form="Accessible" data={"field_lost_chars_title":"Accessible"}
 1688 ms type A
 2141 ms request  fetch-block title="AccessibleA"
 2265 ms response for "AccessibleA" is held canvas="AccessibleA" form="AccessibleA" data={"field_lost_chars_title":"AccessibleA"}
 2265 ms type B
 2299 ms typed B                            canvas="AccessibleAB" form="AccessibleAB" data={"field_lost_chars_title":"AccessibleA"}
 2313 ms releasing the held response
 2331 ms response fetch-block title="AccessibleA"
 2345 ms [write] text node "AccessibleAB" -> "AccessibleA"
 2566 ms request  fetch-block title="AccessibleA"
 2655 ms response fetch-block title="AccessibleA"
 5326 ms settled                            canvas="AccessibleA" form="AccessibleA" data={"field_lost_chars_title":"AccessibleA"}

The stack of the [write] is React's commit (react-dom.min.js?ver=18.3.1.1).

A held response isn't needed to lose characters. With 300 ms of extra latency on every fetch-block response and a 600 ms pause between the two keystrokes, AccessibleAB also ends as AccessibleA.

How often this happens in our runs:

  • The response for the older text landed after B and before the next request started in 16 runs. Those runs released it 0 to 300 ms after the keystroke, or added latency.
  • In every traced run of these, the canvas text was overwritten with the older text.
  • B was lost for good in 13 of the 16. In the other 3, a request for AccessibleAB still followed, and the canvas showed AccessibleAB again (in the two traced ones, 300 and 500 ms after the overwrite).
  • When the response was released only after the next request had started (about 450 to 500 ms after the keystroke), the older request had been aborted (net::ERR_ABORTED) and AccessibleAB was kept.

Cause

fetchBlockData() does abort the previous request (assets/src/js/pro/blocks-v3/components/block-edit.js lines 302 to 305). But it only does so when the next request starts, and that start comes two debounces after the keystroke:

  1. The keystroke changes the text. The MutationObserver (lines 1025 to 1063) calls updateFieldValueFromContentEditable() (line 984), which serializes the form into theSerializedAcfData (line 1015).
  2. After the 200 ms debounce (line 622), the form data differs from data, so setAttributes() writes it (line 651).
  3. That re-runs the effect. 200 ms later the form data equals data, so fetchBlockData() runs (lines 634 to 644) and aborts the previous request.

Between the keystroke and step 3, about 400 to 450 ms in our runs, the request for the previous text is still live. When its response lands in that window, .done() (line 355) calls setBlockPreviewHtml() (line 372) with HTML rendered from the old text. React writes AccessibleA into the text node, because the text it rendered last is different (Accessible, from the preview before). This is the [write] above.

The MutationObserver can't tell that write from typing. It reads the element's innerHTML and copies AccessibleA into the form input. From there it goes to data and is fetched again (the second AccessibleA request above). Nothing compares the response with what the user has typed since the request was sent. We didn't find out why the newer text sometimes comes back, as in 3 of our 16 runs.

The cause is in the same code path as #574 (the React text write into an element the browser is editing), but it is a separate bug. It happens on trunk and with the fix from #575, which only keeps the caret. With the #575 build, the same script ends with canvas="AccessibleA" and data={"field_lost_chars_title":"AccessibleA"} too.

Possible fix

A request counter or AbortController in fetchBlockData() alone doesn't close the window, because the response that lands is still the newest request. Some directions that do:

  • Abort, or mark as stale, the request in flight as soon as the form data changes (when theSerializedAcfData changes, before the debounce), not when the next request starts.
  • In .done(), compare the data the request was sent with against the current form data (read through a ref, since the closure holds old state) and drop the preview when they differ. A newer request is always on its way in that case.
  • Don't let the preview's own render write back into the field: ignore MutationObserver records produced while a new preview is committed, or skip writing text into a focused [data-acf-inline-contenteditable] element whose current text differs from the text the response was rendered from.

The first two keep the preview from going backwards. The third keeps a stale preview, if one still renders, from losing typed text.

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