@wordpress/interactivity
Advanced tools
Comparing version 2.7.0 to 3.0.0
@@ -15,2 +15,3 @@ import { createElement, Fragment } from "react"; | ||
import { SlotProvider, Slot, Fill } from './slots'; | ||
import { navigate } from './router'; | ||
const isObject = item => item && typeof item === 'object' && !Array.isArray(item); | ||
@@ -30,5 +31,3 @@ const mergeDeepSignals = (target, source, overwrite) => { | ||
directives: { | ||
context: { | ||
default: newContext | ||
} | ||
context | ||
}, | ||
@@ -45,8 +44,13 @@ props: { | ||
const currentValue = useRef(deepSignal({})); | ||
const passedValues = context.map(({ | ||
value | ||
}) => value); | ||
currentValue.current = useMemo(() => { | ||
const newValue = deepSignal(newContext); | ||
const newValue = context.map(c => deepSignal({ | ||
[c.namespace]: c.value | ||
})).reduceRight(mergeDeepSignals); | ||
mergeDeepSignals(newValue, inheritedValue); | ||
mergeDeepSignals(currentValue.current, newValue, true); | ||
return currentValue.current; | ||
}, [newContext, inheritedValue]); | ||
}, [inheritedValue, ...passedValues]); | ||
return createElement(Provider, { | ||
@@ -68,17 +72,11 @@ value: currentValue.current | ||
// data-wp-effect--[name] | ||
directive('effect', ({ | ||
// data-wp-watch--[name] | ||
directive('watch', ({ | ||
directives: { | ||
effect | ||
watch | ||
}, | ||
context, | ||
evaluate | ||
}) => { | ||
const contextValue = useContext(context); | ||
Object.values(effect).forEach(path => { | ||
useSignalEffect(() => { | ||
return evaluate(path, { | ||
context: contextValue | ||
}); | ||
}); | ||
watch.forEach(entry => { | ||
useSignalEffect(() => evaluate(entry)); | ||
}); | ||
@@ -92,12 +90,6 @@ }); | ||
}, | ||
context, | ||
evaluate | ||
}) => { | ||
const contextValue = useContext(context); | ||
Object.values(init).forEach(path => { | ||
useEffect(() => { | ||
return evaluate(path, { | ||
context: contextValue | ||
}); | ||
}, []); | ||
init.forEach(entry => { | ||
useEffect(() => evaluate(entry), []); | ||
}); | ||
@@ -112,12 +104,7 @@ }); | ||
element, | ||
evaluate, | ||
context | ||
evaluate | ||
}) => { | ||
const contextValue = useContext(context); | ||
Object.entries(on).forEach(([name, path]) => { | ||
element.props[`on${name}`] = event => { | ||
evaluate(path, { | ||
event, | ||
context: contextValue | ||
}); | ||
on.forEach(entry => { | ||
element.props[`on${entry.suffix}`] = event => { | ||
evaluate(entry, event); | ||
}; | ||
@@ -133,10 +120,10 @@ }); | ||
element, | ||
evaluate, | ||
context | ||
evaluate | ||
}) => { | ||
const contextValue = useContext(context); | ||
Object.keys(className).filter(n => n !== 'default').forEach(name => { | ||
const result = evaluate(className[name], { | ||
className: name, | ||
context: contextValue | ||
className.filter(({ | ||
suffix | ||
}) => suffix !== 'default').forEach(entry => { | ||
const name = entry.suffix; | ||
const result = evaluate(entry, { | ||
className: name | ||
}); | ||
@@ -194,10 +181,10 @@ const currentClass = element.props.class || ''; | ||
element, | ||
evaluate, | ||
context | ||
evaluate | ||
}) => { | ||
const contextValue = useContext(context); | ||
Object.keys(style).filter(n => n !== 'default').forEach(key => { | ||
const result = evaluate(style[key], { | ||
key, | ||
context: contextValue | ||
style.filter(({ | ||
suffix | ||
}) => suffix !== 'default').forEach(entry => { | ||
const key = entry.suffix; | ||
const result = evaluate(entry, { | ||
key | ||
}); | ||
@@ -226,10 +213,9 @@ element.props.style = element.props.style || {}; | ||
element, | ||
context, | ||
evaluate | ||
}) => { | ||
const contextValue = useContext(context); | ||
Object.entries(bind).filter(n => n !== 'default').forEach(([attribute, path]) => { | ||
const result = evaluate(path, { | ||
context: contextValue | ||
}); | ||
bind.filter(({ | ||
suffix | ||
}) => suffix !== 'default').forEach(entry => { | ||
const attribute = entry.suffix; | ||
const result = evaluate(entry); | ||
element.props[attribute] = result; | ||
@@ -277,2 +263,46 @@ // Preact doesn't handle the `role` attribute properly, as it doesn't remove it when `null`. | ||
// data-wp-navigation-link | ||
directive('navigation-link', ({ | ||
directives: { | ||
'navigation-link': navigationLink | ||
}, | ||
props: { | ||
href | ||
}, | ||
element | ||
}) => { | ||
const { | ||
value: link | ||
} = navigationLink.find(({ | ||
suffix | ||
}) => suffix === 'default'); | ||
useEffect(() => { | ||
// Prefetch the page if it is in the directive options. | ||
if (link?.prefetch) { | ||
// prefetch( href ); | ||
} | ||
}); | ||
// Don't do anything if it's falsy. | ||
if (link !== false) { | ||
element.props.onclick = async event => { | ||
event.preventDefault(); | ||
// Fetch the page (or return it from cache). | ||
await navigate(href); | ||
// Update the scroll, depending on the option. True by default. | ||
if (link?.scroll === 'smooth') { | ||
window.scrollTo({ | ||
top: 0, | ||
left: 0, | ||
behavior: 'smooth' | ||
}); | ||
} else if (link?.scroll !== false) { | ||
window.scrollTo(0, 0); | ||
} | ||
}; | ||
} | ||
}); | ||
// data-wp-ignore | ||
@@ -301,14 +331,11 @@ directive('ignore', ({ | ||
directives: { | ||
text: { | ||
default: text | ||
} | ||
text | ||
}, | ||
element, | ||
evaluate, | ||
context | ||
evaluate | ||
}) => { | ||
const contextValue = useContext(context); | ||
element.props.children = evaluate(text, { | ||
context: contextValue | ||
}); | ||
const entry = text.find(({ | ||
suffix | ||
}) => suffix === 'default'); | ||
element.props.children = evaluate(entry); | ||
}); | ||
@@ -319,5 +346,3 @@ | ||
directives: { | ||
slot: { | ||
default: slot | ||
} | ||
slot | ||
}, | ||
@@ -329,4 +354,9 @@ props: { | ||
}) => { | ||
const name = typeof slot === 'string' ? slot : slot.name; | ||
const position = slot.position || 'children'; | ||
const { | ||
value | ||
} = slot.find(({ | ||
suffix | ||
}) => suffix === 'default'); | ||
const name = typeof value === 'string' ? value : value.name; | ||
const position = value.position || 'children'; | ||
if (position === 'before') { | ||
@@ -359,5 +389,3 @@ return createElement(Fragment, null, createElement(Slot, { | ||
directives: { | ||
fill: { | ||
default: fill | ||
} | ||
fill | ||
}, | ||
@@ -367,9 +395,8 @@ props: { | ||
}, | ||
evaluate, | ||
context | ||
evaluate | ||
}) => { | ||
const contextValue = useContext(context); | ||
const slot = evaluate(fill, { | ||
context: contextValue | ||
}); | ||
const entry = fill.find(({ | ||
suffix | ||
}) => suffix === 'default'); | ||
const slot = evaluate(entry); | ||
return createElement(Fill, { | ||
@@ -376,0 +403,0 @@ slot: slot |
import { createElement } from "react"; | ||
// @ts-nocheck | ||
/** | ||
@@ -6,7 +8,8 @@ * External dependencies | ||
import { h, options, createContext, cloneElement } from 'preact'; | ||
import { useRef, useCallback } from 'preact/hooks'; | ||
import { useRef, useCallback, useContext } from 'preact/hooks'; | ||
import { deepSignal } from 'deepsignal'; | ||
/** | ||
* Internal dependencies | ||
*/ | ||
import { rawStore as store } from './store'; | ||
import { stores } from './store'; | ||
@@ -42,2 +45,54 @@ /** @typedef {import('preact').VNode} VNode */ | ||
// Wrap the element props to prevent modifications. | ||
const immutableMap = new WeakMap(); | ||
const immutableError = () => { | ||
throw new Error('Please use `data-wp-bind` to modify the attributes of an element.'); | ||
}; | ||
const immutableHandlers = { | ||
get(target, key, receiver) { | ||
const value = Reflect.get(target, key, receiver); | ||
return !!value && typeof value === 'object' ? deepImmutable(value) : value; | ||
}, | ||
set: immutableError, | ||
deleteProperty: immutableError | ||
}; | ||
const deepImmutable = target => { | ||
if (!immutableMap.has(target)) immutableMap.set(target, new Proxy(target, immutableHandlers)); | ||
return immutableMap.get(target); | ||
}; | ||
// Store stacks for the current scope and the default namespaces and export APIs | ||
// to interact with them. | ||
const scopeStack = []; | ||
const namespaceStack = []; | ||
export const getContext = namespace => getScope()?.context[namespace || namespaceStack.slice(-1)[0]]; | ||
export const getElement = () => { | ||
if (!getScope()) { | ||
throw Error('Cannot call `getElement()` outside getters and actions used by directives.'); | ||
} | ||
const { | ||
ref, | ||
state, | ||
props | ||
} = getScope(); | ||
return Object.freeze({ | ||
ref: ref.current, | ||
state, | ||
props: deepImmutable(props) | ||
}); | ||
}; | ||
export const getScope = () => scopeStack.slice(-1)[0]; | ||
export const setScope = scope => { | ||
scopeStack.push(scope); | ||
}; | ||
export const resetScope = () => { | ||
scopeStack.pop(); | ||
}; | ||
export const setNamespace = namespace => { | ||
namespaceStack.push(namespace); | ||
}; | ||
export const resetNamespace = () => { | ||
namespaceStack.pop(); | ||
}; | ||
// WordPress Directives. | ||
@@ -120,6 +175,6 @@ const directiveCallbacks = {}; | ||
// Resolve the path to some property of the store object. | ||
const resolve = (path, ctx) => { | ||
const resolve = (path, namespace) => { | ||
let current = { | ||
...store, | ||
context: ctx | ||
...stores.get(namespace), | ||
context: getScope().context[namespace] | ||
}; | ||
@@ -132,13 +187,15 @@ path.split('.').forEach(p => current = current[p]); | ||
const getEvaluate = ({ | ||
ref | ||
} = {}) => (path, extraArgs = {}) => { | ||
scope | ||
} = {}) => (entry, ...args) => { | ||
let { | ||
value: path, | ||
namespace | ||
} = entry; | ||
// If path starts with !, remove it and save a flag. | ||
const hasNegationOperator = path[0] === '!' && !!(path = path.slice(1)); | ||
const value = resolve(path, extraArgs.context); | ||
const returnValue = typeof value === 'function' ? value({ | ||
ref: ref.current, | ||
...store, | ||
...extraArgs | ||
}) : value; | ||
return hasNegationOperator ? !returnValue : returnValue; | ||
setScope(scope); | ||
const value = resolve(path, namespace); | ||
const result = typeof value === 'function' ? value(...args) : value; | ||
resetScope(); | ||
return hasNegationOperator ? !result : result; | ||
}; | ||
@@ -159,3 +216,3 @@ | ||
// Priority level wrapper. | ||
// Component that wraps each priority level of directives of an element. | ||
const Directives = ({ | ||
@@ -165,20 +222,23 @@ directives, | ||
element, | ||
evaluate, | ||
originalProps, | ||
elemRef | ||
previousScope = {} | ||
}) => { | ||
// Initialize the DOM reference. | ||
// eslint-disable-next-line react-hooks/rules-of-hooks | ||
elemRef = elemRef || useRef(null); | ||
// Create a reference to the evaluate function using the DOM reference. | ||
// eslint-disable-next-line react-hooks/rules-of-hooks, react-hooks/exhaustive-deps | ||
evaluate = evaluate || useCallback(getEvaluate({ | ||
ref: elemRef | ||
// Initialize the scope of this element. These scopes are different per each | ||
// level because each level has a different context, but they share the same | ||
// element ref, state and props. | ||
const scope = useRef({}).current; | ||
scope.evaluate = useCallback(getEvaluate({ | ||
scope | ||
}), []); | ||
scope.context = useContext(context); | ||
/* eslint-disable react-hooks/rules-of-hooks */ | ||
scope.ref = previousScope.ref || useRef(null); | ||
scope.state = previousScope.state || useRef(deepSignal({})).current; | ||
/* eslint-enable react-hooks/rules-of-hooks */ | ||
// Create a fresh copy of the vnode element. | ||
// Create a fresh copy of the vnode element and add the props to the scope. | ||
element = cloneElement(element, { | ||
ref: elemRef | ||
ref: scope.ref | ||
}); | ||
scope.props = element.props; | ||
@@ -190,5 +250,4 @@ // Recursively render the wrapper for the next priority level. | ||
element: element, | ||
evaluate: evaluate, | ||
originalProps: originalProps, | ||
elemRef: elemRef | ||
previousScope: scope | ||
}) : element; | ||
@@ -204,4 +263,5 @@ const props = { | ||
context, | ||
evaluate | ||
evaluate: scope.evaluate | ||
}; | ||
setScope(scope); | ||
for (const directiveName of currentPriorityLevel) { | ||
@@ -211,2 +271,3 @@ const wrapper = directiveCallbacks[directiveName]?.(directiveArgs); | ||
} | ||
resetScope(); | ||
return props.children; | ||
@@ -221,3 +282,5 @@ }; | ||
const directives = props.__directives; | ||
if (directives.key) vnode.key = directives.key.default; | ||
if (directives.key) vnode.key = directives.key.find(({ | ||
suffix | ||
}) => suffix === 'default').value; | ||
delete props.__directives; | ||
@@ -224,0 +287,0 @@ const priorityLevels = getPriorityLevels(directives); |
@@ -6,5 +6,4 @@ /** | ||
import { init } from './router'; | ||
import { rawStore, afterLoads } from './store'; | ||
export { store } from './store'; | ||
export { directive } from './hooks'; | ||
export { directive, getContext, getElement } from './hooks'; | ||
export { navigate, prefetch } from './router'; | ||
@@ -17,4 +16,3 @@ export { h as createElement } from 'preact'; | ||
await init(); | ||
afterLoads.forEach(afterLoad => afterLoad(rawStore)); | ||
}); | ||
//# sourceMappingURL=index.js.map |
@@ -5,7 +5,18 @@ /** | ||
import { deepSignal } from 'deepsignal'; | ||
const isObject = item => item && typeof item === 'object' && !Array.isArray(item); | ||
import { computed } from '@preact/signals'; | ||
/** | ||
* Internal dependencies | ||
*/ | ||
import { getScope, setScope, resetScope, setNamespace, resetNamespace } from './hooks'; | ||
const isObject = item => !!item && typeof item === 'object' && !Array.isArray(item); | ||
const deepMerge = (target, source) => { | ||
if (isObject(target) && isObject(source)) { | ||
for (const key in source) { | ||
if (isObject(source[key])) { | ||
const getter = Object.getOwnPropertyDescriptor(source, key)?.get; | ||
if (typeof getter === 'function') { | ||
Object.defineProperty(target, key, { | ||
get: getter | ||
}); | ||
} else if (isObject(source[key])) { | ||
if (!target[key]) Object.assign(target, { | ||
@@ -23,10 +34,8 @@ [key]: {} | ||
}; | ||
const getSerializedState = () => { | ||
const storeTag = document.querySelector(`script[type="application/json"]#wp-interactivity-store-data`); | ||
if (!storeTag) return {}; | ||
const parseInitialState = () => { | ||
const storeTag = document.querySelector(`script[type="application/json"]#wp-interactivity-initial-state`); | ||
if (!storeTag?.textContent) return {}; | ||
try { | ||
const { | ||
state | ||
} = JSON.parse(storeTag.textContent); | ||
if (isObject(state)) return state; | ||
const initialState = JSON.parse(storeTag.textContent); | ||
if (isObject(initialState)) return initialState; | ||
throw Error('Parsed state is not an object'); | ||
@@ -39,8 +48,102 @@ } catch (e) { | ||
}; | ||
export const afterLoads = new Set(); | ||
const rawState = getSerializedState(); | ||
export const rawStore = { | ||
state: deepSignal(rawState) | ||
export const stores = new Map(); | ||
const rawStores = new Map(); | ||
const storeLocks = new Map(); | ||
const objToProxy = new WeakMap(); | ||
const proxyToNs = new WeakMap(); | ||
const scopeToGetters = new WeakMap(); | ||
const proxify = (obj, ns) => { | ||
if (!objToProxy.has(obj)) { | ||
const proxy = new Proxy(obj, handlers); | ||
objToProxy.set(obj, proxy); | ||
proxyToNs.set(proxy, ns); | ||
} | ||
return objToProxy.get(obj); | ||
}; | ||
const handlers = { | ||
get: (target, key, receiver) => { | ||
const ns = proxyToNs.get(receiver); | ||
// Check if the property is a getter and we are inside an scope. If that is | ||
// the case, we clone the getter to avoid overwriting the scoped | ||
// dependencies of the computed each time that getter runs. | ||
const getter = Object.getOwnPropertyDescriptor(target, key)?.get; | ||
if (getter) { | ||
const scope = getScope(); | ||
if (scope) { | ||
const getters = scopeToGetters.get(scope) || scopeToGetters.set(scope, new Map()).get(scope); | ||
if (!getters.has(getter)) { | ||
getters.set(getter, computed(() => { | ||
setNamespace(ns); | ||
setScope(scope); | ||
try { | ||
return getter.call(target); | ||
} finally { | ||
resetScope(); | ||
resetNamespace(); | ||
} | ||
})); | ||
} | ||
return getters.get(getter).value; | ||
} | ||
} | ||
const result = Reflect.get(target, key, receiver); | ||
// Check if the proxy is the store root and no key with that name exist. In | ||
// that case, return an empty object for the requested key. | ||
if (typeof result === 'undefined' && receiver === stores.get(ns)) { | ||
const obj = {}; | ||
Reflect.set(target, key, obj, receiver); | ||
return proxify(obj, ns); | ||
} | ||
// Check if the property is a generator. If it is, we turn it into an | ||
// asynchronous function where we restore the default namespace and scope | ||
// each time it awaits/yields. | ||
if (result?.constructor?.name === 'GeneratorFunction') { | ||
return async (...args) => { | ||
const scope = getScope(); | ||
const gen = result(...args); | ||
let value; | ||
let it; | ||
while (true) { | ||
setNamespace(ns); | ||
setScope(scope); | ||
try { | ||
it = gen.next(value); | ||
} finally { | ||
resetScope(); | ||
resetNamespace(); | ||
} | ||
try { | ||
value = await it.value; | ||
} catch (e) { | ||
gen.throw(e); | ||
} | ||
if (it.done) break; | ||
} | ||
return value; | ||
}; | ||
} | ||
// Check if the property is a synchronous function. If it is, set the | ||
// default namespace. Synchronous functions always run in the proper scope, | ||
// which is set by the Directives component. | ||
if (typeof result === 'function') { | ||
return (...args) => { | ||
setNamespace(ns); | ||
try { | ||
return result(...args); | ||
} finally { | ||
resetNamespace(); | ||
} | ||
}; | ||
} | ||
// Check if the property is an object. If it is, proxyify it. | ||
if (isObject(result)) return proxify(result, ns); | ||
return result; | ||
} | ||
}; | ||
/** | ||
@@ -54,6 +157,2 @@ * @typedef StoreProps Properties object passed to `store`. | ||
* @typedef StoreOptions Options object. | ||
* @property {(store:any) => void} [afterLoad] Callback to be executed after the | ||
* Interactivity API has been set up | ||
* and the store is ready. It | ||
* receives the store as argument. | ||
*/ | ||
@@ -102,12 +201,55 @@ | ||
*/ | ||
export const store = ({ | ||
state, | ||
const universalUnlock = 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.'; | ||
export function store(namespace, { | ||
state = {}, | ||
...block | ||
}, { | ||
afterLoad | ||
} = {}) => { | ||
deepMerge(rawStore, block); | ||
deepMerge(rawState, state); | ||
if (afterLoad) afterLoads.add(afterLoad); | ||
}; | ||
} = {}, { | ||
lock = false | ||
} = {}) { | ||
if (!stores.has(namespace)) { | ||
// Lock the store if the passed lock is different from the universal | ||
// unlock. Once the lock is set (either false, true, or a given string), | ||
// it cannot change. | ||
if (lock !== universalUnlock) { | ||
storeLocks.set(namespace, lock); | ||
} | ||
const rawStore = { | ||
state: deepSignal(state), | ||
...block | ||
}; | ||
const proxiedStore = new Proxy(rawStore, handlers); | ||
rawStores.set(namespace, rawStore); | ||
stores.set(namespace, proxiedStore); | ||
proxyToNs.set(proxiedStore, namespace); | ||
} else { | ||
// Lock the store if it wasn't locked yet and the passed lock is | ||
// different from the universal unlock. If no lock is given, the store | ||
// will be public and won't accept any lock from now on. | ||
if (lock !== universalUnlock && !storeLocks.has(namespace)) { | ||
storeLocks.set(namespace, lock); | ||
} else { | ||
const storeLock = storeLocks.get(namespace); | ||
const isLockValid = lock === universalUnlock || lock !== true && lock === storeLock; | ||
if (!isLockValid) { | ||
if (!storeLock) { | ||
throw Error('Cannot lock a public store'); | ||
} else { | ||
throw Error('Cannot unlock a private store with an invalid lock code'); | ||
} | ||
} | ||
} | ||
const target = rawStores.get(namespace); | ||
deepMerge(target, block); | ||
deepMerge(target.state, state); | ||
} | ||
return stores.get(namespace); | ||
} | ||
// Parse and populate the initial state. | ||
Object.entries(parseInitialState()).forEach(([namespace, state]) => { | ||
store(namespace, { | ||
state | ||
}); | ||
}); | ||
//# sourceMappingURL=store.js.map |
@@ -12,2 +12,3 @@ /** | ||
const fullPrefix = `data-${p}-`; | ||
let namespace = null; | ||
@@ -27,2 +28,7 @@ // Regular expression for directive parsing. | ||
// Regular expression for reference parsing. It can contain a namespace before | ||
// the reference, separated by `::`, like `some-namespace::state.somePath`. | ||
// Namespaces can contain any alphanumeric characters, hyphens, underscores or | ||
// forward slashes. References don't have any restrictions. | ||
const nsPathRegExp = /^([\w-_\/]+)::(.+)$/; | ||
export const hydratedIslands = new WeakSet(); | ||
@@ -53,4 +59,3 @@ | ||
const children = []; | ||
const directives = {}; | ||
let hasDirectives = false; | ||
const directives = []; | ||
let ignore = false; | ||
@@ -63,13 +68,15 @@ let island = false; | ||
ignore = true; | ||
} else if (n === islandAttr) { | ||
island = true; | ||
} else { | ||
hasDirectives = true; | ||
let val = attributes[i].value; | ||
var _nsPathRegExp$exec$sl; | ||
let [ns, value] = (_nsPathRegExp$exec$sl = nsPathRegExp.exec(attributes[i].value)?.slice(1)) !== null && _nsPathRegExp$exec$sl !== void 0 ? _nsPathRegExp$exec$sl : [null, attributes[i].value]; | ||
try { | ||
val = JSON.parse(val); | ||
value = JSON.parse(value); | ||
} catch (e) {} | ||
const [, prefix, suffix] = directiveParser.exec(n); | ||
directives[prefix] = directives[prefix] || {}; | ||
directives[prefix][suffix || 'default'] = val; | ||
if (n === islandAttr) { | ||
var _value$namespace; | ||
island = true; | ||
namespace = (_value$namespace = value?.namespace) !== null && _value$namespace !== void 0 ? _value$namespace : null; | ||
} else { | ||
directives.push([n, ns, value]); | ||
} | ||
} | ||
@@ -89,3 +96,14 @@ } else if (n === 'ref') { | ||
if (island) hydratedIslands.add(node); | ||
if (hasDirectives) props.__directives = directives; | ||
if (directives.length) { | ||
props.__directives = directives.reduce((obj, [name, ns, value]) => { | ||
const [, prefix, suffix = 'default'] = directiveParser.exec(name); | ||
if (!obj[prefix]) obj[prefix] = []; | ||
obj[prefix].push({ | ||
namespace: ns !== null && ns !== void 0 ? ns : namespace, | ||
value, | ||
suffix | ||
}); | ||
return obj; | ||
}, {}); | ||
} | ||
let child = treeWalker.firstChild(); | ||
@@ -92,0 +110,0 @@ if (child) { |
@@ -14,2 +14,3 @@ "use strict"; | ||
var _slots = require("./slots"); | ||
var _router = require("./router"); | ||
/** | ||
@@ -37,5 +38,3 @@ * External dependencies | ||
directives: { | ||
context: { | ||
default: newContext | ||
} | ||
context | ||
}, | ||
@@ -52,8 +51,13 @@ props: { | ||
const currentValue = (0, _hooks.useRef)((0, _deepsignal.deepSignal)({})); | ||
const passedValues = context.map(({ | ||
value | ||
}) => value); | ||
currentValue.current = (0, _hooks.useMemo)(() => { | ||
const newValue = (0, _deepsignal.deepSignal)(newContext); | ||
const newValue = context.map(c => (0, _deepsignal.deepSignal)({ | ||
[c.namespace]: c.value | ||
})).reduceRight(mergeDeepSignals); | ||
mergeDeepSignals(newValue, inheritedValue); | ||
mergeDeepSignals(currentValue.current, newValue, true); | ||
return currentValue.current; | ||
}, [newContext, inheritedValue]); | ||
}, [inheritedValue, ...passedValues]); | ||
return (0, _react.createElement)(Provider, { | ||
@@ -75,17 +79,11 @@ value: currentValue.current | ||
// data-wp-effect--[name] | ||
(0, _hooks2.directive)('effect', ({ | ||
// data-wp-watch--[name] | ||
(0, _hooks2.directive)('watch', ({ | ||
directives: { | ||
effect | ||
watch | ||
}, | ||
context, | ||
evaluate | ||
}) => { | ||
const contextValue = (0, _hooks.useContext)(context); | ||
Object.values(effect).forEach(path => { | ||
(0, _utils.useSignalEffect)(() => { | ||
return evaluate(path, { | ||
context: contextValue | ||
}); | ||
}); | ||
watch.forEach(entry => { | ||
(0, _utils.useSignalEffect)(() => evaluate(entry)); | ||
}); | ||
@@ -99,12 +97,6 @@ }); | ||
}, | ||
context, | ||
evaluate | ||
}) => { | ||
const contextValue = (0, _hooks.useContext)(context); | ||
Object.values(init).forEach(path => { | ||
(0, _hooks.useEffect)(() => { | ||
return evaluate(path, { | ||
context: contextValue | ||
}); | ||
}, []); | ||
init.forEach(entry => { | ||
(0, _hooks.useEffect)(() => evaluate(entry), []); | ||
}); | ||
@@ -119,12 +111,7 @@ }); | ||
element, | ||
evaluate, | ||
context | ||
evaluate | ||
}) => { | ||
const contextValue = (0, _hooks.useContext)(context); | ||
Object.entries(on).forEach(([name, path]) => { | ||
element.props[`on${name}`] = event => { | ||
evaluate(path, { | ||
event, | ||
context: contextValue | ||
}); | ||
on.forEach(entry => { | ||
element.props[`on${entry.suffix}`] = event => { | ||
evaluate(entry, event); | ||
}; | ||
@@ -140,10 +127,10 @@ }); | ||
element, | ||
evaluate, | ||
context | ||
evaluate | ||
}) => { | ||
const contextValue = (0, _hooks.useContext)(context); | ||
Object.keys(className).filter(n => n !== 'default').forEach(name => { | ||
const result = evaluate(className[name], { | ||
className: name, | ||
context: contextValue | ||
className.filter(({ | ||
suffix | ||
}) => suffix !== 'default').forEach(entry => { | ||
const name = entry.suffix; | ||
const result = evaluate(entry, { | ||
className: name | ||
}); | ||
@@ -201,10 +188,10 @@ const currentClass = element.props.class || ''; | ||
element, | ||
evaluate, | ||
context | ||
evaluate | ||
}) => { | ||
const contextValue = (0, _hooks.useContext)(context); | ||
Object.keys(style).filter(n => n !== 'default').forEach(key => { | ||
const result = evaluate(style[key], { | ||
key, | ||
context: contextValue | ||
style.filter(({ | ||
suffix | ||
}) => suffix !== 'default').forEach(entry => { | ||
const key = entry.suffix; | ||
const result = evaluate(entry, { | ||
key | ||
}); | ||
@@ -233,10 +220,9 @@ element.props.style = element.props.style || {}; | ||
element, | ||
context, | ||
evaluate | ||
}) => { | ||
const contextValue = (0, _hooks.useContext)(context); | ||
Object.entries(bind).filter(n => n !== 'default').forEach(([attribute, path]) => { | ||
const result = evaluate(path, { | ||
context: contextValue | ||
}); | ||
bind.filter(({ | ||
suffix | ||
}) => suffix !== 'default').forEach(entry => { | ||
const attribute = entry.suffix; | ||
const result = evaluate(entry); | ||
element.props[attribute] = result; | ||
@@ -284,2 +270,46 @@ // Preact doesn't handle the `role` attribute properly, as it doesn't remove it when `null`. | ||
// data-wp-navigation-link | ||
(0, _hooks2.directive)('navigation-link', ({ | ||
directives: { | ||
'navigation-link': navigationLink | ||
}, | ||
props: { | ||
href | ||
}, | ||
element | ||
}) => { | ||
const { | ||
value: link | ||
} = navigationLink.find(({ | ||
suffix | ||
}) => suffix === 'default'); | ||
(0, _hooks.useEffect)(() => { | ||
// Prefetch the page if it is in the directive options. | ||
if (link?.prefetch) { | ||
// prefetch( href ); | ||
} | ||
}); | ||
// Don't do anything if it's falsy. | ||
if (link !== false) { | ||
element.props.onclick = async event => { | ||
event.preventDefault(); | ||
// Fetch the page (or return it from cache). | ||
await (0, _router.navigate)(href); | ||
// Update the scroll, depending on the option. True by default. | ||
if (link?.scroll === 'smooth') { | ||
window.scrollTo({ | ||
top: 0, | ||
left: 0, | ||
behavior: 'smooth' | ||
}); | ||
} else if (link?.scroll !== false) { | ||
window.scrollTo(0, 0); | ||
} | ||
}; | ||
} | ||
}); | ||
// data-wp-ignore | ||
@@ -308,14 +338,11 @@ (0, _hooks2.directive)('ignore', ({ | ||
directives: { | ||
text: { | ||
default: text | ||
} | ||
text | ||
}, | ||
element, | ||
evaluate, | ||
context | ||
evaluate | ||
}) => { | ||
const contextValue = (0, _hooks.useContext)(context); | ||
element.props.children = evaluate(text, { | ||
context: contextValue | ||
}); | ||
const entry = text.find(({ | ||
suffix | ||
}) => suffix === 'default'); | ||
element.props.children = evaluate(entry); | ||
}); | ||
@@ -326,5 +353,3 @@ | ||
directives: { | ||
slot: { | ||
default: slot | ||
} | ||
slot | ||
}, | ||
@@ -336,4 +361,9 @@ props: { | ||
}) => { | ||
const name = typeof slot === 'string' ? slot : slot.name; | ||
const position = slot.position || 'children'; | ||
const { | ||
value | ||
} = slot.find(({ | ||
suffix | ||
}) => suffix === 'default'); | ||
const name = typeof value === 'string' ? value : value.name; | ||
const position = value.position || 'children'; | ||
if (position === 'before') { | ||
@@ -366,5 +396,3 @@ return (0, _react.createElement)(_react.Fragment, null, (0, _react.createElement)(_slots.Slot, { | ||
directives: { | ||
fill: { | ||
default: fill | ||
} | ||
fill | ||
}, | ||
@@ -374,9 +402,8 @@ props: { | ||
}, | ||
evaluate, | ||
context | ||
evaluate | ||
}) => { | ||
const contextValue = (0, _hooks.useContext)(context); | ||
const slot = evaluate(fill, { | ||
context: contextValue | ||
}); | ||
const entry = fill.find(({ | ||
suffix | ||
}) => suffix === 'default'); | ||
const slot = evaluate(entry); | ||
return (0, _react.createElement)(_slots.Fill, { | ||
@@ -383,0 +410,0 @@ slot: slot |
@@ -6,7 +6,10 @@ "use strict"; | ||
}); | ||
exports.directive = void 0; | ||
exports.setScope = exports.setNamespace = exports.resetScope = exports.resetNamespace = exports.getScope = exports.getElement = exports.getContext = exports.directive = void 0; | ||
var _react = require("react"); | ||
var _preact = require("preact"); | ||
var _hooks = require("preact/hooks"); | ||
var _deepsignal = require("deepsignal"); | ||
var _store = require("./store"); | ||
// @ts-nocheck | ||
/** | ||
@@ -49,3 +52,62 @@ * External dependencies | ||
// Wrap the element props to prevent modifications. | ||
const immutableMap = new WeakMap(); | ||
const immutableError = () => { | ||
throw new Error('Please use `data-wp-bind` to modify the attributes of an element.'); | ||
}; | ||
const immutableHandlers = { | ||
get(target, key, receiver) { | ||
const value = Reflect.get(target, key, receiver); | ||
return !!value && typeof value === 'object' ? deepImmutable(value) : value; | ||
}, | ||
set: immutableError, | ||
deleteProperty: immutableError | ||
}; | ||
const deepImmutable = target => { | ||
if (!immutableMap.has(target)) immutableMap.set(target, new Proxy(target, immutableHandlers)); | ||
return immutableMap.get(target); | ||
}; | ||
// Store stacks for the current scope and the default namespaces and export APIs | ||
// to interact with them. | ||
const scopeStack = []; | ||
const namespaceStack = []; | ||
const getContext = namespace => getScope()?.context[namespace || namespaceStack.slice(-1)[0]]; | ||
exports.getContext = getContext; | ||
const getElement = () => { | ||
if (!getScope()) { | ||
throw Error('Cannot call `getElement()` outside getters and actions used by directives.'); | ||
} | ||
const { | ||
ref, | ||
state, | ||
props | ||
} = getScope(); | ||
return Object.freeze({ | ||
ref: ref.current, | ||
state, | ||
props: deepImmutable(props) | ||
}); | ||
}; | ||
exports.getElement = getElement; | ||
const getScope = () => scopeStack.slice(-1)[0]; | ||
exports.getScope = getScope; | ||
const setScope = scope => { | ||
scopeStack.push(scope); | ||
}; | ||
exports.setScope = setScope; | ||
const resetScope = () => { | ||
scopeStack.pop(); | ||
}; | ||
exports.resetScope = resetScope; | ||
const setNamespace = namespace => { | ||
namespaceStack.push(namespace); | ||
}; | ||
exports.setNamespace = setNamespace; | ||
const resetNamespace = () => { | ||
namespaceStack.pop(); | ||
}; | ||
// WordPress Directives. | ||
exports.resetNamespace = resetNamespace; | ||
const directiveCallbacks = {}; | ||
@@ -128,6 +190,6 @@ const directivePriorities = {}; | ||
exports.directive = directive; | ||
const resolve = (path, ctx) => { | ||
const resolve = (path, namespace) => { | ||
let current = { | ||
..._store.rawStore, | ||
context: ctx | ||
..._store.stores.get(namespace), | ||
context: getScope().context[namespace] | ||
}; | ||
@@ -140,13 +202,15 @@ path.split('.').forEach(p => current = current[p]); | ||
const getEvaluate = ({ | ||
ref | ||
} = {}) => (path, extraArgs = {}) => { | ||
scope | ||
} = {}) => (entry, ...args) => { | ||
let { | ||
value: path, | ||
namespace | ||
} = entry; | ||
// If path starts with !, remove it and save a flag. | ||
const hasNegationOperator = path[0] === '!' && !!(path = path.slice(1)); | ||
const value = resolve(path, extraArgs.context); | ||
const returnValue = typeof value === 'function' ? value({ | ||
ref: ref.current, | ||
..._store.rawStore, | ||
...extraArgs | ||
}) : value; | ||
return hasNegationOperator ? !returnValue : returnValue; | ||
setScope(scope); | ||
const value = resolve(path, namespace); | ||
const result = typeof value === 'function' ? value(...args) : value; | ||
resetScope(); | ||
return hasNegationOperator ? !result : result; | ||
}; | ||
@@ -167,3 +231,3 @@ | ||
// Priority level wrapper. | ||
// Component that wraps each priority level of directives of an element. | ||
const Directives = ({ | ||
@@ -173,20 +237,23 @@ directives, | ||
element, | ||
evaluate, | ||
originalProps, | ||
elemRef | ||
previousScope = {} | ||
}) => { | ||
// Initialize the DOM reference. | ||
// eslint-disable-next-line react-hooks/rules-of-hooks | ||
elemRef = elemRef || (0, _hooks.useRef)(null); | ||
// Create a reference to the evaluate function using the DOM reference. | ||
// eslint-disable-next-line react-hooks/rules-of-hooks, react-hooks/exhaustive-deps | ||
evaluate = evaluate || (0, _hooks.useCallback)(getEvaluate({ | ||
ref: elemRef | ||
// Initialize the scope of this element. These scopes are different per each | ||
// level because each level has a different context, but they share the same | ||
// element ref, state and props. | ||
const scope = (0, _hooks.useRef)({}).current; | ||
scope.evaluate = (0, _hooks.useCallback)(getEvaluate({ | ||
scope | ||
}), []); | ||
scope.context = (0, _hooks.useContext)(context); | ||
/* eslint-disable react-hooks/rules-of-hooks */ | ||
scope.ref = previousScope.ref || (0, _hooks.useRef)(null); | ||
scope.state = previousScope.state || (0, _hooks.useRef)((0, _deepsignal.deepSignal)({})).current; | ||
/* eslint-enable react-hooks/rules-of-hooks */ | ||
// Create a fresh copy of the vnode element. | ||
// Create a fresh copy of the vnode element and add the props to the scope. | ||
element = (0, _preact.cloneElement)(element, { | ||
ref: elemRef | ||
ref: scope.ref | ||
}); | ||
scope.props = element.props; | ||
@@ -198,5 +265,4 @@ // Recursively render the wrapper for the next priority level. | ||
element: element, | ||
evaluate: evaluate, | ||
originalProps: originalProps, | ||
elemRef: elemRef | ||
previousScope: scope | ||
}) : element; | ||
@@ -212,4 +278,5 @@ const props = { | ||
context, | ||
evaluate | ||
evaluate: scope.evaluate | ||
}; | ||
setScope(scope); | ||
for (const directiveName of currentPriorityLevel) { | ||
@@ -219,2 +286,3 @@ const wrapper = directiveCallbacks[directiveName]?.(directiveArgs); | ||
} | ||
resetScope(); | ||
return props.children; | ||
@@ -229,3 +297,5 @@ }; | ||
const directives = props.__directives; | ||
if (directives.key) vnode.key = directives.key.default; | ||
if (directives.key) vnode.key = directives.key.find(({ | ||
suffix | ||
}) => suffix === 'default').value; | ||
delete props.__directives; | ||
@@ -232,0 +302,0 @@ const priorityLevels = getPriorityLevels(directives); |
@@ -25,2 +25,14 @@ "use strict"; | ||
}); | ||
Object.defineProperty(exports, "getContext", { | ||
enumerable: true, | ||
get: function () { | ||
return _hooks.getContext; | ||
} | ||
}); | ||
Object.defineProperty(exports, "getElement", { | ||
enumerable: true, | ||
get: function () { | ||
return _hooks.getElement; | ||
} | ||
}); | ||
Object.defineProperty(exports, "navigate", { | ||
@@ -76,4 +88,3 @@ enumerable: true, | ||
await (0, _router.init)(); | ||
_store.afterLoads.forEach(afterLoad => afterLoad(_store.rawStore)); | ||
}); | ||
//# sourceMappingURL=index.js.map |
@@ -6,4 +6,7 @@ "use strict"; | ||
}); | ||
exports.store = exports.rawStore = exports.afterLoads = void 0; | ||
exports.store = store; | ||
exports.stores = void 0; | ||
var _deepsignal = require("deepsignal"); | ||
var _signals = require("@preact/signals"); | ||
var _hooks = require("./hooks"); | ||
/** | ||
@@ -13,7 +16,16 @@ * External dependencies | ||
const isObject = item => item && typeof item === 'object' && !Array.isArray(item); | ||
/** | ||
* Internal dependencies | ||
*/ | ||
const isObject = item => !!item && typeof item === 'object' && !Array.isArray(item); | ||
const deepMerge = (target, source) => { | ||
if (isObject(target) && isObject(source)) { | ||
for (const key in source) { | ||
if (isObject(source[key])) { | ||
const getter = Object.getOwnPropertyDescriptor(source, key)?.get; | ||
if (typeof getter === 'function') { | ||
Object.defineProperty(target, key, { | ||
get: getter | ||
}); | ||
} else if (isObject(source[key])) { | ||
if (!target[key]) Object.assign(target, { | ||
@@ -31,10 +43,8 @@ [key]: {} | ||
}; | ||
const getSerializedState = () => { | ||
const storeTag = document.querySelector(`script[type="application/json"]#wp-interactivity-store-data`); | ||
if (!storeTag) return {}; | ||
const parseInitialState = () => { | ||
const storeTag = document.querySelector(`script[type="application/json"]#wp-interactivity-initial-state`); | ||
if (!storeTag?.textContent) return {}; | ||
try { | ||
const { | ||
state | ||
} = JSON.parse(storeTag.textContent); | ||
if (isObject(state)) return state; | ||
const initialState = JSON.parse(storeTag.textContent); | ||
if (isObject(initialState)) return initialState; | ||
throw Error('Parsed state is not an object'); | ||
@@ -47,9 +57,103 @@ } catch (e) { | ||
}; | ||
const afterLoads = new Set(); | ||
exports.afterLoads = afterLoads; | ||
const rawState = getSerializedState(); | ||
const rawStore = { | ||
state: (0, _deepsignal.deepSignal)(rawState) | ||
const stores = new Map(); | ||
exports.stores = stores; | ||
const rawStores = new Map(); | ||
const storeLocks = new Map(); | ||
const objToProxy = new WeakMap(); | ||
const proxyToNs = new WeakMap(); | ||
const scopeToGetters = new WeakMap(); | ||
const proxify = (obj, ns) => { | ||
if (!objToProxy.has(obj)) { | ||
const proxy = new Proxy(obj, handlers); | ||
objToProxy.set(obj, proxy); | ||
proxyToNs.set(proxy, ns); | ||
} | ||
return objToProxy.get(obj); | ||
}; | ||
const handlers = { | ||
get: (target, key, receiver) => { | ||
const ns = proxyToNs.get(receiver); | ||
// Check if the property is a getter and we are inside an scope. If that is | ||
// the case, we clone the getter to avoid overwriting the scoped | ||
// dependencies of the computed each time that getter runs. | ||
const getter = Object.getOwnPropertyDescriptor(target, key)?.get; | ||
if (getter) { | ||
const scope = (0, _hooks.getScope)(); | ||
if (scope) { | ||
const getters = scopeToGetters.get(scope) || scopeToGetters.set(scope, new Map()).get(scope); | ||
if (!getters.has(getter)) { | ||
getters.set(getter, (0, _signals.computed)(() => { | ||
(0, _hooks.setNamespace)(ns); | ||
(0, _hooks.setScope)(scope); | ||
try { | ||
return getter.call(target); | ||
} finally { | ||
(0, _hooks.resetScope)(); | ||
(0, _hooks.resetNamespace)(); | ||
} | ||
})); | ||
} | ||
return getters.get(getter).value; | ||
} | ||
} | ||
const result = Reflect.get(target, key, receiver); | ||
// Check if the proxy is the store root and no key with that name exist. In | ||
// that case, return an empty object for the requested key. | ||
if (typeof result === 'undefined' && receiver === stores.get(ns)) { | ||
const obj = {}; | ||
Reflect.set(target, key, obj, receiver); | ||
return proxify(obj, ns); | ||
} | ||
// Check if the property is a generator. If it is, we turn it into an | ||
// asynchronous function where we restore the default namespace and scope | ||
// each time it awaits/yields. | ||
if (result?.constructor?.name === 'GeneratorFunction') { | ||
return async (...args) => { | ||
const scope = (0, _hooks.getScope)(); | ||
const gen = result(...args); | ||
let value; | ||
let it; | ||
while (true) { | ||
(0, _hooks.setNamespace)(ns); | ||
(0, _hooks.setScope)(scope); | ||
try { | ||
it = gen.next(value); | ||
} finally { | ||
(0, _hooks.resetScope)(); | ||
(0, _hooks.resetNamespace)(); | ||
} | ||
try { | ||
value = await it.value; | ||
} catch (e) { | ||
gen.throw(e); | ||
} | ||
if (it.done) break; | ||
} | ||
return value; | ||
}; | ||
} | ||
// Check if the property is a synchronous function. If it is, set the | ||
// default namespace. Synchronous functions always run in the proper scope, | ||
// which is set by the Directives component. | ||
if (typeof result === 'function') { | ||
return (...args) => { | ||
(0, _hooks.setNamespace)(ns); | ||
try { | ||
return result(...args); | ||
} finally { | ||
(0, _hooks.resetNamespace)(); | ||
} | ||
}; | ||
} | ||
// Check if the property is an object. If it is, proxyify it. | ||
if (isObject(result)) return proxify(result, ns); | ||
return result; | ||
} | ||
}; | ||
/** | ||
@@ -63,6 +167,2 @@ * @typedef StoreProps Properties object passed to `store`. | ||
* @typedef StoreOptions Options object. | ||
* @property {(store:any) => void} [afterLoad] Callback to be executed after the | ||
* Interactivity API has been set up | ||
* and the store is ready. It | ||
* receives the store as argument. | ||
*/ | ||
@@ -111,14 +211,55 @@ | ||
*/ | ||
exports.rawStore = rawStore; | ||
const store = ({ | ||
state, | ||
const universalUnlock = 'I acknowledge that using a private store means my plugin will inevitably break on the next store release.'; | ||
function store(namespace, { | ||
state = {}, | ||
...block | ||
}, { | ||
afterLoad | ||
} = {}) => { | ||
deepMerge(rawStore, block); | ||
deepMerge(rawState, state); | ||
if (afterLoad) afterLoads.add(afterLoad); | ||
}; | ||
exports.store = store; | ||
} = {}, { | ||
lock = false | ||
} = {}) { | ||
if (!stores.has(namespace)) { | ||
// Lock the store if the passed lock is different from the universal | ||
// unlock. Once the lock is set (either false, true, or a given string), | ||
// it cannot change. | ||
if (lock !== universalUnlock) { | ||
storeLocks.set(namespace, lock); | ||
} | ||
const rawStore = { | ||
state: (0, _deepsignal.deepSignal)(state), | ||
...block | ||
}; | ||
const proxiedStore = new Proxy(rawStore, handlers); | ||
rawStores.set(namespace, rawStore); | ||
stores.set(namespace, proxiedStore); | ||
proxyToNs.set(proxiedStore, namespace); | ||
} else { | ||
// Lock the store if it wasn't locked yet and the passed lock is | ||
// different from the universal unlock. If no lock is given, the store | ||
// will be public and won't accept any lock from now on. | ||
if (lock !== universalUnlock && !storeLocks.has(namespace)) { | ||
storeLocks.set(namespace, lock); | ||
} else { | ||
const storeLock = storeLocks.get(namespace); | ||
const isLockValid = lock === universalUnlock || lock !== true && lock === storeLock; | ||
if (!isLockValid) { | ||
if (!storeLock) { | ||
throw Error('Cannot lock a public store'); | ||
} else { | ||
throw Error('Cannot unlock a private store with an invalid lock code'); | ||
} | ||
} | ||
} | ||
const target = rawStores.get(namespace); | ||
deepMerge(target, block); | ||
deepMerge(target.state, state); | ||
} | ||
return stores.get(namespace); | ||
} | ||
// Parse and populate the initial state. | ||
Object.entries(parseInitialState()).forEach(([namespace, state]) => { | ||
store(namespace, { | ||
state | ||
}); | ||
}); | ||
//# sourceMappingURL=store.js.map |
@@ -21,2 +21,3 @@ "use strict"; | ||
const fullPrefix = `data-${_constants.directivePrefix}-`; | ||
let namespace = null; | ||
@@ -36,2 +37,7 @@ // Regular expression for directive parsing. | ||
// Regular expression for reference parsing. It can contain a namespace before | ||
// the reference, separated by `::`, like `some-namespace::state.somePath`. | ||
// Namespaces can contain any alphanumeric characters, hyphens, underscores or | ||
// forward slashes. References don't have any restrictions. | ||
const nsPathRegExp = /^([\w-_\/]+)::(.+)$/; | ||
const hydratedIslands = new WeakSet(); | ||
@@ -63,4 +69,3 @@ | ||
const children = []; | ||
const directives = {}; | ||
let hasDirectives = false; | ||
const directives = []; | ||
let ignore = false; | ||
@@ -73,13 +78,15 @@ let island = false; | ||
ignore = true; | ||
} else if (n === islandAttr) { | ||
island = true; | ||
} else { | ||
hasDirectives = true; | ||
let val = attributes[i].value; | ||
var _nsPathRegExp$exec$sl; | ||
let [ns, value] = (_nsPathRegExp$exec$sl = nsPathRegExp.exec(attributes[i].value)?.slice(1)) !== null && _nsPathRegExp$exec$sl !== void 0 ? _nsPathRegExp$exec$sl : [null, attributes[i].value]; | ||
try { | ||
val = JSON.parse(val); | ||
value = JSON.parse(value); | ||
} catch (e) {} | ||
const [, prefix, suffix] = directiveParser.exec(n); | ||
directives[prefix] = directives[prefix] || {}; | ||
directives[prefix][suffix || 'default'] = val; | ||
if (n === islandAttr) { | ||
var _value$namespace; | ||
island = true; | ||
namespace = (_value$namespace = value?.namespace) !== null && _value$namespace !== void 0 ? _value$namespace : null; | ||
} else { | ||
directives.push([n, ns, value]); | ||
} | ||
} | ||
@@ -99,3 +106,14 @@ } else if (n === 'ref') { | ||
if (island) hydratedIslands.add(node); | ||
if (hasDirectives) props.__directives = directives; | ||
if (directives.length) { | ||
props.__directives = directives.reduce((obj, [name, ns, value]) => { | ||
const [, prefix, suffix = 'default'] = directiveParser.exec(name); | ||
if (!obj[prefix]) obj[prefix] = []; | ||
obj[prefix].push({ | ||
namespace: ns !== null && ns !== void 0 ? ns : namespace, | ||
value, | ||
suffix | ||
}); | ||
return obj; | ||
}, {}); | ||
} | ||
let child = treeWalker.firstChild(); | ||
@@ -102,0 +120,0 @@ if (child) { |
@@ -5,2 +5,8 @@ <!-- Learn how to maintain this file at https://github.com/WordPress/gutenberg/tree/HEAD/packages#maintaining-changelogs. --> | ||
## 3.0.0 (2023-11-29) | ||
### Breaking Change | ||
- Implement the new `store()` API as specified in the [proposal](https://github.com/WordPress/gutenberg/discussions/53586). ([#55459](https://github.com/WordPress/gutenberg/pull/55459)) | ||
## 2.7.0 (2023-11-16) | ||
@@ -7,0 +13,0 @@ |
{ | ||
"name": "@wordpress/interactivity", | ||
"version": "2.7.0", | ||
"version": "3.0.0", | ||
"description": "Package that provides a standard and simple way to handle the frontend interactivity of Gutenberg blocks.", | ||
@@ -27,2 +27,3 @@ "author": "The WordPress Contributors", | ||
"react-native": "src/index", | ||
"types": "build-types", | ||
"dependencies": { | ||
@@ -36,3 +37,3 @@ "@preact/signals": "^1.1.3", | ||
}, | ||
"gitHead": "839018ff6029ba749780e288e08ff9cd898e50e8" | ||
"gitHead": "d98dff8ea96f29cfea045bf964269f46f040d539" | ||
} |
@@ -20,2 +20,3 @@ /** | ||
import { SlotProvider, Slot, Fill } from './slots'; | ||
import { navigate } from './router'; | ||
@@ -44,5 +45,3 @@ const isObject = ( item ) => | ||
( { | ||
directives: { | ||
context: { default: newContext }, | ||
}, | ||
directives: { context }, | ||
props: { children }, | ||
@@ -54,8 +53,13 @@ context: inheritedContext, | ||
const currentValue = useRef( deepSignal( {} ) ); | ||
const passedValues = context.map( ( { value } ) => value ); | ||
currentValue.current = useMemo( () => { | ||
const newValue = deepSignal( newContext ); | ||
const newValue = context | ||
.map( ( c ) => deepSignal( { [ c.namespace ]: c.value } ) ) | ||
.reduceRight( mergeDeepSignals ); | ||
mergeDeepSignals( newValue, inheritedValue ); | ||
mergeDeepSignals( currentValue.current, newValue, true ); | ||
return currentValue.current; | ||
}, [ newContext, inheritedValue ] ); | ||
}, [ inheritedValue, ...passedValues ] ); | ||
@@ -74,9 +78,6 @@ return ( | ||
// data-wp-effect--[name] | ||
directive( 'effect', ( { directives: { effect }, context, evaluate } ) => { | ||
const contextValue = useContext( context ); | ||
Object.values( effect ).forEach( ( path ) => { | ||
useSignalEffect( () => { | ||
return evaluate( path, { context: contextValue } ); | ||
} ); | ||
// data-wp-watch--[name] | ||
directive( 'watch', ( { directives: { watch }, evaluate } ) => { | ||
watch.forEach( ( entry ) => { | ||
useSignalEffect( () => evaluate( entry ) ); | ||
} ); | ||
@@ -86,8 +87,5 @@ } ); | ||
// data-wp-init--[name] | ||
directive( 'init', ( { directives: { init }, context, evaluate } ) => { | ||
const contextValue = useContext( context ); | ||
Object.values( init ).forEach( ( path ) => { | ||
useEffect( () => { | ||
return evaluate( path, { context: contextValue } ); | ||
}, [] ); | ||
directive( 'init', ( { directives: { init }, evaluate } ) => { | ||
init.forEach( ( entry ) => { | ||
useEffect( () => evaluate( entry ), [] ); | ||
} ); | ||
@@ -97,7 +95,6 @@ } ); | ||
// data-wp-on--[event] | ||
directive( 'on', ( { directives: { on }, element, evaluate, context } ) => { | ||
const contextValue = useContext( context ); | ||
Object.entries( on ).forEach( ( [ name, path ] ) => { | ||
element.props[ `on${ name }` ] = ( event ) => { | ||
evaluate( path, { event, context: contextValue } ); | ||
directive( 'on', ( { directives: { on }, element, evaluate } ) => { | ||
on.forEach( ( entry ) => { | ||
element.props[ `on${ entry.suffix }` ] = ( event ) => { | ||
evaluate( entry, event ); | ||
}; | ||
@@ -110,16 +107,8 @@ } ); | ||
'class', | ||
( { | ||
directives: { class: className }, | ||
element, | ||
evaluate, | ||
context, | ||
} ) => { | ||
const contextValue = useContext( context ); | ||
Object.keys( className ) | ||
.filter( ( n ) => n !== 'default' ) | ||
.forEach( ( name ) => { | ||
const result = evaluate( className[ name ], { | ||
className: name, | ||
context: contextValue, | ||
} ); | ||
( { directives: { class: className }, element, evaluate } ) => { | ||
className | ||
.filter( ( { suffix } ) => suffix !== 'default' ) | ||
.forEach( ( entry ) => { | ||
const name = entry.suffix; | ||
const result = evaluate( entry, { className: name } ); | ||
const currentClass = element.props.class || ''; | ||
@@ -189,107 +178,138 @@ const classFinder = new RegExp( | ||
// data-wp-style--[style-key] | ||
directive( | ||
'style', | ||
( { directives: { style }, element, evaluate, context } ) => { | ||
const contextValue = useContext( context ); | ||
Object.keys( style ) | ||
.filter( ( n ) => n !== 'default' ) | ||
.forEach( ( key ) => { | ||
const result = evaluate( style[ key ], { | ||
key, | ||
context: contextValue, | ||
} ); | ||
element.props.style = element.props.style || {}; | ||
if ( typeof element.props.style === 'string' ) | ||
element.props.style = cssStringToObject( | ||
element.props.style | ||
); | ||
if ( ! result ) delete element.props.style[ key ]; | ||
else element.props.style[ key ] = result; | ||
directive( 'style', ( { directives: { style }, element, evaluate } ) => { | ||
style | ||
.filter( ( { suffix } ) => suffix !== 'default' ) | ||
.forEach( ( entry ) => { | ||
const key = entry.suffix; | ||
const result = evaluate( entry, { key } ); | ||
element.props.style = element.props.style || {}; | ||
if ( typeof element.props.style === 'string' ) | ||
element.props.style = cssStringToObject( | ||
element.props.style | ||
); | ||
if ( ! result ) delete element.props.style[ key ]; | ||
else element.props.style[ key ] = result; | ||
useEffect( () => { | ||
// This seems necessary because Preact doesn't change the styles on | ||
// the hydration, so we have to do it manually. It doesn't need deps | ||
// because it only needs to do it the first time. | ||
if ( ! result ) { | ||
element.ref.current.style.removeProperty( key ); | ||
} else { | ||
element.ref.current.style[ key ] = result; | ||
} | ||
}, [] ); | ||
} ); | ||
} | ||
); | ||
useEffect( () => { | ||
// This seems necessary because Preact doesn't change the styles on | ||
// the hydration, so we have to do it manually. It doesn't need deps | ||
// because it only needs to do it the first time. | ||
if ( ! result ) { | ||
element.ref.current.style.removeProperty( key ); | ||
} else { | ||
element.ref.current.style[ key ] = result; | ||
} | ||
}, [] ); | ||
} ); | ||
} ); | ||
// data-wp-bind--[attribute] | ||
directive( 'bind', ( { directives: { bind }, element, evaluate } ) => { | ||
bind.filter( ( { suffix } ) => suffix !== 'default' ).forEach( | ||
( entry ) => { | ||
const attribute = entry.suffix; | ||
const result = evaluate( entry ); | ||
element.props[ attribute ] = result; | ||
// Preact doesn't handle the `role` attribute properly, as it doesn't remove it when `null`. | ||
// We need this workaround until the following issue is solved: | ||
// https://github.com/preactjs/preact/issues/4136 | ||
useLayoutEffect( () => { | ||
if ( | ||
attribute === 'role' && | ||
( result === null || result === undefined ) | ||
) { | ||
element.ref.current.removeAttribute( attribute ); | ||
} | ||
}, [ attribute, result ] ); | ||
// This seems necessary because Preact doesn't change the attributes | ||
// on the hydration, so we have to do it manually. It doesn't need | ||
// deps because it only needs to do it the first time. | ||
useEffect( () => { | ||
const el = element.ref.current; | ||
// We set the value directly to the corresponding | ||
// HTMLElement instance property excluding the following | ||
// special cases. | ||
// We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129 | ||
if ( | ||
attribute !== 'width' && | ||
attribute !== 'height' && | ||
attribute !== 'href' && | ||
attribute !== 'list' && | ||
attribute !== 'form' && | ||
// Default value in browsers is `-1` and an empty string is | ||
// cast to `0` instead | ||
attribute !== 'tabIndex' && | ||
attribute !== 'download' && | ||
attribute !== 'rowSpan' && | ||
attribute !== 'colSpan' && | ||
attribute !== 'role' && | ||
attribute in el | ||
) { | ||
try { | ||
el[ attribute ] = | ||
result === null || result === undefined | ||
? '' | ||
: result; | ||
return; | ||
} catch ( err ) {} | ||
} | ||
// aria- and data- attributes have no boolean representation. | ||
// A `false` value is different from the attribute not being | ||
// present, so we can't remove it. | ||
// We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 | ||
if ( | ||
result !== null && | ||
result !== undefined && | ||
( result !== false || attribute[ 4 ] === '-' ) | ||
) { | ||
el.setAttribute( attribute, result ); | ||
} else { | ||
el.removeAttribute( attribute ); | ||
} | ||
}, [] ); | ||
} | ||
); | ||
} ); | ||
// data-wp-navigation-link | ||
directive( | ||
'bind', | ||
( { directives: { bind }, element, context, evaluate } ) => { | ||
const contextValue = useContext( context ); | ||
Object.entries( bind ) | ||
.filter( ( n ) => n !== 'default' ) | ||
.forEach( ( [ attribute, path ] ) => { | ||
const result = evaluate( path, { | ||
context: contextValue, | ||
} ); | ||
element.props[ attribute ] = result; | ||
// Preact doesn't handle the `role` attribute properly, as it doesn't remove it when `null`. | ||
// We need this workaround until the following issue is solved: | ||
// https://github.com/preactjs/preact/issues/4136 | ||
useLayoutEffect( () => { | ||
if ( | ||
attribute === 'role' && | ||
( result === null || result === undefined ) | ||
) { | ||
element.ref.current.removeAttribute( attribute ); | ||
} | ||
}, [ attribute, result ] ); | ||
'navigation-link', | ||
( { | ||
directives: { 'navigation-link': navigationLink }, | ||
props: { href }, | ||
element, | ||
} ) => { | ||
const { value: link } = navigationLink.find( | ||
( { suffix } ) => suffix === 'default' | ||
); | ||
// This seems necessary because Preact doesn't change the attributes | ||
// on the hydration, so we have to do it manually. It doesn't need | ||
// deps because it only needs to do it the first time. | ||
useEffect( () => { | ||
const el = element.ref.current; | ||
useEffect( () => { | ||
// Prefetch the page if it is in the directive options. | ||
if ( link?.prefetch ) { | ||
// prefetch( href ); | ||
} | ||
} ); | ||
// We set the value directly to the corresponding | ||
// HTMLElement instance property excluding the following | ||
// special cases. | ||
// We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L110-L129 | ||
if ( | ||
attribute !== 'width' && | ||
attribute !== 'height' && | ||
attribute !== 'href' && | ||
attribute !== 'list' && | ||
attribute !== 'form' && | ||
// Default value in browsers is `-1` and an empty string is | ||
// cast to `0` instead | ||
attribute !== 'tabIndex' && | ||
attribute !== 'download' && | ||
attribute !== 'rowSpan' && | ||
attribute !== 'colSpan' && | ||
attribute !== 'role' && | ||
attribute in el | ||
) { | ||
try { | ||
el[ attribute ] = | ||
result === null || result === undefined | ||
? '' | ||
: result; | ||
return; | ||
} catch ( err ) {} | ||
} | ||
// aria- and data- attributes have no boolean representation. | ||
// A `false` value is different from the attribute not being | ||
// present, so we can't remove it. | ||
// We follow Preact's logic: https://github.com/preactjs/preact/blob/ea49f7a0f9d1ff2c98c0bdd66aa0cbc583055246/src/diff/props.js#L131C24-L136 | ||
if ( | ||
result !== null && | ||
result !== undefined && | ||
( result !== false || attribute[ 4 ] === '-' ) | ||
) { | ||
el.setAttribute( attribute, result ); | ||
} else { | ||
el.removeAttribute( attribute ); | ||
} | ||
}, [] ); | ||
} ); | ||
// Don't do anything if it's falsy. | ||
if ( link !== false ) { | ||
element.props.onclick = async ( event ) => { | ||
event.preventDefault(); | ||
// Fetch the page (or return it from cache). | ||
await navigate( href ); | ||
// Update the scroll, depending on the option. True by default. | ||
if ( link?.scroll === 'smooth' ) { | ||
window.scrollTo( { | ||
top: 0, | ||
left: 0, | ||
behavior: 'smooth', | ||
} ); | ||
} else if ( link?.scroll !== false ) { | ||
window.scrollTo( 0, 0 ); | ||
} | ||
}; | ||
} | ||
} | ||
@@ -319,18 +339,6 @@ ); | ||
// data-wp-text | ||
directive( | ||
'text', | ||
( { | ||
directives: { | ||
text: { default: text }, | ||
}, | ||
element, | ||
evaluate, | ||
context, | ||
} ) => { | ||
const contextValue = useContext( context ); | ||
element.props.children = evaluate( text, { | ||
context: contextValue, | ||
} ); | ||
} | ||
); | ||
directive( 'text', ( { directives: { text }, element, evaluate } ) => { | ||
const entry = text.find( ( { suffix } ) => suffix === 'default' ); | ||
element.props.children = evaluate( entry ); | ||
} ); | ||
@@ -340,11 +348,8 @@ // data-wp-slot | ||
'slot', | ||
( { | ||
directives: { | ||
slot: { default: slot }, | ||
}, | ||
props: { children }, | ||
element, | ||
} ) => { | ||
const name = typeof slot === 'string' ? slot : slot.name; | ||
const position = slot.position || 'children'; | ||
( { directives: { slot }, props: { children }, element } ) => { | ||
const { value } = slot.find( | ||
( { suffix } ) => suffix === 'default' | ||
); | ||
const name = typeof value === 'string' ? value : value.name; | ||
const position = value.position || 'children'; | ||
@@ -382,12 +387,5 @@ if ( position === 'before' ) { | ||
'fill', | ||
( { | ||
directives: { | ||
fill: { default: fill }, | ||
}, | ||
props: { children }, | ||
evaluate, | ||
context, | ||
} ) => { | ||
const contextValue = useContext( context ); | ||
const slot = evaluate( fill, { context: contextValue } ); | ||
( { directives: { fill }, props: { children }, evaluate } ) => { | ||
const entry = fill.find( ( { suffix } ) => suffix === 'default' ); | ||
const slot = evaluate( entry ); | ||
return <Fill slot={ slot }>{ children }</Fill>; | ||
@@ -394,0 +392,0 @@ }, |
@@ -6,5 +6,5 @@ /** | ||
import { init } from './router'; | ||
import { rawStore, afterLoads } from './store'; | ||
export { store } from './store'; | ||
export { directive } from './hooks'; | ||
export { directive, getContext, getElement } from './hooks'; | ||
export { navigate, prefetch } from './router'; | ||
@@ -18,3 +18,2 @@ export { h as createElement } from 'preact'; | ||
await init(); | ||
afterLoads.forEach( ( afterLoad ) => afterLoad( rawStore ) ); | ||
} ); |
@@ -13,2 +13,3 @@ /** | ||
const fullPrefix = `data-${ p }-`; | ||
let namespace = null; | ||
@@ -29,2 +30,8 @@ // Regular expression for directive parsing. | ||
// Regular expression for reference parsing. It can contain a namespace before | ||
// the reference, separated by `::`, like `some-namespace::state.somePath`. | ||
// Namespaces can contain any alphanumeric characters, hyphens, underscores or | ||
// forward slashes. References don't have any restrictions. | ||
const nsPathRegExp = /^([\w-_\/]+)::(.+)$/; | ||
export const hydratedIslands = new WeakSet(); | ||
@@ -56,4 +63,3 @@ | ||
const children = []; | ||
const directives = {}; | ||
let hasDirectives = false; | ||
const directives = []; | ||
let ignore = false; | ||
@@ -70,13 +76,15 @@ let island = false; | ||
ignore = true; | ||
} else if ( n === islandAttr ) { | ||
island = true; | ||
} else { | ||
hasDirectives = true; | ||
let val = attributes[ i ].value; | ||
let [ ns, value ] = nsPathRegExp | ||
.exec( attributes[ i ].value ) | ||
?.slice( 1 ) ?? [ null, attributes[ i ].value ]; | ||
try { | ||
val = JSON.parse( val ); | ||
value = JSON.parse( value ); | ||
} catch ( e ) {} | ||
const [ , prefix, suffix ] = directiveParser.exec( n ); | ||
directives[ prefix ] = directives[ prefix ] || {}; | ||
directives[ prefix ][ suffix || 'default' ] = val; | ||
if ( n === islandAttr ) { | ||
island = true; | ||
namespace = value?.namespace ?? null; | ||
} else { | ||
directives.push( [ n, ns, value ] ); | ||
} | ||
} | ||
@@ -99,3 +107,18 @@ } else if ( n === 'ref' ) { | ||
if ( hasDirectives ) props.__directives = directives; | ||
if ( directives.length ) { | ||
props.__directives = directives.reduce( | ||
( obj, [ name, ns, value ] ) => { | ||
const [ , prefix, suffix = 'default' ] = | ||
directiveParser.exec( name ); | ||
if ( ! obj[ prefix ] ) obj[ prefix ] = []; | ||
obj[ prefix ].push( { | ||
namespace: ns ?? namespace, | ||
value, | ||
suffix, | ||
} ); | ||
return obj; | ||
}, | ||
{} | ||
); | ||
} | ||
@@ -102,0 +125,0 @@ let child = treeWalker.firstChild(); |
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
900989
81
4381