sprae
Version:
DOM microhydration
143 lines (114 loc) • 5.16 kB
JavaScript
// signals-based proxy
import { signal, computed, batch } from './signal.js'
import { parse } from './core.js';
export const _signals = Symbol('signals'),
_change = Symbol('change'),
_stash = '__',
// object store is not lazy
store = (values, parent) => {
if (!values) return values
// ignore existing state as argument or globals
if (values[_signals] || values[Symbol.toStringTag]) return values;
// non-objects: for array redirect to list
if (values.constructor !== Object) return Array.isArray(values) ? list(values) : values
// we must inherit signals to allow dynamic extend of parent state
let signals = Object.create(parent?.[_signals] || {}),
_len = signal(Object.keys(values).length),
stash
// proxy conducts prop access to signals
let state = new Proxy(signals, {
get: (_, k) => k === _change ? _len : k === _signals ? signals : k === _stash ? stash : k in signals ? signals[k]?.valueOf() : globalThis[k],
set: (_, k, v, s) => k === _stash ? (stash = v, 1) : (s = k in signals, set(signals, k, v), s || ++_len.value), // bump length for new signal
deleteProperty: (_, k) => (signals[k] && (signals[k][Symbol.dispose]?.(), delete signals[k], _len.value--), 1),
// subscribe to length when object is spread
ownKeys: () => (_len.value, Reflect.ownKeys(signals)),
has: _ => true // sandbox prevents writing to global
}),
// init signals for values
descs = Object.getOwnPropertyDescriptors(values)
for (let k in values) {
// getter turns into computed
if (descs[k]?.get)
// stash setter
(signals[k] = computed(descs[k].get.bind(state)))._set = descs[k].set?.bind(state);
else
// init blank signal - make sure we don't take prototype one
signals[k] = null, set(signals, k, values[k]);
}
return state
},
// array store - signals are lazy since arrays can be very large & expensive
list = values => {
// track last accessed property to find out if .length was directly accessed from expression or via .push/etc method
let lastProp,
// .length signal is stored separately, since it cannot be replaced on array
_len = signal(values.length),
// gotta fill with null since proto methods like .reduce may fail
signals = Array(values.length).fill(),
// proxy conducts prop access to signals
state = new Proxy(signals, {
get(_, k) {
// covers Symbol.isConcatSpreadable etc.
if (typeof k === 'symbol') return k === _change ? _len : k === _signals ? signals : signals[k]
// if .length is read within .push/etc - peek signal to avoid recursive subscription
if (k === 'length') return mut.includes(lastProp) ? _len.peek() : _len.value;
lastProp = k;
// create signal (lazy)
// NOTE: if you decide to unlazy values, think about large arrays - init upfront can be costly
return (signals[k] ?? (signals[k] = signal(store(values[k])))).valueOf()
},
set(_, k, v) {
// .length
if (k === 'length') {
// force cleaning up tail
for (let i = v; i < signals.length; i++) delete state[i]
// .length = N directly
_len.value = signals.length = v;
}
else {
set(signals, k, v)
// force changing length, if eg. a=[]; a[1]=1 - need to come after setting the item
if (k >= _len.peek()) _len.value = signals.length = +k + 1
}
return 1
},
deleteProperty: (_, k) => (signals[k]?.[Symbol.dispose]?.(), delete signals[k], 1),
})
return state
}
// length changing methods
const mut = ['push', 'pop', 'shift', 'unshift', 'splice']
// set/update signal value
const set = (signals, k, v) => {
let s = signals[k], cur
// untracked
if (k[0] === '_') signals[k] = v
// new property. preserve signal value as is
else if (!s) signals[k] = s = v?.peek ? v : signal(store(v))
// skip unchanged (although can be handled by last condition - we skip a few checks this way)
else if (v === (cur = s.peek()));
// stashed _set for value with getter/setter
else if (s._set) s._set(v)
// patch array
else if (Array.isArray(v) && Array.isArray(cur)) {
// if we update plain array (stored in signal) - take over value instead
if (cur[_change]) batch(() => {
for (let i = 0; i < v.length; i++) cur[i] = v[i]
cur.length = v.length // forces deleting tail signals
})
else s.value = v
}
// .x = y
else s.value = store(v)
}
// create expression setter, reflecting value back to state
export const setter = (expr, set = parse(`${expr}=${_stash}`)) => (
(state, value) => (
state[_stash] = value, // save value to stash
set(state)
)
)
// make sure state contains first element of path, eg. `a` from `a.b[c]`
// NOTE: we don't need since we force proxy sandbox
// export const ensure = (state, expr, name = expr.match(/^\w+(?=\s*(?:\.|\[|$))/)) => name && (state[_signals][name[0]] ??= null)
export default store