Skip to content

Commit bf8215d

Browse files
DAreRodzDAreRodzluisherranzt-hamano
authored
iAPI: Return a deep-clone object from getServerState and getServerContext functions (#73437)
* Move `deepClone` to utils * Stop using `deepReadOnly` in getServer functions * Update `getServerState` and `getServerContext` e2e tests * Update `getServerState` and `getServerContext` TSDocs * Add changelog update * Remove "read-only" from type tests * Add e2e test for copying obj from server state * Add e2e test for copying obj from server context --------- Co-authored-by: DAreRodz <darerodz@git.wordpress.org> Co-authored-by: luisherranz <luisherranz@git.wordpress.org> Co-authored-by: t-hamano <wildworks@git.wordpress.org>
1 parent 8e86b65 commit bf8215d

File tree

12 files changed

+79
-80
lines changed

12 files changed

+79
-80
lines changed

packages/e2e-tests/plugins/interactive-blocks/get-server-context/render.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
>
4848
<div data-testid="prop" data-wp-text="context.prop"></div>
4949
<div data-testid="nested.prop" data-wp-text="context.nested.prop"></div>
50+
<div data-testid="objCopiedFromServer" data-wp-text="context.objCopiedFromServer.prop"></div>
5051
<div data-testid="newProp" data-wp-text="context.newProp"></div>
5152
<div data-testid="nested.newProp" data-wp-text="context.nested.newProp"></div>
5253
<div data-testid="inherited.prop" data-wp-text="context.inherited.prop"></div>

packages/e2e-tests/plugins/interactive-blocks/get-server-context/view.js

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,11 @@ store( 'test/get-server-context', {
1818
yield actions.navigate( e.target.href );
1919
} ),
2020
attemptModification() {
21-
try {
22-
getServerContext().prop = 'updated from client';
23-
getContext().result = 'unexpectedly modified ❌';
24-
} catch ( e ) {
25-
getContext().result = 'not modified ✅';
26-
}
21+
getServerContext().prop = 'updated from client';
22+
getContext().result =
23+
getServerContext().prop === 'updated from client'
24+
? 'unexpectedly modified ❌'
25+
: 'not modified ✅';
2726
},
2827
updateNonChanging() {
2928
getContext().nonChanging = 'modified from client';
@@ -61,6 +60,11 @@ store( 'test/get-server-context', {
6160
if ( inherited?.newProp ) {
6261
ctx.inherited.newProp = inherited.newProp;
6362
}
63+
if ( ctx.objCopiedFromServer ) {
64+
ctx.objCopiedFromServer.prop = nested?.prop;
65+
} else {
66+
ctx.objCopiedFromServer = nested;
67+
}
6468
},
6569
updateNonChanging() {
6670
// This property never changes in the server, but it changes in the

packages/e2e-tests/plugins/interactive-blocks/get-server-state/render.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
>
2222
<div data-testid="prop" data-wp-text="state.prop"></div>
2323
<div data-testid="nested.prop" data-wp-text="state.nested.prop"></div>
24+
<div data-testid="objCopiedFromServer" data-wp-text="state.objCopiedFromServer.prop"></div>
2425
<div data-testid="newProp" data-wp-text="state.newProp"></div>
2526
<div data-testid="nested.newProp" data-wp-text="state.nested.newProp"></div>
2627
<div data-testid="nonChanging" data-wp-text="state.nonChanging"></div>

packages/e2e-tests/plugins/interactive-blocks/get-server-state/view.js

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,11 @@ const { state } = store( 'test/get-server-state', {
1818
yield actions.navigate( e.target.href );
1919
} ),
2020
attemptModification() {
21-
try {
22-
getServerState().prop = 'updated from client';
23-
getContext().result = 'unexpectedly modified ❌';
24-
} catch ( e ) {
25-
getContext().result = 'not modified ✅';
26-
}
21+
getServerState().prop = 'updated from client';
22+
getContext().result =
23+
getServerState().prop === 'updated from client'
24+
? 'unexpectedly modified ❌'
25+
: 'not modified ✅';
2726
},
2827
updateNonChanging() {
2928
state.nonChanging = 'modified from client';
@@ -40,6 +39,11 @@ const { state } = store( 'test/get-server-state', {
4039
if ( nested.newProp ) {
4140
state.nested.newProp = nested?.newProp;
4241
}
42+
if ( state.objCopiedFromServer ) {
43+
state.objCopiedFromServer.prop = nested?.prop;
44+
} else {
45+
state.objCopiedFromServer = nested;
46+
}
4347
},
4448
updateNonChanging() {
4549
// This property never changes in the server, but it changes in the

packages/interactivity/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Bug Fixes
6+
7+
- Return a deep-clone object from `getServerState` and `getServerContext` functions. ([#73437](https://github.com/WordPress/gutenberg/pull/73437))
8+
59
## 6.35.0 (2025-11-12)
610

711
## 6.34.0 (2025-10-29)

packages/interactivity/src/directives.tsx

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
warn,
2424
splitTask,
2525
isPlainObject,
26-
deepReadOnly,
26+
deepClone,
2727
} from './utils';
2828
import {
2929
directive,
@@ -69,27 +69,6 @@ const warnWithSyncEvent = ( wrongPrefix: string, rightPrefix: string ) => {
6969
}
7070
};
7171

72-
/**
73-
* Recursively clones the passed object.
74-
*
75-
* @param source Source object.
76-
* @return Cloned object.
77-
*/
78-
function deepClone< T >( source: T ): T {
79-
if ( isPlainObject( source ) ) {
80-
return Object.fromEntries(
81-
Object.entries( source as object ).map( ( [ key, value ] ) => [
82-
key,
83-
deepClone( value ),
84-
] )
85-
) as T;
86-
}
87-
if ( Array.isArray( source ) ) {
88-
return source.map( ( i ) => deepClone( i ) ) as T;
89-
}
90-
return source;
91-
}
92-
9372
/**
9473
* Wraps event object to warn about access of synchronous properties and methods.
9574
*
@@ -423,9 +402,9 @@ export default () => {
423402
false
424403
);
425404

426-
// Sets the server context for that namespace to a deep
427-
// read-only.
428-
server[ namespace ] = deepReadOnly( value );
405+
// Replaces the server context for that namespace with the
406+
// current value.
407+
server[ namespace ] = value;
429408

430409
// Registers the namespace.
431410
namespaces.add( namespace );

packages/interactivity/src/scopes.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import type { h as createElement, RefObject } from 'preact';
77
* Internal dependencies
88
*/
99
import { getNamespace } from './namespaces';
10-
import { deepReadOnly, navigationSignal } from './utils';
11-
import type { DeepReadonly } from './utils';
10+
import { deepReadOnly, navigationSignal, deepClone } from './utils';
1211
import type { Evaluate } from './hooks';
1312

1413
export interface Scope {
@@ -86,8 +85,8 @@ export const getElement = () => {
8685
/**
8786
* Gets the context defined and updated from the server.
8887
*
89-
* The object returned is read-only, and includes the context defined in PHP
90-
* with `data-wp-context` directives, including the corresponding inherited
88+
* The object returned is a deep clone of the context defined in PHP with
89+
* `data-wp-context` directives, including the corresponding inherited
9190
* properties. When `actions.navigate()` is called, this object is updated to
9291
* reflect the changes in the new visited page, without affecting the context
9392
* returned by `getContext()`. Directives can subscribe to those changes to
@@ -113,13 +112,9 @@ export const getElement = () => {
113112
*/
114113
export function getServerContext(
115114
namespace?: string
116-
): DeepReadonly< Record< string, unknown > >;
117-
export function getServerContext< T extends object >(
118-
namespace?: string
119-
): DeepReadonly< T >;
120-
export function getServerContext< T extends object >(
121-
namespace?: string
122-
): DeepReadonly< T > {
115+
): Record< string, unknown >;
116+
export function getServerContext< T extends object >( namespace?: string ): T;
117+
export function getServerContext< T extends object >( namespace?: string ): T {
123118
const scope = getScope();
124119

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

135-
return scope.serverContext[ namespace || getNamespace() ];
130+
return deepClone( scope.serverContext[ namespace || getNamespace() ] );
136131
}
137132
getServerContext.subscribe = 0;

packages/interactivity/src/store.ts

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44
import { proxifyState, proxifyStore, deepMerge, peek } from './proxies';
55
import { PENDING_GETTER } from './proxies/state';
66
import { getNamespace } from './namespaces';
7-
import { isPlainObject, deepReadOnly, navigationSignal } from './utils';
8-
import type { DeepReadonly } from './utils';
7+
import { isPlainObject, navigationSignal, deepClone } from './utils';
98

109
export const stores = new Map();
1110
const rawStores = new Map();
@@ -25,7 +24,7 @@ export const getConfig = ( namespace?: string ) =>
2524
/**
2625
* Gets the state defined and updated from the server.
2726
*
28-
* The object returned is read-only, and includes the state defined in PHP with
27+
* The object returned is a deep clone of the state defined in PHP with
2928
* `wp_interactivity_state()`. When using `actions.navigate()`, this object is
3029
* updated to reflect the changes in its properties, without affecting the state
3130
* returned by `store()`. Directives can subscribe to those changes to update
@@ -48,24 +47,18 @@ export const getConfig = ( namespace?: string ) =>
4847
* the store where it is defined.
4948
* @return The server state for the given namespace.
5049
*/
51-
export function getServerState(
52-
namespace?: string
53-
): DeepReadonly< Record< string, unknown > >;
54-
export function getServerState< T extends object >(
55-
namespace?: string
56-
): DeepReadonly< T >;
57-
export function getServerState< T extends object >(
58-
namespace?: string
59-
): DeepReadonly< T > {
50+
export function getServerState( namespace?: string ): Record< string, unknown >;
51+
export function getServerState< T extends object >( namespace?: string ): T;
52+
export function getServerState< T extends object >( namespace?: string ): T {
6053
const ns = namespace || getNamespace();
6154
if ( ! serverStates.has( ns ) ) {
62-
serverStates.set( ns, deepReadOnly( {} ) );
55+
serverStates.set( ns, {} );
6356
}
6457
// Accesses the navigation signal to make this reactive. It assigns it to an
6558
// arbitrary property (`subscribe`) to prevent the JavaScript minifier from
6659
// removing this line.
6760
getServerState.subscribe = navigationSignal.value;
68-
return serverStates.get( ns ) as DeepReadonly< T >;
61+
return deepClone( serverStates.get( ns ) ) as T;
6962
}
7063
getServerState.subscribe = 0;
7164

@@ -295,7 +288,7 @@ export const populateServerData = ( data?: {
295288
Object.entries( data!.state ).forEach( ( [ namespace, state ] ) => {
296289
const st = store< any >( namespace, {}, { lock: universalUnlock } );
297290
deepMerge( st.state, state, false );
298-
serverStates.set( namespace, deepReadOnly( state! ) );
291+
serverStates.set( namespace, state! );
299292
} );
300293
}
301294
if ( isPlainObject( data?.config ) ) {

packages/interactivity/src/test/types.ts

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -375,17 +375,15 @@ describe( 'Interactivity API types', () => {
375375
} );
376376

377377
describe( 'getServerState', () => {
378-
describe( 'should return a read-only generic object when no type is passed', () => {
378+
describe( 'should return a generic object when no type is passed', () => {
379379
// eslint-disable-next-line no-unused-expressions
380380
() => {
381381
const state = getServerState();
382-
// @ts-expect-error
383-
state.nonModifiable = 'error';
384382
state.nonExistent satisfies any;
385383
};
386384
} );
387385

388-
describe( 'should accept a type parameter to define the returned object type, but convert it to read-only', () => {
386+
describe( 'should accept a type parameter to define the returned object type', () => {
389387
// eslint-disable-next-line no-unused-expressions
390388
() => {
391389
interface State {
@@ -397,28 +395,22 @@ describe( 'Interactivity API types', () => {
397395
const state = getServerState< State >();
398396
// @ts-expect-error
399397
state.nonExistent = 'error';
400-
// @ts-expect-error
401-
state.foo = 'error';
402-
// @ts-expect-error
403-
state.bar.baz = 1;
404398
state.foo satisfies string;
405399
state.bar.baz satisfies number;
406400
};
407401
} );
408402
} );
409403

410404
describe( 'getServerContext', () => {
411-
describe( 'should return a read-only generic object when no type is passed', () => {
405+
describe( 'should return a generic object when no type is passed', () => {
412406
// eslint-disable-next-line no-unused-expressions
413407
() => {
414408
const context = getServerContext();
415-
// @ts-expect-error
416-
context.nonModifiable = 'error';
417409
context.nonExistent satisfies any;
418410
};
419411
} );
420412

421-
describe( 'should accept a type parameter to define the returned object type, but convert it to read-only', () => {
413+
describe( 'should accept a type parameter to define the returned object type', () => {
422414
// eslint-disable-next-line no-unused-expressions
423415
() => {
424416
interface Context {
@@ -430,10 +422,6 @@ describe( 'Interactivity API types', () => {
430422
const context = getServerContext< Context >();
431423
// @ts-expect-error
432424
context.nonExistent = 'error';
433-
// @ts-expect-error
434-
context.foo = 'error';
435-
// @ts-expect-error
436-
context.bar.baz = 1;
437425
context.foo satisfies string;
438426
context.bar.baz satisfies number;
439427
};

packages/interactivity/src/utils.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,3 +485,24 @@ export function deepReadOnly< T extends object >(
485485
}
486486

487487
export const navigationSignal = signal( 0 );
488+
489+
/**
490+
* Recursively clones the passed object.
491+
*
492+
* @param source Source object.
493+
* @return Cloned object.
494+
*/
495+
export function deepClone< T >( source: T ): T {
496+
if ( isPlainObject( source ) ) {
497+
return Object.fromEntries(
498+
Object.entries( source as object ).map( ( [ key, value ] ) => [
499+
key,
500+
deepClone( value ),
501+
] )
502+
) as T;
503+
}
504+
if ( Array.isArray( source ) ) {
505+
return source.map( ( i ) => deepClone( i ) ) as T;
506+
}
507+
return source;
508+
}

0 commit comments

Comments
 (0)