Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Expand Up @@ -47,6 +47,7 @@
>
<div data-testid="prop" data-wp-text="context.prop"></div>
<div data-testid="nested.prop" data-wp-text="context.nested.prop"></div>
<div data-testid="objCopiedFromServer" data-wp-text="context.objCopiedFromServer.prop"></div>
<div data-testid="newProp" data-wp-text="context.newProp"></div>
<div data-testid="nested.newProp" data-wp-text="context.nested.newProp"></div>
<div data-testid="inherited.prop" data-wp-text="context.inherited.prop"></div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,11 @@ store( 'test/get-server-context', {
yield actions.navigate( e.target.href );
} ),
attemptModification() {
try {
getServerContext().prop = 'updated from client';
getContext().result = 'unexpectedly modified ❌';
} catch ( e ) {
getContext().result = 'not modified ✅';
}
getServerContext().prop = 'updated from client';
getContext().result =
getServerContext().prop === 'updated from client'
? 'unexpectedly modified ❌'
: 'not modified ✅';
},
updateNonChanging() {
getContext().nonChanging = 'modified from client';
Expand Down Expand Up @@ -61,6 +60,11 @@ store( 'test/get-server-context', {
if ( inherited?.newProp ) {
ctx.inherited.newProp = inherited.newProp;
}
if ( ctx.objCopiedFromServer ) {
ctx.objCopiedFromServer.prop = nested?.prop;
} else {
ctx.objCopiedFromServer = nested;
}
},
updateNonChanging() {
// This property never changes in the server, but it changes in the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
>
<div data-testid="prop" data-wp-text="state.prop"></div>
<div data-testid="nested.prop" data-wp-text="state.nested.prop"></div>
<div data-testid="objCopiedFromServer" data-wp-text="state.objCopiedFromServer.prop"></div>
<div data-testid="newProp" data-wp-text="state.newProp"></div>
<div data-testid="nested.newProp" data-wp-text="state.nested.newProp"></div>
<div data-testid="nonChanging" data-wp-text="state.nonChanging"></div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,11 @@ const { state } = store( 'test/get-server-state', {
yield actions.navigate( e.target.href );
} ),
attemptModification() {
try {
getServerState().prop = 'updated from client';
getContext().result = 'unexpectedly modified ❌';
} catch ( e ) {
getContext().result = 'not modified ✅';
}
getServerState().prop = 'updated from client';
getContext().result =
getServerState().prop === 'updated from client'
? 'unexpectedly modified ❌'
: 'not modified ✅';
},
updateNonChanging() {
state.nonChanging = 'modified from client';
Expand All @@ -40,6 +39,11 @@ const { state } = store( 'test/get-server-state', {
if ( nested.newProp ) {
state.nested.newProp = nested?.newProp;
}
if ( state.objCopiedFromServer ) {
state.objCopiedFromServer.prop = nested?.prop;
} else {
state.objCopiedFromServer = nested;
}
},
updateNonChanging() {
// This property never changes in the server, but it changes in the
Expand Down
4 changes: 4 additions & 0 deletions packages/interactivity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Bug Fixes

- Return a deep-clone object from `getServerState` and `getServerContext` functions. ([#73437](https://github.com/WordPress/gutenberg/pull/73437))

## 6.35.0 (2025-11-12)

## 6.34.0 (2025-10-29)
Expand Down
29 changes: 4 additions & 25 deletions packages/interactivity/src/directives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
warn,
splitTask,
isPlainObject,
deepReadOnly,
deepClone,
} from './utils';
import {
directive,
Expand Down Expand Up @@ -69,27 +69,6 @@ const warnWithSyncEvent = ( wrongPrefix: string, rightPrefix: string ) => {
}
};

/**
* Recursively clones the passed object.
*
* @param source Source object.
* @return Cloned object.
*/
function deepClone< T >( source: T ): T {
if ( isPlainObject( source ) ) {
return Object.fromEntries(
Object.entries( source as object ).map( ( [ key, value ] ) => [
key,
deepClone( value ),
] )
) as T;
}
if ( Array.isArray( source ) ) {
return source.map( ( i ) => deepClone( i ) ) as T;
}
return source;
}

/**
* Wraps event object to warn about access of synchronous properties and methods.
*
Expand Down Expand Up @@ -423,9 +402,9 @@ export default () => {
false
);

// Sets the server context for that namespace to a deep
// read-only.
server[ namespace ] = deepReadOnly( value );
// Replaces the server context for that namespace with the
// current value.
server[ namespace ] = value;

// Registers the namespace.
namespaces.add( namespace );
Expand Down
19 changes: 7 additions & 12 deletions packages/interactivity/src/scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import type { h as createElement, RefObject } from 'preact';
* Internal dependencies
*/
import { getNamespace } from './namespaces';
import { deepReadOnly, navigationSignal } from './utils';
import type { DeepReadonly } from './utils';
import { deepReadOnly, navigationSignal, deepClone } from './utils';
import type { Evaluate } from './hooks';

export interface Scope {
Expand Down Expand Up @@ -86,8 +85,8 @@ export const getElement = () => {
/**
* Gets the context defined and updated from the server.
*
* The object returned is read-only, and includes the context defined in PHP
* with `data-wp-context` directives, including the corresponding inherited
* The object returned is a deep clone of the context defined in PHP with
* `data-wp-context` directives, including the corresponding inherited
* properties. When `actions.navigate()` is called, this object is updated to
* reflect the changes in the new visited page, without affecting the context
* returned by `getContext()`. Directives can subscribe to those changes to
Expand All @@ -113,13 +112,9 @@ export const getElement = () => {
*/
export function getServerContext(
namespace?: string
): DeepReadonly< Record< string, unknown > >;
export function getServerContext< T extends object >(
namespace?: string
): DeepReadonly< T >;
export function getServerContext< T extends object >(
namespace?: string
): DeepReadonly< T > {
): Record< string, unknown >;
export function getServerContext< T extends object >( namespace?: string ): T;
export function getServerContext< T extends object >( namespace?: string ): T {
const scope = getScope();

if ( globalThis.SCRIPT_DEBUG ) {
Expand All @@ -132,6 +127,6 @@ export function getServerContext< T extends object >(
// to prevent the JavaScript minifier from removing this line.
getServerContext.subscribe = navigationSignal.value;

return scope.serverContext[ namespace || getNamespace() ];
return deepClone( scope.serverContext[ namespace || getNamespace() ] );
}
getServerContext.subscribe = 0;
23 changes: 8 additions & 15 deletions packages/interactivity/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@
import { proxifyState, proxifyStore, deepMerge, peek } from './proxies';
import { PENDING_GETTER } from './proxies/state';
import { getNamespace } from './namespaces';
import { isPlainObject, deepReadOnly, navigationSignal } from './utils';
import type { DeepReadonly } from './utils';
import { isPlainObject, navigationSignal, deepClone } from './utils';

export const stores = new Map();
const rawStores = new Map();
Expand All @@ -25,7 +24,7 @@ export const getConfig = ( namespace?: string ) =>
/**
* Gets the state defined and updated from the server.
*
* The object returned is read-only, and includes the state defined in PHP with
* The object returned is a deep clone of the state defined in PHP with
* `wp_interactivity_state()`. When using `actions.navigate()`, this object is
* updated to reflect the changes in its properties, without affecting the state
* returned by `store()`. Directives can subscribe to those changes to update
Expand All @@ -48,24 +47,18 @@ export const getConfig = ( namespace?: string ) =>
* the store where it is defined.
* @return The server state for the given namespace.
*/
export function getServerState(
namespace?: string
): DeepReadonly< Record< string, unknown > >;
export function getServerState< T extends object >(
namespace?: string
): DeepReadonly< T >;
export function getServerState< T extends object >(
namespace?: string
): DeepReadonly< T > {
export function getServerState( namespace?: string ): Record< string, unknown >;
export function getServerState< T extends object >( namespace?: string ): T;
export function getServerState< T extends object >( namespace?: string ): T {
const ns = namespace || getNamespace();
if ( ! serverStates.has( ns ) ) {
serverStates.set( ns, deepReadOnly( {} ) );
serverStates.set( ns, {} );
}
// Accesses the navigation signal to make this reactive. It assigns it to an
// arbitrary property (`subscribe`) to prevent the JavaScript minifier from
// removing this line.
getServerState.subscribe = navigationSignal.value;
return serverStates.get( ns ) as DeepReadonly< T >;
return deepClone( serverStates.get( ns ) ) as T;
}
getServerState.subscribe = 0;

Expand Down Expand Up @@ -295,7 +288,7 @@ export const populateServerData = ( data?: {
Object.entries( data!.state ).forEach( ( [ namespace, state ] ) => {
const st = store< any >( namespace, {}, { lock: universalUnlock } );
deepMerge( st.state, state, false );
serverStates.set( namespace, deepReadOnly( state! ) );
serverStates.set( namespace, state! );
} );
}
if ( isPlainObject( data?.config ) ) {
Expand Down
20 changes: 4 additions & 16 deletions packages/interactivity/src/test/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,17 +375,15 @@ describe( 'Interactivity API types', () => {
} );

describe( 'getServerState', () => {
describe( 'should return a read-only generic object when no type is passed', () => {
describe( 'should return a generic object when no type is passed', () => {
// eslint-disable-next-line no-unused-expressions
() => {
const state = getServerState();
// @ts-expect-error
state.nonModifiable = 'error';
state.nonExistent satisfies any;
};
} );

describe( 'should accept a type parameter to define the returned object type, but convert it to read-only', () => {
describe( 'should accept a type parameter to define the returned object type', () => {
// eslint-disable-next-line no-unused-expressions
() => {
interface State {
Expand All @@ -397,28 +395,22 @@ describe( 'Interactivity API types', () => {
const state = getServerState< State >();
// @ts-expect-error
state.nonExistent = 'error';
// @ts-expect-error
state.foo = 'error';
// @ts-expect-error
state.bar.baz = 1;
state.foo satisfies string;
state.bar.baz satisfies number;
};
} );
} );

describe( 'getServerContext', () => {
describe( 'should return a read-only generic object when no type is passed', () => {
describe( 'should return a generic object when no type is passed', () => {
// eslint-disable-next-line no-unused-expressions
() => {
const context = getServerContext();
// @ts-expect-error
context.nonModifiable = 'error';
context.nonExistent satisfies any;
};
} );

describe( 'should accept a type parameter to define the returned object type, but convert it to read-only', () => {
describe( 'should accept a type parameter to define the returned object type', () => {
// eslint-disable-next-line no-unused-expressions
() => {
interface Context {
Expand All @@ -430,10 +422,6 @@ describe( 'Interactivity API types', () => {
const context = getServerContext< Context >();
// @ts-expect-error
context.nonExistent = 'error';
// @ts-expect-error
context.foo = 'error';
// @ts-expect-error
context.bar.baz = 1;
context.foo satisfies string;
context.bar.baz satisfies number;
};
Expand Down
21 changes: 21 additions & 0 deletions packages/interactivity/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,3 +485,24 @@ export function deepReadOnly< T extends object >(
}

export const navigationSignal = signal( 0 );

/**
* Recursively clones the passed object.
*
* @param source Source object.
* @return Cloned object.
*/
export function deepClone< T >( source: T ): T {
if ( isPlainObject( source ) ) {
return Object.fromEntries(
Object.entries( source as object ).map( ( [ key, value ] ) => [
key,
deepClone( value ),
] )
) as T;
}
if ( Array.isArray( source ) ) {
return source.map( ( i ) => deepClone( i ) ) as T;
}
return source;
}
4 changes: 4 additions & 0 deletions test/e2e/specs/interactivity/get-sever-context.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,25 +107,29 @@ test.describe( 'getServerContext()', () => {
test( 'should update modified props on navigation', async ( { page } ) => {
const prop = page.getByTestId( 'prop' );
const nestedProp = page.getByTestId( 'nested.prop' );
const objCopiedFromServer = page.getByTestId( 'objCopiedFromServer' );
const inheritedProp = page.getByTestId( 'inherited.prop' );

await expect( page ).toHaveTitle( /main/ );
await expect( prop ).toHaveText( 'child' );
await expect( nestedProp ).toHaveText( 'child' );
await expect( objCopiedFromServer ).toHaveText( 'child' );
await expect( inheritedProp ).toHaveText( 'parent' );

await page.getByTestId( 'modified' ).click();
await expect( page ).toHaveTitle( /modified/ );

await expect( prop ).toHaveText( 'childModified' );
await expect( nestedProp ).toHaveText( 'childModified' );
await expect( objCopiedFromServer ).toHaveText( 'childModified' );
await expect( inheritedProp ).toHaveText( 'parentModified' );

await page.goBack();
await expect( page ).toHaveTitle( /main/ );

await expect( prop ).toHaveText( 'child' );
await expect( nestedProp ).toHaveText( 'child' );
await expect( objCopiedFromServer ).toHaveText( 'child' );
await expect( inheritedProp ).toHaveText( 'parent' );
} );

Expand Down
5 changes: 5 additions & 0 deletions test/e2e/specs/interactivity/get-sever-state.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,28 +70,33 @@ test.describe( 'getServerState()', () => {
} ) => {
const prop = page.getByTestId( 'prop' );
const nestedProp = page.getByTestId( 'nested.prop' );
const objCopiedFromServer = page.getByTestId( 'objCopiedFromServer' );

await expect( page ).toHaveTitle( /main/ );
await expect( prop ).toHaveText( 'main' );
await expect( nestedProp ).toHaveText( 'main' );
await expect( objCopiedFromServer ).toHaveText( 'main' );

await page.getByTestId( 'link 1' ).click();
await expect( page ).toHaveTitle( /link 1/ );

await expect( prop ).toHaveText( 'link 1' );
await expect( nestedProp ).toHaveText( 'link 1' );
await expect( objCopiedFromServer ).toHaveText( 'link 1' );

await page.goBack();
await expect( page ).toHaveTitle( /main/ );

await expect( prop ).toHaveText( 'main' );
await expect( nestedProp ).toHaveText( 'main' );
await expect( objCopiedFromServer ).toHaveText( 'main' );

await page.getByTestId( 'link 2' ).click();
await expect( page ).toHaveTitle( /link 2/ );

await expect( prop ).toHaveText( 'link 2' );
await expect( nestedProp ).toHaveText( 'link 2' );
await expect( objCopiedFromServer ).toHaveText( 'link 2' );
} );

test( 'should add new state props and keep them on navigation', async ( {
Expand Down
Loading