Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
52d68bc
Introduce synchronization promise for initialVdom to resolve race con…
Mar 2, 2026
9d7b387
Update hydration.ts
Mar 2, 2026
61ab3f4
Expose initialVdomPromise via privateApis
Mar 2, 2026
3030539
Fix router race condition by awaiting hydration
Mar 2, 2026
0208546
Fixed the TypeScript error (`TS18004`)
Mar 2, 2026
e6ca1d1
Fix Prettier formatting in hydration.ts
Mar 2, 2026
00dbd54
Fix Prettier formatting for resolveInitialVdom type
Mar 2, 2026
e500ae2
Add test block registration for router hydration race condition
Mar 3, 2026
8b9371a
Interactive markup for router race condition test block
Mar 3, 2026
74f658e
Force static router import to make hydration race deterministic in al…
Mar 3, 2026
4214b0d
Add store with context and state actions for router race condition test
Mar 3, 2026
08dba8c
Add e2e test for router hydration race condition
Mar 3, 2026
cbea0bd
Add PHP docblock to render.php to satisfy PHPCS coding standards
Mar 3, 2026
9d04f95
Fix TypeScript errors in spec: use requestUtils.createPage instead of…
Mar 3, 2026
ca764be
Fix e2e spec: use interactivityUtils fixture to activate test plugin
Mar 3, 2026
e651478
Evaluate router code
DAreRodz Mar 4, 2026
236c303
Make interactive region a router region
DAreRodz Mar 4, 2026
f55e9e0
Run e2e tests in Safari and Firefox too
DAreRodz Mar 4, 2026
b1b07f1
Move goto to a beforeEach callback and simplify comments
DAreRodz Mar 4, 2026
b5226c1
Update CHANGELOG.md
Mar 4, 2026
1e1601e
Update CHANGELOG iAPI
Mar 4, 2026
33fdef5
Update CHANGELOG.md
Mar 4, 2026
aaf7637
Update CHANGELOG.md (iAPI-router)
Mar 4, 2026
997f334
Interactivity: Remove initialVdom from privateApis exports
Mar 4, 2026
c1fed18
Interactivity Router: Remove initialVdom import, use WeakMap type in …
Mar 4, 2026
e5e37bd
Merge branch 'trunk' into Router-initialization-race-condition-on-Saf…
Mar 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "test/router-race-condition",
"title": "E2E Interactivity tests - router race condition",
"category": "text",
"icon": "heart",
"description": "",
"supports": {
"interactivity": true
},
"textdomain": "e2e-interactivity",
"viewScriptModule": "file:./view.js",
"render": "file:./render.php"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php
/**
* HTML for testing the router hydration race condition.
*
* @package gutenberg-test-interactive-blocks
*
* @phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
*/
?>
<div
data-wp-interactive="router-race-condition"
data-wp-router-region="router-race-condition/buttons"
data-wp-context='{ "counter": 0 }'
>
<button
data-testid="context-counter"
data-wp-text="context.counter"
data-wp-on--click="actions.increment"
>0</button>

