sprae
Version:
DOM microhydration
270 lines (221 loc) • 9.55 kB
JavaScript
/**
* @fileoverview Signals-powered reactive proxy store
* @module sprae/store
*/
import { signal, computed, batch, untracked } from './core.js'
/** Symbol for accessing the internal signals map */
export const _signals = Symbol('signals')
/** Symbol for the change signal that tracks object keys or array length */
export const _change = Symbol('change')
/** Symbol for list index/content bumps (swap, splice, index writes) */
export const _touch = Symbol('touch')
/** Symbol for stashed setter on computed values */
export const _set = Symbol('set')
/** Symbol for parent scope link */
const _parent = Symbol('parent')
// a hack to simulate sandbox for `with` in evaluator
let sandbox = true
/**
* Reactive store with signals backing.
* @template T
* @typedef {T & { [_signals]: Record<string | symbol, import('./core.js').Signal<any>> }} ReactiveStore
*/
/**
* Creates a reactive proxy store from an object or array.
* Properties become signals for fine-grained reactivity.
* Supports nested objects, arrays, computed getters, and methods.
*
* @template {Object} T
* @param {T} values - Initial values object
* @param {Object} [parent] - Parent scope for inheritance
* @returns {ReactiveStore<T>} Reactive proxy store
*
* @example
* const state = store({ count: 0, get doubled() { return this.count * 2 } })
* state.count = 5 // triggers updates
* state.doubled // 10 (computed)
*/
export const store = (values, parent) => {
if (!values) return values
// ignore globals
// FIXME: handle via has trap
if (values[Symbol.toStringTag]) return values;
// bypass existing store
if (values[_signals]) return values
// non-objects: for array redirect to list
if (values.constructor !== Object) return Array.isArray(values) ? list(values) : values
// _change stores total number of keys to track new props
let keyCount = Object.keys(values).length,
signals = {}
// proxy conducts prop access to signals
let state = new Proxy(Object.assign(signals, {
[_change]: signal(keyCount),
[_signals]: signals,
}), {
get: (_, k) => {
if (k in signals) {
// raw methods (no prototype) - bind to state for consistent `this`
if (signals.hasOwnProperty(k) && typeof signals[k] === 'function' && !signals[k].prototype) return signals[k].bind(state)
return (signals[k] ? signals[k].valueOf() : signals[k])
}
if (parent) {
return parent[k]
}
return (typeof globalThis[k] === 'function' && !globalThis[k].prototype ? globalThis[k].bind(globalThis) : globalThis[k])
},
set: (_, k, v) => {
// console.group('SET', k, v)
if (k in signals) return set(signals, k, v), 1
// turn off sandbox to check if parents have the prop - we don't want to create new prop in global scope
sandbox = false
// write transparency for parent scope, unlike prototype chain
// if prop is defined in parent scope (except global) - write there
if (parent && k in parent) {
parent[k] = v
}
// else create in current scope
else {
create(signals, k, v)
signals[_change].value = ++keyCount
}
sandbox = true
// console.groupEnd()
// bump length for new signal
return 1
},
// FIXME: try to avild calling Symbol.dispose here. Maybe _delete method?
deleteProperty: (_, k) => {
k in signals && (k[0] != '_' && signals[k]?.[Symbol.dispose]?.(), delete signals[k], signals[_change].value = --keyCount)
return 1
},
// subscribe to length when spreading
ownKeys: () => (signals[_change].value, Reflect.ownKeys(signals).filter(k => k !== _parent)),
// sandbox prevents writing to global
has: (_, k) => {
if (k in signals) return true
if (parent) return k in parent
return sandbox
}
})
Object.defineProperty(signals, _parent, { value: parent, configurable: true })
// init signals for values
const 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);
// init blank signal - make sure we don't take prototype one
else create(signals, k, values[k])
}
return state
}
/**
* Creates a reactive array store with lazy signal initialization.
* Arrays can be large, so signals are created on-demand.
* @param {any[]} values - Initial array values
* @param {Object} [parent=globalThis] - Parent scope
* @returns {ReactiveStore<any[]>} Reactive array proxy
*/
const list = (values, parent = globalThis) => {
// init signals eagerly — shallow() is cheap (1 Proxy + 1 signal per object item)
let signals = values.map(v => signal(shallow(v))),
// if .length was accessed from mutator (.push/etc) method
isMut = false,
// since array mutator methods read .length internally only once, we disable it on the moment of call, allowing rest of operations to be reactive
mut = fn => function () { isMut = true; return fn.apply(this, arguments); },
length = signal(values.length),
// _touch bumps notify keyed :each of index/content changes (swap, splice) in O(1)
// instead of forcing :each to subscribe to all N index signals
touch = signal(0),
bump = () => { touch.value++ },
// capture native array mutators before they're shadowed on signals[]
asplice = signals.splice,
// proxy passes prop access to signals
state = new Proxy(
Object.assign(signals, {
[_change]: length,
[_touch]: touch,
[_signals]: signals,
// patch mutators — `this` must be the list proxy so set traps fire reactively
push: mut(signals.push),
pop: mut(signals.pop),
shift: mut(signals.shift),
unshift: mut(signals.unshift),
splice: mut(function () { let r = asplice.apply(this, arguments); bump(); return r }),
}),
{
get(_, k) {
// console.log('GET', k, isMut)
// if .length is read within mutators - peek signal to avoid recursive subscription
// we need to ignore it only once and keep for the rest of the mutator call
if (k === 'length') return isMut ? (isMut = false, signals.length) : length.value;
// non-numeric
if (typeof k === 'symbol' || isNaN(k)) return signals[k]?.valueOf() ?? parent[k];
// signals are eagerly initialized; null slots from .length extension default to undefined
return (signals[k] ??= signal(undefined)).valueOf()
},
set(_, k, v) {
// console.log('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
length.value = signals.length = v;
}
// force changing length, if eg. a=[]; a[1]=1 - need to come after setting the item
else if (k >= signals.length) create(signals, k, v, shallow), state.length = +k + 1
// existing signal — bump for :each index tracking (swap)
else if (signals[k]) {
let s = signals[k], prev = s.peek?.() ?? s.valueOf()
set(signals, k, v, shallow)
if ((s.peek?.() ?? s.valueOf()) !== prev) bump()
} else create(signals, k, v, shallow)
return 1
},
// dispose notifies any signal deps, like :each
deleteProperty: (_, k) => (signals[k]?.[Symbol.dispose]?.(), delete signals[k], 1),
})
Object.defineProperty(signals, _parent, { value: parent, configurable: true })
return state
}
/**
* Creates a signal for a property value.
* Skips wrapping for untracked props (underscore prefix), existing signals, and functions.
* @param {Object} signals - Signals storage object
* @param {string} k - Property key
* @param {any} v - Property value
*/
const create = (signals, k, v, wrap = store) => (signals[k] = (k[0] == '_' || v?.peek || typeof v === 'function') ? v : signal(wrap(v)))
/** Lightweight reactive wrapper for array items — avoids full store() per item. */
const shallow = (v) => {
if (!v || typeof v !== 'object' || v.constructor !== Object) return v
if (v[_change]) return v // already reactive (store or shallow proxy)
let ver = signal(0)
return new Proxy(v, {
get: (t, k) => k === _signals ? t : k === _change ? ver : (ver.value, t[k]),
set: (t, k, val) => { let prev = t[k]; t[k] = val; if (prev !== val) ver.value++; return 1 },
has: () => true
})
}
/**
* Updates a signal value, handling arrays specially for efficient patching.
* @param {Object} signals - Signals storage object
* @param {string} k - Property key
* @param {any} v - New value
*/
const set = (signals, k, v, wrap = store) => {
if (k[0] === '_' || typeof signals[k] === 'function') return (signals[k] = v)
let _s = signals[k], _v = _s.peek?.() ?? _s
if (v === _v) return
// stashed _set for value with getter/setter
if (_s[_set]) return _s[_set](v)
// patch store array in-place to preserve identity (avoids reactive loops when an effect reads + writes same prop)
if (Array.isArray(v) && Array.isArray(_v)) {
if (_change in _v) untracked(() => batch(() => { for (let i = 0; i < v.length; i++) _v[i] = v[i]; _v.length = v.length }))
else _s.value = _change in v ? v : list(v)
}
else _s.value = wrap(v)
}
export default store