diff --git a/packages/block-library/src/embed/util.js b/packages/block-library/src/embed/util.js index 528dec45d1ecff..2af0e6adbfc6f2 100644 --- a/packages/block-library/src/embed/util.js +++ b/packages/block-library/src/embed/util.js @@ -177,7 +177,7 @@ export const removeAspectRatioClasses = ( existingClassNames ) => { if ( ! existingClassNames ) { // Avoids extraneous work and also, by returning the same value as // received, ensures the post is not dirtied by a change of the block - // attribute from `undefined` to an emtpy string. + // attribute from `undefined` to an empty string. return existingClassNames; } const aspectRatioClassNames = ASPECT_RATIOS.reduce( diff --git a/packages/e2e-tests/plugins/interactive-blocks/namespace/block.json b/packages/e2e-tests/plugins/interactive-blocks/namespace/block.json new file mode 100644 index 00000000000000..c0382286a16216 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/namespace/block.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "test-namespace/directive-bind", + "title": "E2E Interactivity tests - directive bind", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScriptModule": "file:./view.js", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/namespace/render.php b/packages/e2e-tests/plugins/interactive-blocks/namespace/render.php new file mode 100644 index 00000000000000..6fdc2d07e350c8 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/namespace/render.php @@ -0,0 +1,23 @@ + + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
diff --git a/packages/e2e-tests/plugins/interactive-blocks/namespace/view.asset.php b/packages/e2e-tests/plugins/interactive-blocks/namespace/view.asset.php new file mode 100644 index 00000000000000..db23afdf657a19 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/namespace/view.asset.php @@ -0,0 +1 @@ + array( '@wordpress/interactivity' ) ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/namespace/view.js b/packages/e2e-tests/plugins/interactive-blocks/namespace/view.js new file mode 100644 index 00000000000000..9225f88ce9d279 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/namespace/view.js @@ -0,0 +1,16 @@ +/** + * WordPress dependencies + */ +import { store } from '@wordpress/interactivity'; + +store( 'namespace', { + state: { + url: '/some-url', + }, +} ); + +store( 'other', { + state: { + url: '/other-store-url', + }, +} ); diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md index 56352eb4c489bc..fc22086bef443a 100644 --- a/packages/interactivity/CHANGELOG.md +++ b/packages/interactivity/CHANGELOG.md @@ -7,6 +7,7 @@ - Allow multiple event handlers for the same type with `data-wp-on-document` and `data-wp-on-window`. ([#61009](https://github.com/WordPress/gutenberg/pull/61009)) - Prevent wrong written directives from killing the runtime ([#61249](https://github.com/WordPress/gutenberg/pull/61249)) +- Prevent empty namespace or different namespaces from killing the runtime ([#61409](https://github.com/WordPress/gutenberg/pull/61409)) ## 5.6.0 (2024-05-02) diff --git a/packages/interactivity/src/directives.js b/packages/interactivity/src/directives.js index c09cecb4afc127..8100ac7a52eee5 100644 --- a/packages/interactivity/src/directives.js +++ b/packages/interactivity/src/directives.js @@ -13,6 +13,7 @@ import { deepSignal, peek } from 'deepsignal'; import { useWatch, useInit } from './utils'; import { directive, getScope, getEvaluate } from './hooks'; import { kebabToCamelCase } from './utils/kebab-to-camelcase'; +import { warn } from './utils/warn'; // Assigned objects should be ignore during proxification. const contextAssignedObjects = new WeakMap(); @@ -242,13 +243,8 @@ export default () => { if ( defaultEntry ) { const { namespace, value } = defaultEntry; // Check that the value is a JSON object. Send a console warning if not. - if ( - typeof SCRIPT_DEBUG !== 'undefined' && - SCRIPT_DEBUG === true && - ! isPlainObject( value ) - ) { - // eslint-disable-next-line no-console - console.warn( + if ( ! isPlainObject( value ) ) { + warn( `The value of data-wp-context in "${ namespace }" store must be a valid stringified JSON object.` ); } diff --git a/packages/interactivity/src/hooks.tsx b/packages/interactivity/src/hooks.tsx index f223ddceb9a41f..353959ea5b2ea6 100644 --- a/packages/interactivity/src/hooks.tsx +++ b/packages/interactivity/src/hooks.tsx @@ -16,6 +16,7 @@ import type { VNode, Context, RefObject } from 'preact'; * Internal dependencies */ import { store, stores, universalUnlock } from './store'; +import { warn } from './utils/warn'; interface DirectiveEntry { value: string | Object; namespace: string; @@ -260,18 +261,26 @@ export const directive = ( // Resolve the path to some property of the store object. const resolve = ( path, namespace ) => { + if ( ! namespace ) { + warn( + `The "namespace" cannot be "{}", "null" or an empty string. Path: ${ path }` + ); + return; + } let resolvedStore = stores.get( namespace ); if ( typeof resolvedStore === 'undefined' ) { resolvedStore = store( namespace, undefined, { lock: universalUnlock, } ); } - let current = { + const current = { ...resolvedStore, context: getScope().context[ namespace ], }; - path.split( '.' ).forEach( ( p ) => ( current = current[ p ] ) ); - return current; + try { + // TODO: Support lazy/dynamically initialized stores + return path.split( '.' ).reduce( ( acc, key ) => acc[ key ], current ); + } catch ( e ) {} }; // Generate the evaluate function. diff --git a/packages/interactivity/src/utils/warn.ts b/packages/interactivity/src/utils/warn.ts new file mode 100644 index 00000000000000..98bf88c157acaf --- /dev/null +++ b/packages/interactivity/src/utils/warn.ts @@ -0,0 +1,21 @@ +const logged = new Set(); + +export const warn = ( message ) => { + // @ts-expect-error + if ( typeof SCRIPT_DEBUG !== 'undefined' && SCRIPT_DEBUG === true ) { + if ( logged.has( message ) ) { + return; + } + + // eslint-disable-next-line no-console + console.warn( message ); + + // Adding a stack trace to the warning message to help with debugging. + try { + throw Error( message ); + } catch ( e ) { + // Do nothing. + } + logged.add( message ); + } +}; diff --git a/packages/interactivity/src/vdom.ts b/packages/interactivity/src/vdom.ts index 9e6221bb871f4f..78f6d7032613a5 100644 --- a/packages/interactivity/src/vdom.ts +++ b/packages/interactivity/src/vdom.ts @@ -6,6 +6,7 @@ import { h } from 'preact'; * Internal dependencies */ import { directivePrefix as p } from './constants'; +import { warn } from './utils/warn'; const ignoreAttr = `data-${ p }-ignore`; const islandAttr = `data-${ p }-interactive`; @@ -120,15 +121,7 @@ export function toVdom( root ) { ( obj, [ name, ns, value ] ) => { const directiveMatch = directiveParser.exec( name ); if ( directiveMatch === null ) { - if ( - // @ts-expect-error This is a debug-only warning. - typeof SCRIPT_DEBUG !== 'undefined' && - // @ts-expect-error This is a debug-only warning. - SCRIPT_DEBUG === true - ) { - // eslint-disable-next-line no-console - console.warn( `Invalid directive: ${ name }.` ); - } + warn( `Invalid directive: ${ name }.` ); return obj; } const prefix = directiveMatch[ 1 ] || ''; diff --git a/test/e2e/specs/interactivity/namespace.spec.ts b/test/e2e/specs/interactivity/namespace.spec.ts new file mode 100644 index 00000000000000..fd38a7ecf6ed64 --- /dev/null +++ b/test/e2e/specs/interactivity/namespace.spec.ts @@ -0,0 +1,49 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'Namespaces', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test-namespace/directive-bind' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test-namespace/directive-bind' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'Empty string as namespace should not work', async ( { page } ) => { + const el = page.getByTestId( 'empty namespace' ); + await expect( el ).not.toHaveAttribute( 'href', '/some-url' ); + } ); + + test( 'A string as namespace should work', async ( { page } ) => { + const el = page.getByTestId( 'correct namespace' ); + await expect( el ).toHaveAttribute( 'href', '/some-url' ); + } ); + + test( 'An empty object as namespace should work', async ( { page } ) => { + const el = page.getByTestId( 'object namespace' ); + await expect( el ).not.toHaveAttribute( 'href', '/some-url' ); + } ); + + test( 'A wrong namespace should not break the runtime', async ( { + page, + } ) => { + const el = page.getByTestId( 'object namespace' ); + await expect( el ).not.toHaveAttribute( 'href', '/some-url' ); + const correct = page.getByTestId( 'correct namespace' ); + await expect( correct ).toHaveAttribute( 'href', '/some-url' ); + } ); + + test( 'A different store namespace should work', async ( { page } ) => { + const el = page.getByTestId( 'other namespace' ); + await expect( el ).toHaveAttribute( 'href', '/other-store-url' ); + } ); +} );