Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
08995e6
Preferences Persistence: Read local storage through a helper that tol…
manzoorwanijk Oct 9, 2026
4ae358c
Preferences Persistence: Ignore persisted data that is not an object
manzoorwanijk Oct 9, 2026
04760b9
Preferences Persistence: Return a new empty object instead of a share…
manzoorwanijk Oct 9, 2026
c37f1a6
Preferences Persistence: Stop the migrations mutating their input
manzoorwanijk Oct 9, 2026
6363273
Preferences: Use the PersistenceLayer type from preferences-persistence
manzoorwanijk Oct 9, 2026
e98131c
Preferences Persistence: Cover blocked storage and invalid local data
manzoorwanijk Oct 9, 2026
806dc52
Add CHANGELOG entries
manzoorwanijk Oct 9, 2026
ec2fad1
Preferences Persistence: Preload an empty object for empty user meta
manzoorwanijk Oct 9, 2026
d733e9c
Preferences: Keep its own persistence layer type to avoid a runtime d…
manzoorwanijk Oct 13, 2026
e9bff56
Preferences Persistence: Ignore legacy preferences that are not an ob…
manzoorwanijk Oct 13, 2026
8324647
Preferences Persistence: Always preload the server data
manzoorwanijk Oct 13, 2026
467514b
Preferences Persistence: Leave scopes that are not objects untouched …
manzoorwanijk Oct 13, 2026
da70576
Preferences Persistence: Exercise get and set with blocked storage in…
manzoorwanijk Oct 13, 2026
f26dc46
Preferences Persistence: Share the logic that picks the newest prefer…
manzoorwanijk Oct 13, 2026
ea33889
Preferences Persistence: Remove invalid scopes in the editor settings…
manzoorwanijk Oct 13, 2026
ae371f8
Preferences Persistence: Validate and migrate the data create() loads
manzoorwanijk Oct 13, 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
4 changes: 4 additions & 0 deletions packages/preferences-persistence/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

