Skip to content

RTC: Fix inner template multiple insertion#79021

Open
alecgeatches wants to merge 13 commits into
trunkfrom
fix/rtc-inner-template-sync2
Open

RTC: Fix inner template multiple insertion#79021
alecgeatches wants to merge 13 commits into
trunkfrom
fix/rtc-inner-template-sync2

Conversation

@alecgeatches

@alecgeatches alecgeatches commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

This is a human-written PR description.

Follow-up from #78696, which addressed the same problem by having peers track which blocks were remotely synced. This worked but involved a large amount of bookkeeping and plumbing from the sync package, through core-data, and deeply into block-editor. This PR uses a different strategy, deferring block updates on the originator to send both the outer block and inner block template at the same time.

There's a fair amount of test code inflating LOC numbers, but a more manageable +227 / -84 changed outside of test code.

What?

With RTC over a WebSocket connection, inserting a core/list in the presence of other peers causes multiple core/list-item insertions:

list-item-duplication.mov

Running on trunk with WebSockets using npm run rtc:ws:slow

This scales with the number of connected peers. If a document has 5 connected users, 5 list items will be inserted.

In this PR, we address the issue by changing the sender to sync the outer core/list block and inner template insertion as a single logical operation:

list-duplication-pr-fix.mov

Running on fix/rtc-inner-template-sync2 with WebSockets using npm run rtc:ws:slow

Why?

The primary cause is that useInnerBlockTemplateSync(), which is in charge of inserting block templates, runs asynchronously after an outer block is inserted. In the reproduction above, all peers receive the core/list outer block in one update, mount it, and then run the same inner template insertion logic independently.

There's another snag that makes detecting this behavior difficult to predict. Blocks that use inner templates like core/list only apply inner block logic after mounting Edit. There is no definitive way to know that a block will insert template blocks later because template insertion happens after block mounting.

These result in the duplication problem. User A adds a block. The block is added to the editor and post entity and synced with peers. After the block is mounted, the original user and each peer uses useInnerBlockTemplateSync() to apply a template. An asynchronous amount of time later, every peer adds a separate template to the block.

How?

In short, remove the async delay from useInnerBlockTemplateSync(), defer outer block insertion operations until the next React event loop, and combine follow-up synchronous inner block insertions into a single logical operation. A bit more about each of those:

  1. Remove the async delay from useInnerBlockTemplateSync(). As mentioned in a comment on trunk, the reason useInnerBlockTemplateSync() is queued into a microtask is a useNestedSettingsUpdate() timing dependency. Add a flushPendingNestedSettingsUpdates() instead to synchronously apply pending settings updates instead of relying on asynchronous ordering.

  2. Defer outer block insertion operations until the next React event loop. Blocks that we should defer have a __unstableShouldDeferBlockSync flag, which is activated as a heuristic for empty blocks that have a defined allowedBlocks list. The rest of the logic happens in useBlockSync(). When we detect a "should defer" block, store that change in deferredBlockSyncRef, and schedule it to fire on the start of the next React event loop via a useEffect().

  3. Combine follow-up synchronous inner block insertions into a single logical operation. In step 2, we slightly deferred the outer block insertion. In the meantime, if we detect another change from the now-synchronous inner block insertion, overwrite the deferred change with new block content which contains the inner template. Finally, the useEffect() fires on the next event loop and flushDeferredBlockSync() sends the block to core data and other users, complete with template. If there was no follow-up insertion, the block is still synced here too.

There was also a small change to legacy quote and list migrations that I'll comment on in code below.

Testing Instructions

  1. Run the built-in WebSockets server in Gutenberg via npm run rtc:ws:slow. Note we're using slow mode for a 30ms delay, which reflects real-world delays and makes race condition reproduction reliable.
  2. Ensure RTC is enabled via Settings -> Writing.
  3. Open the same post in two browser sessions.
    IMPORTANT: Use two separate browsers, not just two tabs, as queueMicrotask() seems to avoid race conditions between two tabs in the same browser.
  4. As user A, insert a core/list block via /list or the sidebar.
  5. See one list item added to the list in both browsers.
  6. As user B, add a new core/list and ensure only one inner template is added.

In trunk, steps 4 & 6 will result in two list items as per the reproduction at the top.

Also try:

RTC_WS_DELAY=300 npm run test:e2e:rtc-websocket -- specs/editor/collaboration/websocket-only/collaboration-list-template.spec.ts

