Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
*
* @package gutenberg-test-interactive-blocks
*/

wp_interactivity_state(
'test/deferred-store',
array(
'number' => 2,
'double' => 4,
)
);

?>

<div
Expand All @@ -12,4 +21,7 @@
>
<span data-wp-text="state.reversedText" data-testid="result"></span>
<span data-wp-text="state.reversedTextGetter" data-testid="result-getter"></span>

<span data-wp-text="state.number" data-testid="state-number"></span>
<span data-wp-text="state.double" data-testid="state-double"></span>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,19 @@ window.addEventListener(
},
{ once: true }
);

window.addEventListener(
'_test_proceed_',
() => {
const { state } = store( 'test/deferred-store', {
state: {
number: 3,

get double() {
return state.number * 2;
},
},
} );
},
{ once: true }
);
4 changes: 4 additions & 0 deletions packages/interactivity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
- Deprecated the `"data-wp-ignore"` directive of the Interactivity API.([#70945](https://github.com/WordPress/gutenberg/pull/70945))
It is deprecated as of WordPress 6.9 and will be removed in version 7.0.

### Bug Fixes

- Make state getters to be updated asynchronously with `store()`. ([#70974](https://github.com/WordPress/gutenberg/pull/70974))

## 6.27.0 (2025-07-23)

## 6.26.0 (2025-06-25)
Expand Down
38 changes: 38 additions & 0 deletions packages/interactivity/src/proxies/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ export class PropSignal {
*/
private getterSignal?: Signal< ( () => any ) | undefined >;

/**
* Pending getter to be consolidated.
*/
private pendingGetter?: () => any;

/**
* Structure that manages reactivity for a property in a state object, using
* signals to keep track of property value or getter modifications.
Expand Down Expand Up @@ -82,6 +87,30 @@ export class PropSignal {
this.update( { get: getter } );
}

/**
* Changes the internal getter asynchronously.
*
* The update is made in a microtask, which prevents issues with getters
* accessing the state, and ensures the update occurs before any render.
*
* @param getter New getter.
*/
public setPendingGetter( getter: () => any ) {
this.pendingGetter = getter;
queueMicrotask( () => this.consolidateGetter() );
}

/**
* Consolidate the pending value of the getter.
*/
private consolidateGetter() {
const getter = this.pendingGetter;
if ( getter ) {
this.pendingGetter = undefined;
this.update( { get: getter } );
}
}

/**
* Returns the computed that holds the result of evaluating the prop in the
* current scope.
Expand All @@ -99,6 +128,15 @@ export class PropSignal {
this.update( {} );
}

/*
* If there is any pending getter, consolidate it first. This
* could happen if a getter is accessed synchronously after
* being set with `store()`.
*/
if ( this.pendingGetter ) {
this.consolidateGetter();
}

if ( ! this.computedsByScope.has( scope ) ) {
const callback = () => {
const getter = this.getterSignal?.value;
Expand Down
2 changes: 1 addition & 1 deletion packages/interactivity/src/proxies/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ const deepMergeRecursive = (
} );
// Update the getter in the property signal if it exists
if ( desc.get && propSignal ) {
propSignal.setGetter( desc.get );
propSignal.setPendingGetter( desc.get );
}
}

Expand Down
111 changes: 111 additions & 0 deletions packages/interactivity/src/proxies/test/deep-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,117 @@ describe( 'Interactivity API', () => {
expect( deepValue ).toBe( 'value 2' );
} );

it( 'should overwrite the getters of existing signals asynchronously', async () => {
const target: any = proxifyState( 'test', {
number: 2,
double: 4,
} );

let double: any;
const spy = jest.fn( () => ( double = target.double ) );
effect( spy );

expect( spy ).toHaveBeenCalledTimes( 1 );
expect( double ).toBe( 4 );

deepMerge(
target,
{
number: 3,
get double() {
return this.number * 2;
},
},
true
);

expect( spy ).toHaveBeenCalledTimes( 1 );
expect( double ).toBe( 4 );

// After this await, the `double` getter should have been updated.
await Promise.resolve();

expect( spy ).toHaveBeenCalledTimes( 2 );
expect( double ).toBe( 6 );
} );

it( 'should overwrite the getters of existing signals synchronously when accessed immediately after', async () => {
const target: any = proxifyState( 'test', {
number: 2,
double: 4,
} );

let double: any;
const spy = jest.fn( () => ( double = target.double ) );
effect( spy );

expect( spy ).toHaveBeenCalledTimes( 1 );
expect( double ).toBe( 4 );

deepMerge(
target,
{
number: 3,
get double() {
return this.number * 2;
},
},
true
);

expect( spy ).toHaveBeenCalledTimes( 1 );
expect( double ).toBe( 4 );

// Access the getter synchronously.
expect( target.double ).toBe( 6 );
expect( spy ).toHaveBeenCalledTimes( 2 );
expect( double ).toBe( 6 );
} );

it( 'should set the last value for multiple-overwritten getters', async () => {
const target: any = proxifyState( 'test', {
number: 2,
double: 4,
} );

let double: any;
const spy = jest.fn( () => ( double = target.double ) );
effect( spy );

expect( spy ).toHaveBeenCalledTimes( 1 );
expect( double ).toBe( 4 );

deepMerge(
target,
{
number: 3,
get double() {
return this.number * 2;
},
},
true
);

deepMerge(
target,
{
number: 3,
get double() {
return `${ this.number * 2 }!`;
},
},
true
);

expect( spy ).toHaveBeenCalledTimes( 1 );
expect( double ).toBe( 4 );

// Access the getter synchronously.
expect( target.double ).toBe( '6!' );
expect( spy ).toHaveBeenCalledTimes( 2 );
expect( double ).toBe( '6!' );
} );

describe( 'arrays', () => {
it( 'should handle arrays', () => {
const target = { a: [ 1, 2 ] };
Expand Down
14 changes: 14 additions & 0 deletions test/e2e/specs/interactivity/deferred-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,18 @@ test.describe( 'deferred store', () => {
} );
await expect( resultInput ).toHaveText( 'Hello, world!' );
} );

test( 'Ensure that a state getter can access the returned state even when directives already subscribed to it', async ( {
page,
} ) => {
const stateNumber = page.getByTestId( 'state-number' );
const stateDouble = page.getByTestId( 'state-double' );
await expect( stateNumber ).toHaveText( '2' );
await expect( stateDouble ).toHaveText( '4' );
await page.evaluate( () => {
window.dispatchEvent( new Event( '_test_proceed_' ) );
} );
await expect( stateNumber ).toHaveText( '3' );
await expect( stateDouble ).toHaveText( '6' );
} );
} );
Loading