<button
data-testid="global-counter"
data-wp-text="state.counter"
data-wp-on--click="actions.incrementGlobal"
>0</button>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php return array(
'dependencies' => array(
'@wordpress/interactivity',
array(
'id' => '@wordpress/interactivity-router',
'import' => 'static',
),
),
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* WordPress dependencies
*/
import { store, getContext } from '@wordpress/interactivity';
import '@wordpress/interactivity-router';

const { state } = store( 'router-race-condition', {
state: {
counter: 0,
},
actions: {
increment() {
const context = getContext();
context.counter += 1;
},
incrementGlobal() {
state.counter += 1;
},
},
} );
3 changes: 3 additions & 0 deletions packages/interactivity-router/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

### Bug Fixes

- Fix race condition where router evaluates before `hydrateRegions()` completes on Safari and Firefox, causing interactive regions to be permanently non-functional (dead DOM). ([#76053](https://github.com/WordPress/gutenberg/pull/76053))
## 2.41.0 (2026-03-04)

## 2.40.0 (2026-02-18)
Expand Down
25 changes: 15 additions & 10 deletions packages/interactivity-router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {

const {
getRegionRootFragment,
initialVdom,
initialVdomPromise,
toVdom,
render,
parseServerData,
Expand Down Expand Up @@ -50,7 +50,7 @@ export interface PrefetchOptions {
}

interface VdomParams {
vdom?: typeof initialVdom;
vdom?: WeakMap< Element, any >;
}

interface Page {
Expand Down Expand Up @@ -361,14 +361,19 @@ document.querySelectorAll( regionsSelector ).forEach( ( region ) => {
window.document
.querySelectorAll< HTMLScriptElement >( 'script[type=module][src]' )
.forEach( ( { src } ) => markScriptModuleAsResolved( src ) );
pages.set(
getPagePath( window.location.href ),
Promise.resolve(
preparePage( getPagePath( window.location.href ), document, {
vdom: initialVdom,
} )
)
);

// Await hydration completion before setting the initial page to ensure initialVdom is populated.
( async () => {
const initialVdomMap = await initialVdomPromise;
pages.set(
getPagePath( window.location.href ),
Promise.resolve(
preparePage( getPagePath( window.location.href ), document, {
vdom: initialVdomMap,
} )
)
);
} )();

// Variable to store the current navigation.
let navigatingTo = '';
Expand Down
3 changes: 3 additions & 0 deletions packages/interactivity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

### Bug Fixes

- Add `initialVdomPromise` synchronization promise to ensure the router waits for hydration to complete before initializing, fixing dead DOM on Safari and Firefox. ([#76053](https://github.com/WordPress/gutenberg/pull/76053))
## 6.41.0 (2026-03-04)

## 6.40.0 (2026-02-18)
Expand Down
11 changes: 11 additions & 0 deletions packages/interactivity/src/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ export const getRegionRootFragment = (
// Initial vDOM regions associated with its DOM element.
export const initialVdom = new WeakMap< Element, ComponentChild >();

// Promise that resolves with the populated initialVdom after hydration completes.
let resolveInitialVdom!: ( map: WeakMap< Element, ComponentChild > ) => void;
export const initialVdomPromise = new Promise<
WeakMap< Element, ComponentChild >
>( ( resolve ) => {
resolveInitialVdom = resolve;
} );

// Initialize the router with the initial DOM.
export const hydrateRegions = async () => {
const nodes = document.querySelectorAll( `[data-wp-interactive]` );
Expand All @@ -43,4 +51,7 @@ export const hydrateRegions = async () => {
hydrate( vdom, fragment );
}
}

// Resolve the promise with the fully populated initialVdom after all regions are hydrated.
resolveInitialVdom( initialVdom );
};
4 changes: 2 additions & 2 deletions packages/interactivity/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { batch, effect } from '@preact/signals';
*/
import registerDirectives, { routerRegions } from './directives';
import {
initialVdom,
initialVdomPromise,
hydrateRegions,
getRegionRootFragment,
} from './hydration';
Expand Down Expand Up @@ -78,7 +78,7 @@ export const privateApis = (
if ( lock === requiredConsent ) {
return {
getRegionRootFragment,
initialVdom,
initialVdomPromise,
toVdom,
directive,
getNamespace,
Expand Down
51 changes: 51 additions & 0 deletions test/e2e/specs/interactivity/router-race-condition.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Internal dependencies
*/
import { test, expect } from './fixtures';

// Regression test for https://github.com/WordPress/gutenberg/issues/75778.
test.describe( 'Router hydration race condition (@webkit, @firefox)', () => {
test.beforeAll( async ( { interactivityUtils: utils } ) => {
await utils.activatePlugins();
await utils.addPostWithBlock( 'test/router-race-condition', {
alias: 'router-race-condition',
} );
} );

test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
await page.goto( utils.getLink( 'router-race-condition' ) );
} );

test.afterAll( async ( { interactivityUtils: utils } ) => {
await utils.deactivatePlugins();
await utils.deleteAllPosts();
} );

test( 'should hydrate context-bound directives when router loads statically', async ( {
page,
} ) => {
const button = page.getByTestId( 'context-counter' );

await expect( button ).toHaveText( '0' );

await button.click();
await expect( button ).toHaveText( '1' );

await button.click();
await expect( button ).toHaveText( '2' );
} );

test( 'should hydrate state-bound directives when router loads statically', async ( {
page,
} ) => {
const button = page.getByTestId( 'global-counter' );

await expect( button ).toHaveText( '0' );

await button.click();
await expect( button ).toHaveText( '1' );

await button.click();
await expect( button ).toHaveText( '2' );
} );
} );
Loading