to run the same steps in an automated test.

Use of AI Tools

Code written with Codex and a lot of iteration.

@alecgeatches alecgeatches self-assigned this Jun 8, 2026
@github-actions github-actions Bot added the [Package] Block editor /packages/block-editor label Jun 8, 2026
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown

Size Change: +504 B (+0.01%)

Total Size: 7.61 MB

📦 View Changed
Filename Size Change
build/scripts/block-editor/index.min.js 385 kB +383 B (+0.1%)
build/scripts/block-library/index.min.js 325 kB +121 B (+0.04%)

compressed-size-action

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown

Flaky tests detected in 9d1b414.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/28530629278
📝 Reported issues:

@github-actions github-actions Bot added the [Package] Block library /packages/block-library label Jun 9, 2026
@alecgeatches alecgeatches added [Feature] Real-time Collaboration Phase 3 of the Gutenberg roadmap around real-time collaboration [Type] Bug An existing feature does not function as intended labels Jun 9, 2026
updateBlockAttributes( clientId, newAttributes );
replaceInnerBlocks( clientId, newInnerBlocks );
if ( shouldReselectBlock ) {
selectBlock( clientId );

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code was added to useMigrateOnLoad() because style-variation.spec.js was failing after the changes in this PR.

The problem is that the test inserts list content via an old attributes value, which triggered the above useMigrateOnLoad() to migrate the block to inner blocks. After this PR the quote block was no longer automatically selected after the migration due to timing changes.

Previously in trunk:

  1. Block is inserted with legacy attributes.value.
  2. Parent block is currently selected.
  3. Template sync is scheduled for later.
  4. useMigrateOnLoad() runs and converts the legacy attribute into real inner blocks.
  5. When template sync runs asynchronously and checks inner blocks, they are no longer empty, so it does not run the default inner block insertion and does not change selection.
  6. Parent selection survives, even though we inserted inner blocks.

Previously, parent selection was preserved as a (probably) accidental result of the delayed template sync. The legacy migration populated the inner blocks before template sync ran, so template sync skipped the default inner-block insertion and never moved selection into the template child. Normally, you'd expect selection to move to the inner block, but here it was preserved and the test could click the "Plain" button on the parent quote block.

In the new logic, we run inner block template syncing first, which switches selection to the inserted child, before migrating the legacy value next. Migration uses replaceInnerBlocks() which doesn't keep selection, and the selection on the inner child is lost. The same applies for core/list's migration code, so I added the same fix in there.

I don't think this really matters in any real scenario. useMigrateOnLoad() is designed to run on initial editor load of a quote block with legacy attributes, where selection won't be set and doesn't matter. It really only applies to the style-variation.spec.js test where the parent block is assumed to still be selected after migration of a newly-created block with legacy attributes. We could also solve the issue by changing style-variation.spec.js to avoid inserting a legacy version, or actively manually select the quote block before trying to click the "Plain" button.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wish we could fix this differently. This used to work because the migration ran before the template sync. The migration converted the legacy attributes to inner blocks, and then the template sync found that there are already inner blocks, and that it doesn't need to insert the template.

The migration ran first because the template sync was deferred with queueMicrotask. Now when the microtask is gone the template sync run first. Because the template sync is in a child component (UncontrolledInnerBlocks) and the migration is in a parent component (quote's Edit). And effects run bottom-up, from children to parents.

Because the template sync run first, it modifies the selection: it selects the quote block's new paragraph child. That's why we need special code to fix the selection, i.e., move it back to the parent.

The template sync is unwanted. If we found a way to run the migration effect first again, we'd avoid the arbitrary fixes. But that means moving the useMigrateOnLoad to a child component -- not sure how to do that elegantly.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm solving this as part of #80100. There the useMigrateOnLoad is moved to an InnerBlocks child, ensuring the right order: migration first, template sync second.

@alecgeatches alecgeatches marked this pull request as ready for review June 9, 2026 21:39
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: alecgeatches <alecgeatches@git.wordpress.org>
Co-authored-by: maxschmeling <maxschmeling@git.wordpress.org>
Co-authored-by: jsnajdr <jsnajdr@git.wordpress.org>
Co-authored-by: ellatrix <ellatrix@git.wordpress.org>
Co-authored-by: youknowriad <youknowriad@git.wordpress.org>
Co-authored-by: Mamaduka <mamaduka@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@alecgeatches

Copy link
Copy Markdown
Contributor Author

Hello @ellatrix, I'd really appreciate a review on this PR. We've been seeing a problem with inner templates getting inserted multiple times in a WebSockets RTC environment, and the core is a queueMicrotask() in between the outer block and inner template insertion that runs on all peers simultaneously. This PR removes the async delay and defers outer block inserts until synchronous inner template logic has the chance to run. This is a tricky problem and I'd appreciate your eye on it.

@youknowriad

Copy link
Copy Markdown
Contributor

Isn't there a conceptual issue here at the "sync layer" where any side effects of block insertion (useInnerBlockTemplateSync is one but there could be others, in fact, I'm certain we have a lot more) could result in an issue? Did you think about this a little bit and if there's a potential way forward where these effects and syncing don't conflict?

@alecgeatches

alecgeatches commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

Isn't there a conceptual issue here at the "sync layer" where any side effects of block insertion (useInnerBlockTemplateSync is one but there could be others, in fact, I'm certain we have a lot more) could result in an issue? Did you think about this a little bit and if there's a potential way forward where these effects and syncing don't conflict?

