sprae
Version:
DOM microhydration
197 lines (175 loc) • 8.08 kB
JavaScript
import sprae, { parse, _state, _off, effect, untracked, _change, _touch, _signals, frag, throttle, mutate } from "../core.js"
// Row scope proxy — positional rows (s.c = source array) read item live via c[i], keyed rows hold direct ref s.r
const rowHandler = {
// Symbol.unscopables: `with` fetches it per identifier per eval — answer the miss here, don't walk scope→store→globalThis
get: (s, k) => k === s.v ? (s.c ? s.c[s.i] : s.r) : k === s.k ? (s.o ? s.o[s.i] : s.i) : k === _signals ? s : k === Symbol.unscopables ? undefined : s.l?.[k] !== undefined ? s.l[k] : s.p?.[k],
set: (s, k, v) => (k === s.v ? (s.c ? s.c[s.i] = v : s.r = v) : k === s.k ? 0 : s.l?.[k] !== undefined ? ((s.l[k] = v), 0) : k in (s.p?.[_signals] || {}) ? (s.p[k] = v) : (s.l ||= {})[k] = v, 1),
has: () => true
}
/**
* Each directive - renders list items from array/object/number.
* Syntax: `:each="item in items"` or `:each="(item, idx) of items"`
*
* Keyed by object identity for arrays of objects (including store arrays).
* Primitives use positional (index-based) mode.
*/
export default (tpl, state, expr) => {
// first standalone `in`/`of` splits the expression — `\b` on both sides so `includes`, `index`, `typeof` etc. in rhs don't match
const [, lhs, rhs] = expr.match(/^(.*?)\b(?:in|of)\b(.*)$/s) || []
let [itemVar, idxVar = "$"] = lhs.trim().replace(/\(|\)/g, '').split(/\s*,\s*/)
let doc = tpl.ownerDocument
let holder = tpl._eachHolder || (tpl._eachHolder = doc.createTextNode(""))
let rowMap = new Map, rows = [], items, keys, cur, keyed = false
// removal always evicts from rowMap — every path (keyed diff, positional shrink, clear) must, or rows leak
let rm = r => { rowMap.delete(r.scope.r); r.el.remove(); r.el[Symbol.dispose]?.() }
// all current rows go: one replaceChildren instead of N .remove(), keeping non-row siblings (whitespace, holder).
// bail if any keeper is an element — reinserting one could drop focus/iframe/selection state
let rmAll = () => {
let parent = holder.parentNode, keep = [], node
if (rows.length && !tpl.content && parent?.replaceChildren && rows[0].node.parentNode === parent) {
for (node = parent.firstChild; node && node !== rows[0].node; node = node.nextSibling)
if (node.nodeType === 1) { keep = null; break } else keep.push(node)
if (keep) for (node = holder; node; node = node.nextSibling)
if (node.nodeType === 1) { keep = null; break } else keep.push(node)
if (keep) {
parent.replaceChildren(...keep)
for (let r of rows) rowMap.delete(r.scope.r), r.el[Symbol.dispose]?.()
rows.length = 0
return
}
}
for (let r of rows) rm(r)
rows.length = 0
}
// _di tracks current DOM index so swap/reorder only touches actually-moved rows
// scope shape is uniform (keyed: r, positional: c/o) — monomorphic for the proxy traps
let mkrow = (r, c, i) => {
let scope = { p: state, v: itemVar, k: idxVar, r, c, i, o: keys, l: null }
let el = tpl.content ? frag(tpl) : tpl.cloneNode(true)
return { el, scope, proxy: new Proxy(scope, rowHandler), node: el.content || el, _di: i }
}
let insert = pending => {
if (!pending.length) return
let f = pending.length > 1 ? doc.createDocumentFragment() : null
for (let r of pending) f ? f.appendChild(r.node) : holder.before(r.node)
if (f) holder.before(f)
// element rows pair with tpl as clone master: first row records the directive scan, rest replay it
for (let r of pending) sprae(r.el, r.proxy, tpl.content ? undefined : tpl)
}
// untracked: update reads item signals (src[i]) but must not subscribe the :each effect to them —
// it re-runs via _change/_touch only, else every index write re-notifies it (O(N) per splice)
let update = throttle(() => untracked(() => mutate(() => {
let src = items, newl = src.length, prevl = rows.length, lenChanged = newl !== prevl
// detect keyed: array of objects (store items are shallow proxies — keyed by proxy identity)
keyed = false
for (let i = 0; i < newl; i++) {
if (src[i] != null) { keyed = typeof src[i] === 'object'; break }
}
if (keyed && prevl) {
let newRows = [], pending = [], seen = new Set(), moved = false, misplaced = false, reused = 0
for (let i = 0; i < newl; i++) {
let id = src[i]
if (id != null && typeof id === 'object') {
if (seen.has(id)) return // intermediate swap — retry after next index write
seen.add(id)
}
let row = rowMap.get(id)
if (row) {
reused++
// index-only shifts from remove/append keep DOM order — reorder only for same-length permutes (swap)
if (!lenChanged && row.scope.i !== i) moved = true
// insert() appends new rows at the tail — a reused row after a new one means wrong placement
if (pending.length) misplaced = true
row.scope.i = i; row.scope.r = id; row.scope.o = keys
} else {
row = mkrow(id, null, i)
rowMap.set(id, row)
pending.push(row)
}
newRows.push(row)
}
// clear/replace-all: nothing reused — bulk-remove old rows (rowMap already holds new entries, rmAll evicts by old row)
if (!reused) rmAll()
else for (let [id, row] of rowMap) if (!seen.has(id)) rm(row)
insert(pending)
if (misplaced) {
// new rows sit at the tail — sweep everything into list order
let next = holder
for (let i = newRows.length - 1; i >= 0; i--) {
let n = newRows[i].node
if (n.nextSibling !== next) next.before(n)
next = n
newRows[i]._di = i
}
}
else if (moved) {
// collect rows whose DOM position no longer matches their list index
let fix = []
for (let i = 0; i < newRows.length; i++) {
let row = newRows[i]
if (row._di !== i) fix.push(row)
row._di = i
}
// 2-node swap fast path (common case: JFB swap rows)
if (fix.length === 2) {
let a = fix[0].node, b = fix[1].node, t = doc.createTextNode('')
a.replaceWith(t); b.replaceWith(a); t.replaceWith(b)
} else {
// general reorder: backward sweep, only move rows that aren't already in place
let next = holder
for (let i = newRows.length - 1; i >= 0; i--) {
let n = newRows[i].node
if (n.nextSibling !== next) next.before(n)
next = n
}
}
} else for (let i = 0; i < newRows.length; i++) newRows[i]._di = i
rows = newRows
} else {
// --- POSITIONAL ---
if (prevl && cur !== src) {
rmAll()
prevl = 0; rowMap.clear()
}
cur = src
if (newl < prevl) {
if (!newl) rmAll()
else {
for (let i = newl; i < prevl; i++) rm(rows[i])
rows.length = newl
}
}
for (let i = 0; i < Math.min(prevl, newl); i++) {
rows[i].scope.c = src
if (keys) rows[i].scope.o = keys
}
if (newl > prevl) {
let pending = []
for (let i = prevl; i < newl; i++) {
let row = keyed ? mkrow(src[i], null, i) : mkrow(null, src, i)
rows.push(row)
if (keyed) rowMap.set(src[i], row)
pending.push(row)
}
insert(pending)
}
}
})))
if (tpl.parentNode) mutate(() => tpl.replaceWith(holder))
tpl[_state] = null
let cb = value => {
keys = null
if (typeof value === "number") items = Array.from({ length: value }, (_, i) => i + 1)
else if (value?.constructor === Object) keys = Object.keys(value), items = Object.values(value)
else items = value || []
let off = effect(() => {
items[_change]?.value
items[_touch] // O(1) subscribe to index/content changes (swap, splice) on list stores
update()
})
return () => off()
}
cb.eval = parse(rhs)
cb[_off] = () => { rmAll(); rowMap.clear() }
return cb
}