@wordpress/interactivity
Advanced tools
Comparing version 5.1.0 to 5.2.0
@@ -16,12 +16,127 @@ /* @jsx createElement */ | ||
import { kebabToCamelCase } from './utils/kebab-to-camelcase'; | ||
const isObject = item => item && typeof item === 'object' && !Array.isArray(item); | ||
const mergeDeepSignals = (target, source, overwrite) => { | ||
// Assigned objects should be ignore during proxification. | ||
const contextAssignedObjects = new WeakMap(); | ||
// Store the context proxy and fallback for each object in the context. | ||
const contextObjectToProxy = new WeakMap(); | ||
const contextProxyToObject = new WeakMap(); | ||
const contextObjectToFallback = new WeakMap(); | ||
const isPlainObject = item => item && typeof item === 'object' && item.constructor === Object; | ||
const descriptor = Reflect.getOwnPropertyDescriptor; | ||
/** | ||
* Wrap a context object with a proxy to reproduce the context stack. The proxy | ||
* uses the passed `inherited` context as a fallback to look up for properties | ||
* that don't exist in the given context. Also, updated properties are modified | ||
* where they are defined, or added to the main context when they don't exist. | ||
* | ||
* By default, all plain objects inside the context are wrapped, unless it is | ||
* listed in the `ignore` option. | ||
* | ||
* @param {Object} current Current context. | ||
* @param {Object} inherited Inherited context, used as fallback. | ||
* | ||
* @return {Object} The wrapped context object. | ||
*/ | ||
const proxifyContext = (current, inherited = {}) => { | ||
// Update the fallback object reference when it changes. | ||
contextObjectToFallback.set(current, inherited); | ||
if (!contextObjectToProxy.has(current)) { | ||
const proxy = new Proxy(current, { | ||
get: (target, k) => { | ||
const fallback = contextObjectToFallback.get(current); | ||
// Always subscribe to prop changes in the current context. | ||
const currentProp = target[k]; | ||
// Return the inherited prop when missing in target. | ||
if (!(k in target) && k in fallback) { | ||
return fallback[k]; | ||
} | ||
// Proxify plain objects that were not directly assigned. | ||
if (k in target && !contextAssignedObjects.get(target)?.has(k) && isPlainObject(peek(target, k))) { | ||
return proxifyContext(currentProp, fallback[k]); | ||
} | ||
// Return the stored proxy for `currentProp` when it exists. | ||
if (contextObjectToProxy.has(currentProp)) { | ||
return contextObjectToProxy.get(currentProp); | ||
} | ||
/* | ||
* For other cases, return the value from target, also | ||
* subscribing to changes in the parent context when the current | ||
* prop is not defined. | ||
*/ | ||
return k in target ? currentProp : fallback[k]; | ||
}, | ||
set: (target, k, value) => { | ||
const fallback = contextObjectToFallback.get(current); | ||
const obj = k in target || !(k in fallback) ? target : fallback; | ||
/* | ||
* Assigned object values should not be proxified so they point | ||
* to the original object and don't inherit unexpected | ||
* properties. | ||
*/ | ||
if (value && typeof value === 'object') { | ||
if (!contextAssignedObjects.has(obj)) { | ||
contextAssignedObjects.set(obj, new Set()); | ||
} | ||
contextAssignedObjects.get(obj).add(k); | ||
} | ||
/* | ||
* When the value is a proxy, it's because it comes from the | ||
* context, so the inner value is assigned instead. | ||
*/ | ||
if (contextProxyToObject.has(value)) { | ||
const innerValue = contextProxyToObject.get(value); | ||
obj[k] = innerValue; | ||
} else { | ||
obj[k] = value; | ||
} | ||
return true; | ||
}, | ||
ownKeys: target => [...new Set([...Object.keys(contextObjectToFallback.get(current)), ...Object.keys(target)])], | ||
getOwnPropertyDescriptor: (target, k) => descriptor(target, k) || descriptor(contextObjectToFallback.get(current), k) | ||
}); | ||
contextObjectToProxy.set(current, proxy); | ||
contextProxyToObject.set(proxy, current); | ||
} | ||
return contextObjectToProxy.get(current); | ||
}; | ||
/** | ||
* Recursively update values within a deepSignal object. | ||
* | ||
* @param {Object} target A deepSignal instance. | ||
* @param {Object} source Object with properties to update in `target` | ||
*/ | ||
const updateSignals = (target, source) => { | ||
for (const k in source) { | ||
if (isObject(peek(target, k)) && isObject(peek(source, k))) { | ||
mergeDeepSignals(target[`$${k}`].peek(), source[`$${k}`].peek(), overwrite); | ||
} else if (overwrite || typeof peek(target, k) === 'undefined') { | ||
target[`$${k}`] = source[`$${k}`]; | ||
if (isPlainObject(peek(target, k)) && isPlainObject(peek(source, k))) { | ||
updateSignals(target[`$${k}`].peek(), source[k]); | ||
} else { | ||
target[k] = source[k]; | ||
} | ||
} | ||
}; | ||
/** | ||
* Recursively clone the passed object. | ||
* | ||
* @param {Object} source Source object. | ||
* @return {Object} Cloned object. | ||
*/ | ||
const deepClone = source => { | ||
if (isPlainObject(source)) { | ||
return Object.fromEntries(Object.entries(source).map(([key, value]) => [key, deepClone(value)])); | ||
} | ||
if (Array.isArray(source)) { | ||
return source.map(i => deepClone(i)); | ||
} | ||
return source; | ||
}; | ||
const newRule = /(?:([\u0080-\uFFFF\w-%@]+) *:? *([^{;]+?);|([^;}{]*?) *{)|(}\s*)/g; | ||
@@ -98,20 +213,19 @@ const ruleClean = /\/\*[^]*?\*\/| +/g; | ||
}) => suffix === 'default'); | ||
currentValue.current = useMemo(() => { | ||
if (!defaultEntry) return null; | ||
const { | ||
namespace, | ||
value | ||
} = defaultEntry; | ||
const newValue = deepSignal({ | ||
[namespace]: value | ||
}); | ||
mergeDeepSignals(newValue, inheritedValue); | ||
mergeDeepSignals(currentValue.current, newValue, true); | ||
return currentValue.current; | ||
}, [inheritedValue, defaultEntry]); | ||
if (currentValue.current) { | ||
return createElement(Provider, { | ||
value: currentValue.current | ||
}, children); | ||
} | ||
// No change should be made if `defaultEntry` does not exist. | ||
const contextStack = useMemo(() => { | ||
if (defaultEntry) { | ||
const { | ||
namespace, | ||
value | ||
} = defaultEntry; | ||
updateSignals(currentValue.current, { | ||
[namespace]: deepClone(value) | ||
}); | ||
} | ||
return proxifyContext(currentValue.current, inheritedValue); | ||
}, [defaultEntry, inheritedValue]); | ||
return createElement(Provider, { | ||
value: contextStack | ||
}, children); | ||
}, { | ||
@@ -365,11 +479,10 @@ priority: 5 | ||
return list.map(item => { | ||
const mergedContext = deepSignal({}); | ||
const itemProp = suffix === 'default' ? 'item' : kebabToCamelCase(suffix); | ||
const newValue = deepSignal({ | ||
[namespace]: { | ||
[itemProp]: item | ||
} | ||
const itemContext = deepSignal({ | ||
[namespace]: {} | ||
}); | ||
mergeDeepSignals(newValue, inheritedValue); | ||
mergeDeepSignals(mergedContext, newValue, true); | ||
const mergedContext = proxifyContext(itemContext, inheritedValue); | ||
// Set the item after proxifying the context. | ||
mergedContext[namespace][itemProp] = item; | ||
const scope = { | ||
@@ -376,0 +489,0 @@ ...getScope(), |
@@ -5,2 +5,3 @@ /** | ||
import { h, cloneElement, render } from 'preact'; | ||
import { batch } from '@preact/signals'; | ||
import { deepSignal } from 'deepsignal'; | ||
@@ -16,2 +17,3 @@ | ||
import { directive, getNamespace } from './hooks'; | ||
import { parseInitialData, populateInitialData } from './store'; | ||
export { store, getConfig } from './store'; | ||
@@ -34,3 +36,6 @@ export { getContext, getElement } from './hooks'; | ||
render, | ||
deepSignal | ||
deepSignal, | ||
parseInitialData, | ||
populateInitialData, | ||
batch | ||
}; | ||
@@ -37,0 +42,0 @@ } |
@@ -21,10 +21,11 @@ /** | ||
} else if (isObject(source[key])) { | ||
if (!target[key]) Object.assign(target, { | ||
[key]: {} | ||
}); | ||
if (!target[key]) target[key] = {}; | ||
deepMerge(target[key], source[key]); | ||
} else { | ||
Object.assign(target, { | ||
[key]: source[key] | ||
}); | ||
try { | ||
target[key] = source[key]; | ||
} catch (e) { | ||
// Assignemnts fail for properties that are only getters. | ||
// When that's the case, the assignment is simply ignored. | ||
} | ||
} | ||
@@ -34,13 +35,2 @@ } | ||
}; | ||
const parseInitialData = () => { | ||
const storeTag = document.querySelector(`script[type="application/json"]#wp-interactivity-data`); | ||
if (storeTag?.textContent) { | ||
try { | ||
return JSON.parse(storeTag.textContent); | ||
} catch (e) { | ||
// Do nothing. | ||
} | ||
} | ||
return {}; | ||
}; | ||
export const stores = new Map(); | ||
@@ -88,3 +78,3 @@ const rawStores = new Map(); | ||
} | ||
const result = Reflect.get(target, key, receiver); | ||
const result = Reflect.get(target, key); | ||
@@ -95,3 +85,3 @@ // Check if the proxy is the store root and no key with that name exist. In | ||
const obj = {}; | ||
Reflect.set(target, key, obj, receiver); | ||
Reflect.set(target, key, obj); | ||
return proxify(obj, ns); | ||
@@ -146,2 +136,6 @@ } | ||
return result; | ||
}, | ||
// Prevents passing the current proxy as the receiver to the deepSignal. | ||
set(target, key, value) { | ||
return Reflect.set(target, key, value); | ||
} | ||
@@ -249,19 +243,33 @@ }; | ||
} | ||
export const parseInitialData = (dom = document) => { | ||
const storeTag = dom.querySelector(`script[type="application/json"]#wp-interactivity-data`); | ||
if (storeTag?.textContent) { | ||
try { | ||
return JSON.parse(storeTag.textContent); | ||
} catch (e) { | ||
// Do nothing. | ||
} | ||
} | ||
return {}; | ||
}; | ||
export const populateInitialData = data => { | ||
if (isObject(data?.state)) { | ||
Object.entries(data.state).forEach(([namespace, state]) => { | ||
store(namespace, { | ||
state | ||
}, { | ||
lock: universalUnlock | ||
}); | ||
}); | ||
} | ||
if (isObject(data?.config)) { | ||
Object.entries(data.config).forEach(([namespace, config]) => { | ||
storeConfigs.set(namespace, config); | ||
}); | ||
} | ||
}; | ||
// Parse and populate the initial state and config. | ||
const data = parseInitialData(); | ||
if (isObject(data?.state)) { | ||
Object.entries(data.state).forEach(([namespace, state]) => { | ||
store(namespace, { | ||
state | ||
}, { | ||
lock: universalUnlock | ||
}); | ||
}); | ||
} | ||
if (isObject(data?.config)) { | ||
Object.entries(data.config).forEach(([namespace, config]) => { | ||
storeConfigs.set(namespace, config); | ||
}); | ||
} | ||
populateInitialData(data); | ||
//# sourceMappingURL=store.js.map |
@@ -83,3 +83,8 @@ export declare const stores: Map<any, any>; | ||
export declare function store<T extends object>(namespace: string, storePart?: T, options?: StoreOptions): T; | ||
export declare const parseInitialData: (dom?: Document) => any; | ||
export declare const populateInitialData: (data?: { | ||
state?: Record<string, unknown>; | ||
config?: Record<string, unknown>; | ||
}) => void; | ||
export {}; | ||
//# sourceMappingURL=store.d.ts.map |
@@ -23,12 +23,126 @@ "use strict"; | ||
const isObject = item => item && typeof item === 'object' && !Array.isArray(item); | ||
const mergeDeepSignals = (target, source, overwrite) => { | ||
// Assigned objects should be ignore during proxification. | ||
const contextAssignedObjects = new WeakMap(); | ||
// Store the context proxy and fallback for each object in the context. | ||
const contextObjectToProxy = new WeakMap(); | ||
const contextProxyToObject = new WeakMap(); | ||
const contextObjectToFallback = new WeakMap(); | ||
const isPlainObject = item => item && typeof item === 'object' && item.constructor === Object; | ||
const descriptor = Reflect.getOwnPropertyDescriptor; | ||
/** | ||
* Wrap a context object with a proxy to reproduce the context stack. The proxy | ||
* uses the passed `inherited` context as a fallback to look up for properties | ||
* that don't exist in the given context. Also, updated properties are modified | ||
* where they are defined, or added to the main context when they don't exist. | ||
* | ||
* By default, all plain objects inside the context are wrapped, unless it is | ||
* listed in the `ignore` option. | ||
* | ||
* @param {Object} current Current context. | ||
* @param {Object} inherited Inherited context, used as fallback. | ||
* | ||
* @return {Object} The wrapped context object. | ||
*/ | ||
const proxifyContext = (current, inherited = {}) => { | ||
// Update the fallback object reference when it changes. | ||
contextObjectToFallback.set(current, inherited); | ||
if (!contextObjectToProxy.has(current)) { | ||
const proxy = new Proxy(current, { | ||
get: (target, k) => { | ||
const fallback = contextObjectToFallback.get(current); | ||
// Always subscribe to prop changes in the current context. | ||
const currentProp = target[k]; | ||
// Return the inherited prop when missing in target. | ||
if (!(k in target) && k in fallback) { | ||
return fallback[k]; | ||
} | ||
// Proxify plain objects that were not directly assigned. | ||
if (k in target && !contextAssignedObjects.get(target)?.has(k) && isPlainObject((0, _deepsignal.peek)(target, k))) { | ||
return proxifyContext(currentProp, fallback[k]); | ||
} | ||
// Return the stored proxy for `currentProp` when it exists. | ||
if (contextObjectToProxy.has(currentProp)) { | ||
return contextObjectToProxy.get(currentProp); | ||
} | ||
/* | ||
* For other cases, return the value from target, also | ||
* subscribing to changes in the parent context when the current | ||
* prop is not defined. | ||
*/ | ||
return k in target ? currentProp : fallback[k]; | ||
}, | ||
set: (target, k, value) => { | ||
const fallback = contextObjectToFallback.get(current); | ||
const obj = k in target || !(k in fallback) ? target : fallback; | ||
/* | ||
* Assigned object values should not be proxified so they point | ||
* to the original object and don't inherit unexpected | ||
* properties. | ||
*/ | ||
if (value && typeof value === 'object') { | ||
if (!contextAssignedObjects.has(obj)) { | ||
contextAssignedObjects.set(obj, new Set()); | ||
} | ||
contextAssignedObjects.get(obj).add(k); | ||
} | ||
/* | ||
* When the value is a proxy, it's because it comes from the | ||
* context, so the inner value is assigned instead. | ||
*/ | ||
if (contextProxyToObject.has(value)) { | ||
const innerValue = contextProxyToObject.get(value); | ||
obj[k] = innerValue; | ||
} else { | ||
obj[k] = value; | ||
} | ||
return true; | ||
}, | ||
ownKeys: target => [...new Set([...Object.keys(contextObjectToFallback.get(current)), ...Object.keys(target)])], | ||
getOwnPropertyDescriptor: (target, k) => descriptor(target, k) || descriptor(contextObjectToFallback.get(current), k) | ||
}); | ||
contextObjectToProxy.set(current, proxy); | ||
contextProxyToObject.set(proxy, current); | ||
} | ||
return contextObjectToProxy.get(current); | ||
}; | ||
/** | ||
* Recursively update values within a deepSignal object. | ||
* | ||
* @param {Object} target A deepSignal instance. | ||
* @param {Object} source Object with properties to update in `target` | ||
*/ | ||
const updateSignals = (target, source) => { | ||
for (const k in source) { | ||
if (isObject((0, _deepsignal.peek)(target, k)) && isObject((0, _deepsignal.peek)(source, k))) { | ||
mergeDeepSignals(target[`$${k}`].peek(), source[`$${k}`].peek(), overwrite); | ||
} else if (overwrite || typeof (0, _deepsignal.peek)(target, k) === 'undefined') { | ||
target[`$${k}`] = source[`$${k}`]; | ||
if (isPlainObject((0, _deepsignal.peek)(target, k)) && isPlainObject((0, _deepsignal.peek)(source, k))) { | ||
updateSignals(target[`$${k}`].peek(), source[k]); | ||
} else { | ||
target[k] = source[k]; | ||
} | ||
} | ||
}; | ||
/** | ||
* Recursively clone the passed object. | ||
* | ||
* @param {Object} source Source object. | ||
* @return {Object} Cloned object. | ||
*/ | ||
const deepClone = source => { | ||
if (isPlainObject(source)) { | ||
return Object.fromEntries(Object.entries(source).map(([key, value]) => [key, deepClone(value)])); | ||
} | ||
if (Array.isArray(source)) { | ||
return source.map(i => deepClone(i)); | ||
} | ||
return source; | ||
}; | ||
const newRule = /(?:([\u0080-\uFFFF\w-%@]+) *:? *([^{;]+?);|([^;}{]*?) *{)|(}\s*)/g; | ||
@@ -105,20 +219,19 @@ const ruleClean = /\/\*[^]*?\*\/| +/g; | ||
}) => suffix === 'default'); | ||
currentValue.current = (0, _hooks.useMemo)(() => { | ||
if (!defaultEntry) return null; | ||
const { | ||
namespace, | ||
value | ||
} = defaultEntry; | ||
const newValue = (0, _deepsignal.deepSignal)({ | ||
[namespace]: value | ||
}); | ||
mergeDeepSignals(newValue, inheritedValue); | ||
mergeDeepSignals(currentValue.current, newValue, true); | ||
return currentValue.current; | ||
}, [inheritedValue, defaultEntry]); | ||
if (currentValue.current) { | ||
return (0, _preact.h)(Provider, { | ||
value: currentValue.current | ||
}, children); | ||
} | ||
// No change should be made if `defaultEntry` does not exist. | ||
const contextStack = (0, _hooks.useMemo)(() => { | ||
if (defaultEntry) { | ||
const { | ||
namespace, | ||
value | ||
} = defaultEntry; | ||
updateSignals(currentValue.current, { | ||
[namespace]: deepClone(value) | ||
}); | ||
} | ||
return proxifyContext(currentValue.current, inheritedValue); | ||
}, [defaultEntry, inheritedValue]); | ||
return (0, _preact.h)(Provider, { | ||
value: contextStack | ||
}, children); | ||
}, { | ||
@@ -372,11 +485,10 @@ priority: 5 | ||
return list.map(item => { | ||
const mergedContext = (0, _deepsignal.deepSignal)({}); | ||
const itemProp = suffix === 'default' ? 'item' : (0, _kebabToCamelcase.kebabToCamelCase)(suffix); | ||
const newValue = (0, _deepsignal.deepSignal)({ | ||
[namespace]: { | ||
[itemProp]: item | ||
} | ||
const itemContext = (0, _deepsignal.deepSignal)({ | ||
[namespace]: {} | ||
}); | ||
mergeDeepSignals(newValue, inheritedValue); | ||
mergeDeepSignals(mergedContext, newValue, true); | ||
const mergedContext = proxifyContext(itemContext, inheritedValue); | ||
// Set the item after proxifying the context. | ||
mergedContext[namespace][itemProp] = item; | ||
const scope = { | ||
@@ -383,0 +495,0 @@ ...(0, _hooks2.getScope)(), |
@@ -87,2 +87,3 @@ "use strict"; | ||
var _preact = require("preact"); | ||
var _signals = require("@preact/signals"); | ||
var _deepsignal = require("deepsignal"); | ||
@@ -118,3 +119,6 @@ var _directives = _interopRequireDefault(require("./directives")); | ||
render: _preact.render, | ||
deepSignal: _deepsignal.deepSignal | ||
deepSignal: _deepsignal.deepSignal, | ||
parseInitialData: _store.parseInitialData, | ||
populateInitialData: _store.populateInitialData, | ||
batch: _signals.batch | ||
}; | ||
@@ -121,0 +125,0 @@ } |
@@ -6,3 +6,3 @@ "use strict"; | ||
}); | ||
exports.getConfig = void 0; | ||
exports.populateInitialData = exports.parseInitialData = exports.getConfig = void 0; | ||
exports.store = store; | ||
@@ -31,10 +31,11 @@ exports.stores = void 0; | ||
} else if (isObject(source[key])) { | ||
if (!target[key]) Object.assign(target, { | ||
[key]: {} | ||
}); | ||
if (!target[key]) target[key] = {}; | ||
deepMerge(target[key], source[key]); | ||
} else { | ||
Object.assign(target, { | ||
[key]: source[key] | ||
}); | ||
try { | ||
target[key] = source[key]; | ||
} catch (e) { | ||
// Assignemnts fail for properties that are only getters. | ||
// When that's the case, the assignment is simply ignored. | ||
} | ||
} | ||
@@ -44,13 +45,2 @@ } | ||
}; | ||
const parseInitialData = () => { | ||
const storeTag = document.querySelector(`script[type="application/json"]#wp-interactivity-data`); | ||
if (storeTag?.textContent) { | ||
try { | ||
return JSON.parse(storeTag.textContent); | ||
} catch (e) { | ||
// Do nothing. | ||
} | ||
} | ||
return {}; | ||
}; | ||
const stores = exports.stores = new Map(); | ||
@@ -98,3 +88,3 @@ const rawStores = new Map(); | ||
} | ||
const result = Reflect.get(target, key, receiver); | ||
const result = Reflect.get(target, key); | ||
@@ -105,3 +95,3 @@ // Check if the proxy is the store root and no key with that name exist. In | ||
const obj = {}; | ||
Reflect.set(target, key, obj, receiver); | ||
Reflect.set(target, key, obj); | ||
return proxify(obj, ns); | ||
@@ -156,2 +146,6 @@ } | ||
return result; | ||
}, | ||
// Prevents passing the current proxy as the receiver to the deepSignal. | ||
set(target, key, value) { | ||
return Reflect.set(target, key, value); | ||
} | ||
@@ -260,19 +254,35 @@ }; | ||
} | ||
const parseInitialData = (dom = document) => { | ||
const storeTag = dom.querySelector(`script[type="application/json"]#wp-interactivity-data`); | ||
if (storeTag?.textContent) { | ||
try { | ||
return JSON.parse(storeTag.textContent); | ||
} catch (e) { | ||
// Do nothing. | ||
} | ||
} | ||
return {}; | ||
}; | ||
exports.parseInitialData = parseInitialData; | ||
const populateInitialData = data => { | ||
if (isObject(data?.state)) { | ||
Object.entries(data.state).forEach(([namespace, state]) => { | ||
store(namespace, { | ||
state | ||
}, { | ||
lock: universalUnlock | ||
}); | ||
}); | ||
} | ||
if (isObject(data?.config)) { | ||
Object.entries(data.config).forEach(([namespace, config]) => { | ||
storeConfigs.set(namespace, config); | ||
}); | ||
} | ||
}; | ||
// Parse and populate the initial state and config. | ||
exports.populateInitialData = populateInitialData; | ||
const data = parseInitialData(); | ||
if (isObject(data?.state)) { | ||
Object.entries(data.state).forEach(([namespace, state]) => { | ||
store(namespace, { | ||
state | ||
}, { | ||
lock: universalUnlock | ||
}); | ||
}); | ||
} | ||
if (isObject(data?.config)) { | ||
Object.entries(data.config).forEach(([namespace, config]) => { | ||
storeConfigs.set(namespace, config); | ||
}); | ||
} | ||
populateInitialData(data); | ||
//# sourceMappingURL=store.js.map |
@@ -5,2 +5,9 @@ <!-- Learn how to maintain this file at https://github.com/WordPress/gutenberg/tree/HEAD/packages#maintaining-changelogs. --> | ||
## 5.2.0 (2024-03-06) | ||
### Bug Fixes | ||
- Prevent passing state proxies as receivers to deepSignal proxy handlers. ([#57134](https://github.com/WordPress/gutenberg/pull/57134)) | ||
- Keep the same references to objects defined inside the context. ([#59553](https://github.com/WordPress/gutenberg/pull/59553)) | ||
## 5.1.0 (2024-02-21) | ||
@@ -11,2 +18,3 @@ | ||
- Only add proxies to plain objects inside the store. ([#59039](https://github.com/WordPress/gutenberg/pull/59039)) | ||
- Improve context merges using proxies. ([59187](https://github.com/WordPress/gutenberg/pull/59187)) | ||
@@ -13,0 +21,0 @@ ## 5.0.0 (2024-02-09) |
{ | ||
"name": "@wordpress/interactivity", | ||
"version": "5.1.0", | ||
"version": "5.2.0", | ||
"description": "Package that provides a standard and simple way to handle the frontend interactivity of Gutenberg blocks.", | ||
@@ -36,3 +36,3 @@ "author": "The WordPress Contributors", | ||
}, | ||
"gitHead": "c139588f4c668b38bafbc5431f2f4e3903dbe683" | ||
"gitHead": "ac3c3e465a083081a86a4da6ee6fb817b41e5130" | ||
} |
133
README.md
# Interactivity API | ||
> **Warning** | ||
> **This package is only available in Gutenberg** at the moment and not in WordPress Core as it is still very experimental, and very likely to change. | ||
> **Note** | ||
> This package enables the API shared at [Proposal: The Interactivity API – A better developer experience in building interactive blocks](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/). As part of an [Open Source project](https://developer.wordpress.org/block-editor/getting-started/faq/#the-gutenberg-project), participation is encouraged in testing this API providing feedback at the [discussions in GitHub](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api). | ||
The Interactivity API is available at WordPress Core from version 6.5: [Merge announcement](https://make.wordpress.org/core/2024/02/19/merge-announcement-interactivity-api/) | ||
These Core blocks are already powered by the API: | ||
- Search | ||
- Query | ||
- Navigation | ||
- File | ||
## Installation | ||
> **Note** | ||
> This package enables the API shared at [Proposal: The Interactivity API – A better developer experience in building interactive blocks](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/). As part of an [Open Source project](https://developer.wordpress.org/block-editor/getting-started/faq/#the-gutenberg-project) we encourage participation in helping shape this API and the [discussions in GitHub](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) is the best place to engage. | ||
> This step is only required if you are using this API outside of WordPress. | ||
> | ||
> Within WordPress, the package is already bundled in Core, so all you need to do to ensure it is loaded, by adding `@wordpress/interactivity` to the dependency array of the script module. | ||
> | ||
>This happens automatically when you use the dependency extraction Webpack plugin that is used in tools like wp-scripts. | ||
This package can be tested, but it's still very experimental. | ||
The Interactivity API is [being used in some core blocks](https://github.com/search?q=repo%3AWordPress%2Fgutenberg%20%40wordpress%2Finteractivity&type=code) but its use is still very limited. | ||
Install the module: | ||
```bash | ||
npm install @wordpress/interactivity --save | ||
``` | ||
## Frequently Asked Questions | ||
_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._ | ||
At this point, some of the questions you have about the Interactivity API may be: | ||
## Quick Start Guide | ||
### What is this? | ||
### Table of Contents | ||
This is the base of a new standard to create interactive blocks. Read [the proposal](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/) to learn more about this. | ||
- [Quick Start Guide](#quick-start-guide) | ||
- [1. Scaffold an interactive block](#1-scaffold-an-interactive-block) | ||
- [2. Generate the build](#2-generate-the-build) | ||
- [3. Use it in your WordPress installation ](#3-use-it-in-your-wordpress-installation) | ||
- [Requirements of the Interactivity API](#requirements-of-the-interactivity-aPI) | ||
- [A local WordPress installation](#a-local-wordpress-installation) | ||
- [Latest vesion of Gutenberg](#latest-vesion-of-gutenberg) | ||
- [Node.js](#nodejs) | ||
- [Code requirements](#code-requirements) | ||
- [Add `interactivity` support to `block.json`](#add-interactivity-support-to-blockjson) | ||
- [Add `wp-interactive` directive to a DOM element](#add-wp-interactive-directive-to-a-dom-element) | ||
### Can I use it? | ||
#### 1. Scaffold an interactive block | ||
You can test it, but it's still very experimental. | ||
A WordPress plugin that registers an interactive block (using the Interactivity API) by using a [template](https://www.npmjs.com/package/@wordpress/create-block-interactive-template) can be scaffolded with the `@wordpress/create-block` command. | ||
### How do I get started? | ||
``` | ||
npx @wordpress/create-block@latest my-first-interactive-block --template @wordpress/create-block-interactive-template | ||
``` | ||
The best place to start with the Interactivity API is this [**Getting started guide**](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/1-getting-started.md). There you'll will find a very quick start guide and the current requirements of the Interactivity API. | ||
#### 2. Generate the build | ||
### Where can I ask questions? | ||
When the plugin folder is generated, the build process needs to be launched to get a working version of the interactive block that can be used in WordPress. | ||
The [“Interactivity API” category](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) in Gutenberg repo discussions is the best place to ask questions about the Interactivity API. | ||
``` | ||
cd my-first-interactive-block && npm start | ||
``` | ||
### Where can I share my feedback about the API? | ||
#### 3. Use it in your WordPress installation | ||
The [“Interactivity API” category](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) in Gutenberg repo discussions is also the best place to share your feedback about the Interactivity API. | ||
If you have a local WordPress installation already running, you can launch the commands above inside the `plugins` folder of that installation. If not, you can use [`wp-now`](https://github.com/WordPress/playground-tools/tree/trunk/packages/wp-now) to launch a WordPress site with the plugin installed by executing from the generated folder (and from a different terminal window or tab) the following command | ||
## Installation | ||
``` | ||
npx @wp-now/wp-now start | ||
``` | ||
Install the module: | ||
At this point you should be able to insert the "My First Interactive Block" block into any post, and see how it behaves in the frontend when published. | ||
```bash | ||
npm install @wordpress/interactivity --save | ||
### Requirements of the Interactivity API | ||
To start working with the Interactivity API you'll need to have a [proper WordPress development environment for blocks](https://developer.wordpress.org/block-editor/getting-started/devenv/) and some specific code in your block, which should include: | ||
#### A local 6.5 WordPress installation | ||
You can use [the tools to set your local WordPress environment](https://developer.wordpress.org/block-editor/getting-started/devenv/#wordpress-development-site) you feel more comfortable with. | ||
To get quickly started, [`wp-now`](https://www.npmjs.com/package/@wp-now/wp-now) is the easiest way to get a WordPress site up and running locally. | ||
Interactivity API is included in Core in WordPress 6.5, for versions below, you'll need to have Gutenberg 17.5 or higher version installed and activated in your WordPress installation. | ||
#### Node.js | ||
Block development requires [Node](https://nodejs.org/en), so you'll need to have Node installed and running on your machine. Any version modern should work, but please check the minimum version requirements if you run into any issues with any of the Node.js tools used in WordPress development. | ||
#### Code requirements | ||
##### Add `interactivity` support to `block.json` | ||
To indicate that the block [supports](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-supports/) the Interactivity API features, add `"interactivity": true` to the `supports` attribute of the block's `block.json` | ||
``` | ||
"supports": { | ||
"interactivity": true | ||
}, | ||
``` | ||
_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._ | ||
##### Add `wp-interactive` directive to a DOM element | ||
## Docs & Examples | ||
To "activate" the Interactivity API in a DOM element (and its children), add the [`wp-interactive` directive](./docs/api-reference.md#wp-interactive) to it from `render.php` or `save.js` | ||
**[Interactivity API Documentation](https://github.com/WordPress/gutenberg/tree/trunk/packages/interactivity/docs)** is the best place to learn about this proposal. Although it's still in progress, some key pages are already available: | ||
- **[Getting Started Guide](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/1-getting-started.md)**: Follow this Getting Started guide to learn how to scaffold a new project and create your first interactive blocks. | ||
- **[API Reference](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/2-api-reference.md)**: Check this page for technical detailed explanations and examples of the directives and the store. | ||
```html | ||
<div data-wp-interactive="myPlugin"> | ||
<!-- Interactivity API zone --> | ||
</div> | ||
``` | ||
## API Reference | ||
To take a deep dive in how the API works internally, the list of Directives, and how Store works, click [here](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-interactivity/packages-interactivity-api-reference/). | ||
## Docs & Examples | ||
Here you have some more resources to learn/read more about the Interactivity API: | ||
- **[Interactivity API Discussions](https://github.com/WordPress/gutenberg/discussions/52882)** | ||
- [Merge announcement](https://make.wordpress.org/core/2024/02/19/merge-announcement-interactivity-api/) | ||
- [Proposal: The Interactivity API – A better developer experience in building interactive blocks](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/) | ||
@@ -62,2 +127,16 @@ - Developer Hours sessions ([Americas](https://www.youtube.com/watch?v=RXNoyP2ZiS8&t=664s) & [APAC/EMEA](https://www.youtube.com/watch?v=6ghbrhyAcvA)) | ||
<br /><br /><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> | ||
There's a Tracking Issue opened to ease the coordination of the work related to the Interactivity API Docs: **[Documentation for the Interactivity API - Tracking Issue #53296](https://github.com/WordPress/gutenberg/issues/53296)** | ||
## Get Involved | ||
As part of an [Open Source project](https://developer.wordpress.org/block-editor/getting-started/faq/#the-gutenberg-project) participation is encouraged in helping shape this API and its Docs. The [discussions](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) and [issues](https://github.com/WordPress/gutenberg/labels/%5BFeature%5D%20Interactivity%20API) in GitHub are the best place to engage. | ||
If you are willing to help with the documentation, please add a comment to [#51928](https://github.com/WordPress/gutenberg/discussions/51928) to coordinate everyone's efforts. | ||
## License | ||
Interactivity API proposal, as part of Gutenberg and the WordPress project is free software, and is released under the terms of the GNU General Public License version 2 or (at your option) any later version. See [LICENSE.md](https://github.com/WordPress/gutenberg/blob/trunk/LICENSE.md) for complete license. | ||
<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> |
@@ -17,15 +17,126 @@ /* @jsx createElement */ | ||
const isObject = ( item ) => | ||
item && typeof item === 'object' && ! Array.isArray( item ); | ||
// Assigned objects should be ignore during proxification. | ||
const contextAssignedObjects = new WeakMap(); | ||
const mergeDeepSignals = ( target, source, overwrite ) => { | ||
// Store the context proxy and fallback for each object in the context. | ||
const contextObjectToProxy = new WeakMap(); | ||
const contextProxyToObject = new WeakMap(); | ||
const contextObjectToFallback = new WeakMap(); | ||
const isPlainObject = ( item ) => | ||
item && typeof item === 'object' && item.constructor === Object; | ||
const descriptor = Reflect.getOwnPropertyDescriptor; | ||
/** | ||
* Wrap a context object with a proxy to reproduce the context stack. The proxy | ||
* uses the passed `inherited` context as a fallback to look up for properties | ||
* that don't exist in the given context. Also, updated properties are modified | ||
* where they are defined, or added to the main context when they don't exist. | ||
* | ||
* By default, all plain objects inside the context are wrapped, unless it is | ||
* listed in the `ignore` option. | ||
* | ||
* @param {Object} current Current context. | ||
* @param {Object} inherited Inherited context, used as fallback. | ||
* | ||
* @return {Object} The wrapped context object. | ||
*/ | ||
const proxifyContext = ( current, inherited = {} ) => { | ||
// Update the fallback object reference when it changes. | ||
contextObjectToFallback.set( current, inherited ); | ||
if ( ! contextObjectToProxy.has( current ) ) { | ||
const proxy = new Proxy( current, { | ||
get: ( target, k ) => { | ||
const fallback = contextObjectToFallback.get( current ); | ||
// Always subscribe to prop changes in the current context. | ||
const currentProp = target[ k ]; | ||
// Return the inherited prop when missing in target. | ||
if ( ! ( k in target ) && k in fallback ) { | ||
return fallback[ k ]; | ||
} | ||
// Proxify plain objects that were not directly assigned. | ||
if ( | ||
k in target && | ||
! contextAssignedObjects.get( target )?.has( k ) && | ||
isPlainObject( peek( target, k ) ) | ||
) { | ||
return proxifyContext( currentProp, fallback[ k ] ); | ||
} | ||
// Return the stored proxy for `currentProp` when it exists. | ||
if ( contextObjectToProxy.has( currentProp ) ) { | ||
return contextObjectToProxy.get( currentProp ); | ||
} | ||
/* | ||
* For other cases, return the value from target, also | ||
* subscribing to changes in the parent context when the current | ||
* prop is not defined. | ||
*/ | ||
return k in target ? currentProp : fallback[ k ]; | ||
}, | ||
set: ( target, k, value ) => { | ||
const fallback = contextObjectToFallback.get( current ); | ||
const obj = | ||
k in target || ! ( k in fallback ) ? target : fallback; | ||
/* | ||
* Assigned object values should not be proxified so they point | ||
* to the original object and don't inherit unexpected | ||
* properties. | ||
*/ | ||
if ( value && typeof value === 'object' ) { | ||
if ( ! contextAssignedObjects.has( obj ) ) { | ||
contextAssignedObjects.set( obj, new Set() ); | ||
} | ||
contextAssignedObjects.get( obj ).add( k ); | ||
} | ||
/* | ||
* When the value is a proxy, it's because it comes from the | ||
* context, so the inner value is assigned instead. | ||
*/ | ||
if ( contextProxyToObject.has( value ) ) { | ||
const innerValue = contextProxyToObject.get( value ); | ||
obj[ k ] = innerValue; | ||
} else { | ||
obj[ k ] = value; | ||
} | ||
return true; | ||
}, | ||
ownKeys: ( target ) => [ | ||
...new Set( [ | ||
...Object.keys( contextObjectToFallback.get( current ) ), | ||
...Object.keys( target ), | ||
] ), | ||
], | ||
getOwnPropertyDescriptor: ( target, k ) => | ||
descriptor( target, k ) || | ||
descriptor( contextObjectToFallback.get( current ), k ), | ||
} ); | ||
contextObjectToProxy.set( current, proxy ); | ||
contextProxyToObject.set( proxy, current ); | ||
} | ||
return contextObjectToProxy.get( current ); | ||
}; | ||
/** | ||
* Recursively update values within a deepSignal object. | ||
* | ||
* @param {Object} target A deepSignal instance. | ||
* @param {Object} source Object with properties to update in `target` | ||
*/ | ||
const updateSignals = ( target, source ) => { | ||
for ( const k in source ) { | ||
if ( isObject( peek( target, k ) ) && isObject( peek( source, k ) ) ) { | ||
mergeDeepSignals( | ||
target[ `$${ k }` ].peek(), | ||
source[ `$${ k }` ].peek(), | ||
overwrite | ||
); | ||
} else if ( overwrite || typeof peek( target, k ) === 'undefined' ) { | ||
target[ `$${ k }` ] = source[ `$${ k }` ]; | ||
if ( | ||
isPlainObject( peek( target, k ) ) && | ||
isPlainObject( peek( source, k ) ) | ||
) { | ||
updateSignals( target[ `$${ k }` ].peek(), source[ k ] ); | ||
} else { | ||
target[ k ] = source[ k ]; | ||
} | ||
@@ -35,2 +146,23 @@ } | ||
/** | ||
* Recursively clone the passed object. | ||
* | ||
* @param {Object} source Source object. | ||
* @return {Object} Cloned object. | ||
*/ | ||
const deepClone = ( source ) => { | ||
if ( isPlainObject( source ) ) { | ||
return Object.fromEntries( | ||
Object.entries( source ).map( ( [ key, value ] ) => [ | ||
key, | ||
deepClone( value ), | ||
] ) | ||
); | ||
} | ||
if ( Array.isArray( source ) ) { | ||
return source.map( ( i ) => deepClone( i ) ); | ||
} | ||
return source; | ||
}; | ||
const newRule = | ||
@@ -110,18 +242,14 @@ /(?:([\u0080-\uFFFF\w-%@]+) *:? *([^{;]+?);|([^;}{]*?) *{)|(}\s*)/g; | ||
currentValue.current = useMemo( () => { | ||
if ( ! defaultEntry ) return null; | ||
const { namespace, value } = defaultEntry; | ||
const newValue = deepSignal( { [ namespace ]: value } ); | ||
mergeDeepSignals( newValue, inheritedValue ); | ||
mergeDeepSignals( currentValue.current, newValue, true ); | ||
return currentValue.current; | ||
}, [ inheritedValue, defaultEntry ] ); | ||
// No change should be made if `defaultEntry` does not exist. | ||
const contextStack = useMemo( () => { | ||
if ( defaultEntry ) { | ||
const { namespace, value } = defaultEntry; | ||
updateSignals( currentValue.current, { | ||
[ namespace ]: deepClone( value ), | ||
} ); | ||
} | ||
return proxifyContext( currentValue.current, inheritedValue ); | ||
}, [ defaultEntry, inheritedValue ] ); | ||
if ( currentValue.current ) { | ||
return ( | ||
<Provider value={ currentValue.current }> | ||
{ children } | ||
</Provider> | ||
); | ||
} | ||
return <Provider value={ contextStack }>{ children }</Provider>; | ||
}, | ||
@@ -364,12 +492,13 @@ { priority: 5 } | ||
return list.map( ( item ) => { | ||
const mergedContext = deepSignal( {} ); | ||
const itemProp = | ||
suffix === 'default' ? 'item' : kebabToCamelCase( suffix ); | ||
const newValue = deepSignal( { | ||
[ namespace ]: { [ itemProp ]: item }, | ||
} ); | ||
mergeDeepSignals( newValue, inheritedValue ); | ||
mergeDeepSignals( mergedContext, newValue, true ); | ||
const itemContext = deepSignal( { [ namespace ]: {} } ); | ||
const mergedContext = proxifyContext( | ||
itemContext, | ||
inheritedValue | ||
); | ||
// Set the item after proxifying the context. | ||
mergedContext[ namespace ][ itemProp ] = item; | ||
const scope = { ...getScope(), context: mergedContext }; | ||
@@ -376,0 +505,0 @@ const key = eachKey |
@@ -5,2 +5,3 @@ /** | ||
import { h, cloneElement, render } from 'preact'; | ||
import { batch } from '@preact/signals'; | ||
import { deepSignal } from 'deepsignal'; | ||
@@ -16,2 +17,3 @@ | ||
import { directive, getNamespace } from './hooks'; | ||
import { parseInitialData, populateInitialData } from './store'; | ||
@@ -48,2 +50,5 @@ export { store, getConfig } from './store'; | ||
deepSignal, | ||
parseInitialData, | ||
populateInitialData, | ||
batch, | ||
}; | ||
@@ -50,0 +55,0 @@ } |
@@ -29,6 +29,11 @@ /** | ||
} else if ( isObject( source[ key ] ) ) { | ||
if ( ! target[ key ] ) Object.assign( target, { [ key ]: {} } ); | ||
if ( ! target[ key ] ) target[ key ] = {}; | ||
deepMerge( target[ key ], source[ key ] ); | ||
} else { | ||
Object.assign( target, { [ key ]: source[ key ] } ); | ||
try { | ||
target[ key ] = source[ key ]; | ||
} catch ( e ) { | ||
// Assignemnts fail for properties that are only getters. | ||
// When that's the case, the assignment is simply ignored. | ||
} | ||
} | ||
@@ -39,16 +44,2 @@ } | ||
const parseInitialData = () => { | ||
const storeTag = document.querySelector( | ||
`script[type="application/json"]#wp-interactivity-data` | ||
); | ||
if ( storeTag?.textContent ) { | ||
try { | ||
return JSON.parse( storeTag.textContent ); | ||
} catch ( e ) { | ||
// Do nothing. | ||
} | ||
} | ||
return {}; | ||
}; | ||
export const stores = new Map(); | ||
@@ -105,3 +96,3 @@ const rawStores = new Map(); | ||
const result = Reflect.get( target, key, receiver ); | ||
const result = Reflect.get( target, key ); | ||
@@ -112,3 +103,3 @@ // Check if the proxy is the store root and no key with that name exist. In | ||
const obj = {}; | ||
Reflect.set( target, key, obj, receiver ); | ||
Reflect.set( target, key, obj ); | ||
return proxify( obj, ns ); | ||
@@ -170,2 +161,6 @@ } | ||
}, | ||
// Prevents passing the current proxy as the receiver to the deepSignal. | ||
set( target: any, key: string, value: any ) { | ||
return Reflect.set( target, key, value ); | ||
}, | ||
}; | ||
@@ -318,13 +313,34 @@ | ||
export const parseInitialData = ( dom = document ) => { | ||
const storeTag = dom.querySelector( | ||
`script[type="application/json"]#wp-interactivity-data` | ||
); | ||
if ( storeTag?.textContent ) { | ||
try { | ||
return JSON.parse( storeTag.textContent ); | ||
} catch ( e ) { | ||
// Do nothing. | ||
} | ||
} | ||
return {}; | ||
}; | ||
export const populateInitialData = ( data?: { | ||
state?: Record< string, unknown >; | ||
config?: Record< string, unknown >; | ||
} ) => { | ||
if ( isObject( data?.state ) ) { | ||
Object.entries( data.state ).forEach( ( [ namespace, state ] ) => { | ||
store( namespace, { state }, { lock: universalUnlock } ); | ||
} ); | ||
} | ||
if ( isObject( data?.config ) ) { | ||
Object.entries( data.config ).forEach( ( [ namespace, config ] ) => { | ||
storeConfigs.set( namespace, config ); | ||
} ); | ||
} | ||
}; | ||
// Parse and populate the initial state and config. | ||
const data = parseInitialData(); | ||
if ( isObject( data?.state ) ) { | ||
Object.entries( data.state ).forEach( ( [ namespace, state ] ) => { | ||
store( namespace, { state }, { lock: universalUnlock } ); | ||
} ); | ||
} | ||
if ( isObject( data?.config ) ) { | ||
Object.entries( data.config ).forEach( ( [ namespace, config ] ) => { | ||
storeConfigs.set( namespace, config ); | ||
} ); | ||
} | ||
populateInitialData( data ); |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
5046
142
465452
72