- The package now bundles TypeScript types ([#84312](https://github.com/WordPress/gutenberg/pull/84312)).

### Bug Fixes

- Load preferences when local storage is blocked or holds invalid JSON, and ignore persisted data that is not an object ([#84375](https://github.com/WordPress/gutenberg/pull/84375)).

## 2.57.0 (2026-10-07)

## 2.56.0 (2026-09-23)
Expand Down
39 changes: 11 additions & 28 deletions packages/preferences-persistence/src/create/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import apiFetch from '@wordpress/api-fetch';
import debounceAsync from './debounce-async';
import getNewestData from '../get-newest-data';
import isPreferencesData from '../is-preferences-data';
import { writeStoredJSON } from '../local-storage';
import type {
CreateOptions,
PersistenceLayer,
PreferencesData,
} from '../types';

const EMPTY_OBJECT: PreferencesData = {};
const localStorage = window.localStorage;

/**
* Creates a persistence layer that stores data in WordPress user meta via the
* REST API.
Expand All @@ -31,7 +31,7 @@ export default function create( {
localStorageRestoreKey = 'WP_PREFERENCES_RESTORE_DATA',
requestDebounceMS = 2500,
}: CreateOptions = {} ): PersistenceLayer {
let cache = preloadedData;
let cache = isPreferencesData( preloadedData ) ? preloadedData : undefined;
const debouncedApiFetch = debounceAsync( apiFetch, requestDebounceMS );

async function get() {
Expand All @@ -40,30 +40,16 @@ export default function create( {
}

const user = await apiFetch< {
Comment thread
manzoorwanijk marked this conversation as resolved.
meta?: { persisted_preferences?: PreferencesData | null };
meta?: { persisted_preferences?: unknown };
} >( {
path: '/wp/v2/users/me?context=edit',
} );

const serverData = user?.meta?.persisted_preferences;
const localData: PreferencesData | null = JSON.parse(
localStorage.getItem( localStorageRestoreKey ) as string
);

// Date parse returns NaN for invalid input. Coerce anything invalid
// into a conveniently comparable zero.
const serverTimestamp = Date.parse( serverData?._modified ?? '' ) || 0;
const localTimestamp = Date.parse( localData?._modified ?? '' ) || 0;

// Prefer server data if it exists and is more recent.
// Otherwise fallback to localStorage data.
if ( serverData && serverTimestamp >= localTimestamp ) {
cache = serverData;
} else if ( localData ) {
cache = localData;
} else {
cache = EMPTY_OBJECT;
}
cache =
getNewestData(
Comment thread
manzoorwanijk marked this conversation as resolved.
user?.meta?.persisted_preferences,
localStorageRestoreKey
) ?? {};

return cache;
}
Expand All @@ -78,10 +64,7 @@ export default function create( {
// Store data in local storage as a fallback. If for some reason the
// api request does not complete or becomes unavailable, this data
// can be used to restore preferences.
localStorage.setItem(
localStorageRestoreKey,
JSON.stringify( dataWithTimestamp )
);
writeStoredJSON( localStorageRestoreKey, dataWithTimestamp );

// The user meta endpoint seems susceptible to errors when consecutive
// requests are made in quick succession. Ensure there's a gap between
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe( 'create', () => {
afterEach( () => {
mockedApiFetch.mockReset();
vi.restoreAllMocks();
window.localStorage.clear();
} );

describe( 'set', () => {
Expand Down Expand Up @@ -161,5 +162,49 @@ describe( 'create', () => {
const { get } = create();
expect( await get() ).toEqual( {} );
} );

it( 'returns a new empty object for each persistence layer', async () => {
mockedApiFetch.mockResolvedValue( {
meta: { persisted_preferences: null },
} );

const first = await create().get();
first.core = { fixedToolbar: true };
expect( await create().get() ).toEqual( {} );
} );

it( 'fetches when the preloaded data is not an object', async () => {
mockedApiFetch.mockResolvedValueOnce( {
meta: {
persisted_preferences: { core: { fixedToolbar: true } },
},
} );

const { get } = create( { preloadedData: [] } );
expect( await get() ).toEqual( { core: { fixedToolbar: true } } );
expect( mockedApiFetch ).toHaveBeenCalled();
} );

it( 'migrates data from the REST API', async () => {
mockedApiFetch.mockResolvedValueOnce( {
meta: {
persisted_preferences: {
'core/edit-post': { fixedToolbar: true },
},
},
} );

const { get } = create();
expect( await get() ).toEqual( { core: { fixedToolbar: true } } );
} );

it( 'ignores an empty array from the REST API', async () => {
mockedApiFetch.mockResolvedValueOnce( {
meta: { persisted_preferences: [] },
} );

const { get } = create();
expect( await get() ).toEqual( {} );
} );
} );
} );
50 changes: 50 additions & 0 deletions packages/preferences-persistence/src/get-newest-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import isPreferencesData from './is-preferences-data';
import { readStoredJSON } from './local-storage';
import convertPreferencesPackageData from './migrations/preferences-package-data';
import type { PreferencesData, ScopedPreferences } from './types';

/**
* Picks the most recently modified preferences from the server data and the
* local storage backup, ignoring either when it is not a preferences object.
*
* @param serverData Preferences data from the server.
* @param localStorageRestoreKey The key of the local storage backup.
*
* @return The newest preferences data, or `null` when neither is valid.
*/
function pickNewestData(
serverData: unknown,
localStorageRestoreKey: string
): PreferencesData | null {
const storedData = readStoredJSON( localStorageRestoreKey );
const localData = isPreferencesData( storedData ) ? storedData : null;
if ( ! isPreferencesData( serverData ) ) {
return localData;
}

// Date parse returns NaN for invalid input. Coerce anything invalid
// into a conveniently comparable zero.
const serverModified = Date.parse( serverData._modified ?? '' ) || 0;
const localModified = Date.parse( localData?._modified ?? '' ) || 0;

// Prefer server data when it is at least as recent.
return localData && localModified > serverModified ? localData : serverData;
}

/**
* Gets the newest persisted preferences, migrated to the current format.
*
* @param serverData Preferences data from the server.
* @param localStorageRestoreKey The key of the local storage backup.
*
* @return The migrated preferences, or `undefined` when there are none.
*/
export default function getNewestData(
serverData: unknown,
localStorageRestoreKey: string
): ScopedPreferences | undefined {
const newestData = pickNewestData( serverData, localStorageRestoreKey );
return newestData
? convertPreferencesPackageData( newestData as ScopedPreferences )
: undefined;
}
46 changes: 11 additions & 35 deletions packages/preferences-persistence/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import create from './create';
import convertLegacyLocalStorageData from './migrations/legacy-local-storage-data';
import convertPreferencesPackageData from './migrations/preferences-package-data';
import type {
PersistenceLayer,
PreferencesData,
ScopedPreferences,
} from './types';
import getNewestData from './get-newest-data';
import type { PersistenceLayer } from './types';

export { create };

Expand All @@ -26,39 +22,19 @@ export type * from './types';
* @return The persistence layer initialized with the preloaded data.
*/
export function __unstableCreatePersistenceLayer(
serverData: PreferencesData | [] | '' | false,
serverData: unknown,
userId: string | number
): PersistenceLayer {
const localStorageRestoreKey = `WP_PREFERENCES_USER_${ userId }`;
const localData: PreferencesData | null = JSON.parse(
window.localStorage.getItem( localStorageRestoreKey ) as string
);

// Date parse returns NaN for invalid input. Coerce anything invalid
// into a conveniently comparable zero.
const serverModified =
Date.parse(
( serverData &&
( serverData as PreferencesData )._modified ) as string
) || 0;
const localModified = Date.parse( localData?._modified ?? '' ) || 0;

let preloadedData;
if ( serverData && serverModified >= localModified ) {
preloadedData = convertPreferencesPackageData(
serverData as ScopedPreferences
);
} else if ( localData ) {
preloadedData = convertPreferencesPackageData(
localData as ScopedPreferences
);
} else {
// Check if there is data in the legacy format from the old persistence system.
preloadedData = convertLegacyLocalStorageData( userId );
}

return create( {
preloadedData,
/*
* Fall back to data in the legacy format from the old persistence system.
* The server data is already preloaded, so fetching it again is redundant.
*/
preloadedData:
getNewestData( serverData, localStorageRestoreKey ) ??
convertLegacyLocalStorageData( userId ) ??
{},
localStorageRestoreKey,
} );
}
15 changes: 15 additions & 0 deletions packages/preferences-persistence/src/is-preferences-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { PreferencesData } from './types';

/**
* Checks whether persisted data is a preferences object. Empty user meta
* reaches the client as `[]` or `''`, which must not be used as preferences.
*
* @param data The persisted data.
*
* @return Whether the data is a preferences object.
*/
export default function isPreferencesData(
data: unknown
): data is PreferencesData {
return !! data && typeof data === 'object' && ! Array.isArray( data );
Comment thread
manzoorwanijk marked this conversation as resolved.
}
30 changes: 30 additions & 0 deletions packages/preferences-persistence/src/local-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Reads and parses a JSON value from local storage.
*
* @param key The local storage key.
*
* @return The parsed value, or `null` when the value is missing or invalid,
* or local storage is unavailable.
*/
export function readStoredJSON( key: string ): unknown {
try {
const value = window.localStorage.getItem( key );
return value === null ? null : JSON.parse( value );
} catch {
return null;
}
}

/**
* Stores a value in local storage as JSON.
*
* Failures, such as blocked storage or an exceeded quota, are ignored.
*
* @param key The local storage key.
* @param value The value to store.
*/
export function writeStoredJSON( key: string, value: unknown ) {
try {
window.localStorage.setItem( key, JSON.stringify( value ) );
} catch {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import moveThirdPartyFeaturePreferences from './move-third-party-feature-prefere
import moveIndividualPreference from './move-individual-preference';
import moveInterfaceEnableItems from './move-interface-enable-items';
import convertEditPostPanels from './convert-edit-post-panels';
import isPreferencesData from '../../is-preferences-data';
import { readStoredJSON } from '../../local-storage';
import type { ScopedPreferences } from '../../types';
import type { LegacyData } from './types';

Expand All @@ -14,9 +16,7 @@ import type { LegacyData } from './types';
* @return The local storage data.
*/
function getLegacyData( userId: string | number ): LegacyData | null {
const key = `WP_DATA_USER_${ userId }`;
const unparsedData = window.localStorage.getItem( key );
return JSON.parse( unparsedData as string );
return readStoredJSON( `WP_DATA_USER_${ userId }` ) as LegacyData | null;
Comment thread
manzoorwanijk marked this conversation as resolved.
}

/**
Expand Down Expand Up @@ -85,7 +85,8 @@ export function convertLegacyData(

// The new system is only concerned with persisting
// 'core/preferences' preferences reducer, so only return that.
return data?.[ 'core/preferences' ]?.preferences;
const preferences = data?.[ 'core/preferences' ]?.preferences;
return isPreferencesData( preferences ) ? preferences : undefined;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,4 +214,10 @@ describe( 'convertLegacyData', () => {
}
` );
} );

it( 'ignores legacy preferences that are not an object', () => {
expect(
convertLegacyData( { 'core/preferences': { preferences: [] } } )
).toBeUndefined();
} );
} );
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import type { ScopedPreferences } from '../../types';

export default function convertComplementaryAreas( state: ScopedPreferences ) {
return Object.keys( state ).reduce( ( stateAccumulator, scope ) => {
const scopeData = state[ scope ];
return Object.keys( state ).reduce(
( stateAccumulator, scope ) => {
const scopeData = state[ scope ];

// If a complementary area is truthy, convert it to the `isComplementaryAreaVisible` boolean.
if ( scopeData?.complementaryArea ) {
const updatedScopeData = { ...scopeData };
delete updatedScopeData.complementaryArea;
updatedScopeData.isComplementaryAreaVisible = true;
stateAccumulator[ scope ] = updatedScopeData;
return stateAccumulator;
}
// If a complementary area is truthy, convert it to the `isComplementaryAreaVisible` boolean.
if ( scopeData?.complementaryArea ) {
const updatedScopeData = { ...scopeData };
delete updatedScopeData.complementaryArea;
updatedScopeData.isComplementaryAreaVisible = true;
stateAccumulator[ scope ] = updatedScopeData;
return stateAccumulator;
}

return stateAccumulator;
}, state );
return stateAccumulator;
},
{ ...state }
);
}
Loading
Loading