Repository navigation
Blocks V3 inline text editing: characters typed while the preview is being fetched are lost #576
Description
Environment
- Secure Custom Fields 6.9.7, and
trunkat 31ead85 (the Blocks V3 sources are the same) - WordPress 7.1.3 (Docker, official
wordpressimage), React 18.3.1 (the copy WordPress ships) - Chromium (Playwright 1.63). Only Chromium was tested.
Steps to reproduce
-
Register a Blocks V3 block whose template puts
acf_inline_text_editing_attrs()on an element. Minimal block (the field group is onetextfield namedtitle, 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>
-
Add the block to a post and give the title a value, e.g.
Accessible. -
Click into the title in the editor canvas, press End and type
A. -
Wait until the block preview request (
acf/ajax/fetch-block) starts, about 450 ms later, and typeBbefore 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
Band 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.
Bwas lost for good in 13 of the 16. In the other 3, a request forAccessibleABstill followed, and the canvas showedAccessibleABagain (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) andAccessibleABwas 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:
- The keystroke changes the text. The
MutationObserver(lines 1025 to 1063) callsupdateFieldValueFromContentEditable()(line 984), which serializes the form intotheSerializedAcfData(line 1015). - After the 200 ms debounce (line 622), the form data differs from
data, sosetAttributes()writes it (line 651). - That re-runs the effect. 200 ms later the form data equals
data, sofetchBlockData()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
theSerializedAcfDatachanges, before the debounce), not when the next request starts. - In
.done(), compare thedatathe 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
MutationObserverrecords 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