Good question, and yes, I think you're right that this is a general class of problem. This is a broad issue with Gutenberg that needs to be addressed probably in several places for real-time collaboration compatibility. Anywhere we process a block (e.g. a core/list) and then have an asynchronous reconciliation effect that queues a separate block mutation in the future (e.g. useInnerBlockTemplateSync), we may need to be careful about RTC.

Although this isn't true in all cases of deferred effects. I think we need two properties in order for this problem to appear:

  1. Non-atomicity. Does the effect sync as a separate operation from the change that triggered it (because it runs in a later commit or a microtask)? If so, peers observe the intermediate state and re-run their own copy of the effect.
  2. Non-convergence. Delayed operations can be fine if the result of re-running the effect is idempotent or irrelevant. Append-only insertions (like with useInnerBlockTemplateSync()) duplicate. However, an overwrite like a deferred setAttributes call is fine since the value will converge after each peer runs the side-effect. Overwriting is typically fine, indiscriminate appending is not.

So the scope is narrowed a bit to where an is operation is non-atomic and non-convergent. This PR fixes non-atomicness for template insertion on the originator side, but generally any non-atomic non-convergent operations are incompatible with collaboration. Some broader ways to try to solve this generally:

  1. Generalize merge-at-source (what this PR does for templates). More generally, fix any reconciliation mutation that runs in a separate commit/microtask to ensure it combines into a single outgoing sync, so peers never see the intermediate state. We'd need a broader refactor to try to cover all such cases in Gutenberg.
  2. Guard remotely-applied changes from re-triggering local reconciliation, so only the originating peer reconciles and then syncs the merged result. This was the receiver-side implementation in RTC: Fix useInnerBlockTemplateSync race condition #78696. I don't think this is necessarily a better solution, since we still need to change the affected mutating code to check if a block has a remote origin, and it takes a lot more plumbing. I think it's cleaner to have the sender be in charge of this.
  3. Completely remove reconciliation out of render effects into the originating action e.g. change insertBlock to also insert any necessary inner block synchronously instead of running as a side-effect sometime after mount. This would be the way I'd build Gutenberg from the ground up for RTC if I wanted to avoid this problem completely. Ideally there is one mutation and one sync per user operation, with nothing to reconcile per peer. This would probably be trickiest because Gutenberg uses a good amount of setTimeout() and queueMicrotask(), sometimes just for timing reasons just because other code is also deferred, which is a code smell to me. We'd need new ways to define things like inner blocks outside of side-effects. Biggest refactor, but it'd be the most generalized fix for addressing non-atomicity where possible.

The PR here is narrowly targeted to templates, but we'll probably need to add originator fixes for other non-sync-compatible patterns. That said, I don't see a way to generally fix this issue without a major change to Gutenberg. Fortunately, many deferred operations work just fine due to convergence, so we largely need to focus on those that indiscriminately append data after a delay like inner templates.

@youknowriad

Copy link
Copy Markdown
Contributor

What I'm saying is that the template insertion is not a user action, the block object shouldn't change, it's just a reflection of a "default state" of a block. Which means, we shouldn't have to update the blocks state to render these template, now it's true that the block editor can only render block if they are in store, so maybe there should be a way to mark some blocks as "default" or something in the block-editor store which means, they get inserted into the store, but not as part of the blocks.tree that gets sent to onChange and only in selectors that are used for rendering...
I'm saying this but I have no idea how simple would that be.

@alecgeatches

Copy link
Copy Markdown
Contributor Author

What I'm saying is that the template insertion is not a user action, the block object shouldn't change, it's just a reflection of a "default state" of a block.

This idea makes sense to me. This is also applicable in other areas like undo, where we have to be careful to make block tree changes that don't actually affect the stack like a user action. I'll try some things and get back to you soon.

@alecgeatches

Copy link
Copy Markdown
Contributor Author

@youknowriad What do you think about the save flow right after inserting a block with a template? e.g. I insert a core/list (<ul></ul>) but don't interact with the default template, and then save afterward. A naive implementation that treats default blocks as render-only would probably just save the outer list without any <li> list items, since the template is client-side rendered until modified. However, I think this would break default behavior and probably also be confusing, because the editor would show "ghost" items that don't actually exist in the output. For now I'm thinking we'll also need a flush step on save to serialize client-side templates to real blocks, but I'm still working through it.

@ellatrix

ellatrix commented Jul 8, 2026

Copy link
Copy Markdown
Member

It would be good to get rid of inner block list "setting" syncing entirely. Which parts actually need to be dynamic and can't be moved to block.json or a simple non-render function?

@youknowriad

Copy link
Copy Markdown
Contributor

@youknowriad What do you think about the save flow right after inserting a block with a template? e.g. I insert a core/list (

    ) but don't interact with the default template, and then save afterward. A naive implementation that treats default blocks as render-only would probably just save the outer list without any
  • list items, since the template is client-side rendered until modified. However, I think this would break default behavior and probably also be confusing, because the editor would show "ghost" items that don't actually exist in the output. For now I'm thinking we'll also need a flush step on save to serialize client-side templates to real blocks, but I'm still working through it.

  • Indeed a good point, and that would probably break a lot of assumptions we've made over time. I guess my suggestion is not a good solution.

    I still don't understand the proposed solution though. I think if we consider "core/list" without template has a "half formed block", we shouldn't be able to insert it at all like that. So is there a solution where the "template" is resolved before the block is added to the store.

    I guess the main issue here is that today template is dynamic and provided as a "prop" in Edit function, probably not just "template" but all the "block list settings" @ellatrix is referring too. Do you think it's reasonable to "audit" our usage of these block list settings (maybe start with just template) and check whether these can be transformed to a new API that is more declarative (like a function we can call before inserting the block or something like that).

    @jsnajdr

    jsnajdr commented Jul 8, 2026

    Copy link
    Copy Markdown
    Member

    Do you think it's reasonable to "audit" our usage of these block list settings (maybe start with just template) and check whether these can be transformed to a new API

    It's possible, but I don't think it will really help. In core blocks, the template passed to useInnerBlocksProps is always a static object and it could be a part of block.json. If it was in block.json, the template could be inserted as part of the INSERT_BLOCKS and REPLACE_BLOCKS actions. Atomically and "headlessy" (without the Edit UI mounted).

    But what if any third party block declares the template dynamically? It can be different based on settings, block attributes, etc. We don't have any place for "headless JS logic" that could be called by the blockEditorStore reducer. We have either static block.json data or dynamic UI Edit function.

    Nested settings are also almost static. But exceptions are possible. For example, the allowedBlocks setting is often stored in block attributes and useInnerBlockProps copies the attribute value to nested settings. But again, our long-term-stable API lets block authors declare these settings dynamically.


    I think the root cause of the issue is somewhere else. The block editor does many "automated" edits as React side-effects. There are attribute edits, like these we discussed in #78989 (submenuVisibility or isNested). These are OK from the RTC perspective because they are idempotent. But there are also block insertions, and the problem is that they are not detected as idempotent. My editor creates core/list and sends it to you. Then it auto-creates core/list-item and then also sends it to you. Your editor receives the core/list insertion and does its own auto-creation of core/list-item, and then it receives a second core/list-item from me.

    We should somehow detect that both core/list-item insertions are actually the same thing. I believe that the real root cause is that we treat them as two independent edits.

    Does Yjs/RTC have some mechanism how to connect these two edits together? In some sense, they are not done by the two human collaborators, but by a third one, a machine agent. Is there a way to model this?

    The automated side-effect edits are ubiquitous and useful, I doubt that we'll be able to fully eliminate them, although it's probably possible in theory. We should rather teach the RTC engine to treat them correctly.

    @youknowriad

    Copy link
    Copy Markdown
    Contributor

    But what if any third party block declares the template dynamically? It can be different based on settings, block attributes, etc. We don't have any place for "headless JS logic" that could be called by the blockEditorStore reducer. We have either static block.json data or dynamic UI Edit function.

    The solution here was for me to deprecate the API and consider a plan for them, but before doing there, I wanted to understand how we're using this ourselves.

    We should somehow detect that both core/list-item insertions are actually the same thing. I believe that the real root cause is that we treat them as two independent edits.

    I agree that this is also a "root cause", but IMO both are issues. The reason the core/list insertion without template is an issue is specifically the fact that if that block is inserted and the effect doesn't run, the frontend is broken. (the save issue Alex mentions)

    Does Yjs/RTC have some mechanism how to connect these two edits together? In some sense, they are not done by the two human collaborators, but by a third one, a machine agent. Is there a way to model this?

    My guess is it's the clientId and two different clientIds means two different blocks.

    // immediately after insertion through useInnerBlockTemplateSync.
    return (
    getBlockType( block.name )?.allowedBlocks !== undefined ||
    getBlockSupport( block.name, 'allowedBlocks' ) !== undefined

    Copy link
    Copy Markdown
    Member

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    This is a very unreliable condition. The allowedBlocks setting/attribute is not related to the template passed to useInnerBlocksProps. Many blocks have one but not the other.

    // settings before the template replacement to preserve that ordering.
    // Example: If you remove flush here, ctrl + click to insert quote
    // block won't close the inserter.
    flushPendingNestedSettingsUpdates( registry );

    Copy link
    Copy Markdown
    Member

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    We could do this flushing more precisely. We don't want to flush all the pending updates for all blocks, but just for the clientId of the current block. Remove that one entry from pendingSettingsUpdates and leave the other entries alone.

    I think this is a nice patch that's worth merging separately. Flushing is more elegant than deferring the entire template sync with queueMicrotask. Even if we never merged the rest of the PR, this part is worth doing.

    @ellatrix

    ellatrix commented Jul 8, 2026

    Copy link
    Copy Markdown
    Member

    But what if any third party block declares the template dynamically?

    I think there's two things here:

    • What if there's a callback with a registry arg? We've done similar things for merge, transform, __experimentalLabel etc. allowing dynamic settings.

    • We'll still need to support the previous way it worked to be backwards compatible, but we could not support them when RTC is on and push people to use the new APIs.

    Even without RTC, these settings have caused performance an memo problems so it would be good to fix it "properly".

    const shouldDeferBlockSync =
    ( ( action.type === 'INSERT_BLOCKS' &&
    action.meta?.__unstableShouldDeferBlockSync ) ||
    action.type === 'REPLACE_BLOCKS' ||

    Copy link
    Copy Markdown
    Member

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    Is it expected that we are "deferring block sync" for all REPLACE_BLOCKS actions?

    @ellatrix

    ellatrix commented Jul 8, 2026

    Copy link
    Copy Markdown
    Member

    I've been poking Claude about the template setting, so I might as well open PRs now.

    @jsnajdr

    jsnajdr commented Jul 9, 2026

    Copy link
    Copy Markdown
    Member

    I've been poking Claude about the template setting, so I might as well open PRs now.

    Another "framework gap" that would be worth looking at is the useMigrateOnLoad hook that migrates the value attribute to inner blocks in list (#39799) and quote (#39844). The deprecated API should be powerful enough to migrate the block during insertion or parsing. Instead of an edit UI useEffect hook.

    @jsnajdr

    jsnajdr commented Jul 9, 2026

    Copy link
    Copy Markdown
    Member

    What if there's a callback with a registry arg? We've done similar things for merge, transform, __experimentalLabel etc. allowing dynamic settings.

    These functions don't have a registry arg, they receive just the minimal necessary arguments: the block attributes etc. If we introduce a similar template callback, we should start with zero parameters (good enough to migrate the static return values) and then introduce new ones as needs are identified.

    @Mamaduka

    Mamaduka commented Jul 9, 2026

    Copy link
    Copy Markdown
    Member

    Another "framework gap" that would be worth looking at is the useMigrateOnLoad hook that migrates

    There are also useDeprecatedAlign|useDeprecatedTextAlign hooks that perform alignment migration when plugins update this old attribute programmatically (#73578). Ideally, it should be handled by the Deprecation API, but it only runs during parsing.

    @jsnajdr

    jsnajdr commented Jul 9, 2026

    Copy link
    Copy Markdown
    Member

    Personally, I'm really concerned about the changes in use-block-sync. The reality is that I don't understand them properly and that is a critical part IMO. It's very unclear to me why some changes are deferred for instance.

    I think I'm starting to understand the useBlockSync changes, and when explained in a proper way, they sound like a really good idea 🙂

    When syncing block-editor tree to the core-data entity, useBlockSync creates a block-editor store subscription and for each incoming (relevant) change, it immediately dispatches editEntityRecord to the core-data entity. This is a synchronous loop with no delays of any kind. There is a keyboard input event handled when you press a key, and within the handler, in the same stack trace, there is the editEntityRecord dispatch.

    This PR essentially makes this fully synchronous loop a little less synchronous. The actions that are marked as shouldDeferBlockSync will be deferred until after the next paint, basically. That's what the setState + useEffect combo does. When a new block is inserted, it triggers a render of the new block. The new block render has a useLayoutEffect code that inserts the template. The layout effect runs before the block is painted for the first time. Block insertion and template insertion are independent and used to cause two separate editEntityRecord calls. But they both run before paint, and if they are now newly deferred until after the paint, they will be merged into one editEntityRecord. That's the essence of the fix.

    Now let me offer an idea that is both very radical and very simple: what if we deferred all actions in the sync loop? There's no targeted shouldDeferBlockSync flag, but everything is always deferred.

    When the store subscription sees an incoming block-editor change, it doesn't sync it immediately, but it adds it to a queue that is flushed after every committed and painted React render. Somewhat similar to flushing inside a regular requestAnimationFrame callback. This would naturally batch related changes together.

    We would have to carefully handle updates with different persistent/non-persistent flags, with different undo/history regimes. And maybe also update the pendingChanges.outgoing and .incoming queues, to account for the batching.

    @youknowriad

    Copy link
    Copy Markdown
    Contributor

    Now let me offer an idea that is both very radical and very simple: what if we deferred all actions in the sync loop? There's no targeted shouldDeferBlockSync flag, but everything is always deferred.

    Yes for me the main issue of the proposal, is that it's not clear why we defer "inserter" actions and not others, there's no real logic here. So if you propose that we defer everything, I'd be more onboard with this. That said, I think a long time ago we had things like that, but maybe more specific to RichText and reverted it. We need to ensure that there are no undesired side effects but I wouldn't mind that change.

    My guess though is that there are situations where we navigate between entities, undo/redo, site editor navigation that might need to be considered carefully

    @alecgeatches

    alecgeatches commented Jul 9, 2026

    Copy link
    Copy Markdown
    Contributor Author

    Appreciate all of the discussion here, thank you all! I agree with some of the concerns above where we may be skipping a defer in a situation where it makes sense (allowedBlocks check on insertions, but that isn't always present) and always deferring in other situations like REPLACE_BLOCKS. It would also be great to have a declarative template property so we could manage template insertion, but as mentioned above we don't have a backwards-compatible mechanism for that.

    The original intent behind some of the defer scoping was to reduce the number of changes we deferred, but it seems like that logic makes things more confusing, and will still probably miss cases.

    Now let me offer an idea that is both very radical and very simple: what if we deferred all actions in the sync loop? There's no targeted shouldDeferBlockSync flag, but everything is always deferred.

    When the store subscription sees an incoming block-editor change, it doesn't sync it immediately, but it adds it to a queue that is flushed after every committed and painted React render. Somewhat similar to flushing inside a regular requestAnimationFrame callback. This would naturally batch related changes together.

    We would have to carefully handle updates with different persistent/non-persistent flags, with different undo/history regimes. And maybe also update the pendingChanges.outgoing and .incoming queues, to account for the batching.

    This sounds like a better solution. I like that it would naturally coalesce microtasks and some effects so we don't need to spot-fix and carefully time when we switch between synchronous and asynchronous tasks in the editor. I'm happy to give this a shot! Good callout on when we have conflicting flags, I think we'd just want to flush incompatible sets separately.

    @ellatrix

    ellatrix commented Jul 9, 2026

    Copy link
    Copy Markdown
    Member

    I have three PRs that together migrate the all template settings to static arrays on the block type object (no function or fn args). For post content and cover, we had some hacks that I removed. (Post content block should be using the appender when there's empty content, just like the post editor. Cover has a weird setup where a template should not be immediately inserted, it should only be added after submitting the placeholder form, so it's not really a template.)
    All the other cases are basically static and could be easily migrated. No need for a function at all. We should deprecate the prop imo and see what use cases are reported that don't work with a static template (or appender).

    @alecgeatches

    Copy link
    Copy Markdown
    Contributor Author

    @ellatrix Awesome! Do you think we should still proceed with the defer strategy? I think this solution is great, we can statically determine if a block has a template and insert it synchronously. Of course this doesn't address third-party blocks that still use useInnerBlocksProps( ... , { template } ), and I'm not completely sure about directly defined default blocks and <InnerBlocks> templates.

    Your fix is much more ideal but not backwards compatible. What do you think? Are you trying to avoid the defer fix?

    @ellatrix

    ellatrix commented Jul 9, 2026

    Copy link
    Copy Markdown
    Member

    I think third parties are not really an issue. There's 61 hits and most of them are static/constant. 1 is "dynamic" but not really, it's coming from an attribute that is set server side for some reason, and another 1 is a similar situation to the cover block. So we should 100% keep this API clean and static only.

    https://veloria.dev/search/89cf04eb-b18a-4312-8572-1a79a22091c2
    https://veloria.dev/search/1b15c144-6744-4e4d-acf0-f83bc2665d45

    A lot of them also have 0 installs. We could deprecate, disable RTC when the old approach is used (or something like that, open to ideas), and contact the ones with the highest install counts to update and be compatible (only 6 are 4k+).

    @alecgeatches

    alecgeatches commented Jul 9, 2026

    Copy link
    Copy Markdown
    Contributor Author

    I'm surprised by the results in your search, I expected more usage! An occasional custom block with this property still might be present, but we'd have a difficult time detecting or disabling RTC until the block was already inserted due to the dynamic reasons you discussed above. But that should be rare and an easy diagnosis.

    I think if template insertion is actually a rarely-used feature as it appears, and we can completely cover core, then that's a better plan. Thank you for putting this together.

    @maxschmeling

    Copy link
    Copy Markdown
    Contributor

    If there is a custom block that does truly need them to be dynamic, it wouldn't have any way to work with RTC properly so that's where you're saying we might need to disable RTC or something? It might be a small enough use case that it isn't worth worrying about, but it would be nice if we were able to provide an alternate for those seemingly rare use cases.

    @ellatrix

    ellatrix commented Jul 9, 2026

    Copy link
    Copy Markdown
    Member

    There's alternatives, you can still insert the blocks yourself like I'm doing with the cover block. Plenty of APIs around that.

    @ellatrix

    ellatrix commented Jul 9, 2026

    Copy link
    Copy Markdown
    Member

    But like I said, there's not really any (public) custom cases out there, it's all practically static. Maybe there's private plugins that do it, but we have a pretty good sample size from core + public plugins.

    @jsnajdr

    jsnajdr commented Jul 10, 2026

    Copy link
    Copy Markdown
    Member

    Even if all plugins use only static templates, it only means that they can easily migrate to the new API. But the duplicate insert issue will still be there. It will only happen much less often because it now affects just a few minor plugins, not an essential Core block like List or Quote.

    We still have many blocks that, for example, use apiVersion: 1 and don't support the iframed canvas. And that's been around for years. Migrations will be very slow.

    I think the proper fix is still worth doing, although it will be much less urgent now.

    @ellatrix

    Copy link
    Copy Markdown
    Member

    It's up to you all. I think apiVersion: 1 is very different, many plugins have created blocks, only a subset of that have created blocks with inner blocks and only a subset of that inner blocks with templates. I also wonder if we could make some back-compat layer that just removes all this code and adds the template in the React render function to the block type once. Technically a breaking change but it doesn't seem like it would be very severe.

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

    Labels

    [Feature] Real-time Collaboration Phase 3 of the Gutenberg roadmap around real-time collaboration [Package] Block editor /packages/block-editor [Package] Block library /packages/block-library [Type] Bug An existing feature does not function as intended

    Projects

    None yet

    Development

    Successfully merging this pull request may close these issues.

    6 participants