sprae 13.3.7 → 13.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core.js CHANGED
@@ -140,14 +140,19 @@ const err = (e, expr, el = currentEl) => {
140
140
  * @property {Record<string, Signal>} [_signals] - Internal signals map
141
141
  */
142
142
 
143
+ /** Symbol for cached directive scan on clone masters */
144
+ const _dirs = Symbol('dirs')
145
+
143
146
  /**
144
147
  * Applies directives to an HTML element and manages its reactive state.
145
148
  *
146
149
  * @param {Element} [el=document.body] - The target HTML element to apply directives to.
147
150
  * @param {Object} [state] - Initial state values to populate the element's reactive state.
151
+ * @param {Element} [master] - Internal: clone source (eg. :each template) — first clone records
152
+ * its directive scan on the master, later clones replay it without touching attributes.
148
153
  * @returns {SpraeState & Object} The reactive state object associated with the element.
149
154
  */
150
- const sprae = (root = document.body, state) => {
155
+ const sprae = (root = document.body, state, master) => {
151
156
  // repeated call can be caused by eg. :each with new objects with old keys
152
157
  if (root[_state]) return Object.assign(root[_state], state)
153
158
 
@@ -181,25 +186,41 @@ const sprae = (root = document.body, state) => {
181
186
  el[_off] = el[_on] = el[_dispose] = el[_add] = el[_state] = null
182
187
  }
183
188
 
184
- const add = el[_add] = (el) => {
185
- let _attrs = el.attributes, start;
186
-
187
- if (_attrs) for (let i = 0; i < _attrs.length;) {
188
- let { name, value } = _attrs[i]
189
-
190
- if (name.startsWith(prefix)) {
191
- el.removeAttribute(name)
189
+ // apply one directive to el; true = stop (subsprae directives like :each/:if/:scope change state identity)
190
+ const apply = (el, name, short, value, prev = el[_state]) => (
191
+ currentDir = name, currentEl = el,
192
+ // directive initializer can be redefined
193
+ fx.push(start = dir(el, short, value, state)), offs.push(start()),
194
+ el[_state] !== prev
195
+ )
196
+ let start
197
+
198
+ const add = el[_add] = (el, mel) => {
199
+ let dirs = mel?.[_dirs]
200
+
201
+ // replay master's recorded scan — no attribute reads, no parsing
202
+ if (dirs !== undefined) {
203
+ for (let [name, short, value] of dirs) {
204
+ el.removeAttribute(name) // clones from the recording batch still carry attrs; no-op for later ones
205
+ if (apply(el, name, short, value)) return
206
+ }
207
+ }
208
+ else {
209
+ let _attrs = el.attributes, rec = mel && (mel[_dirs] = [])
192
210
 
193
- let prev = el[_state]
194
- currentDir = name;
195
- currentEl = el;
211
+ if (_attrs) for (let i = 0; i < _attrs.length;) {
212
+ let { name, value } = _attrs[i]
196
213
 
197
- // directive initializer can be redefined
198
- fx.push(start = dir(el, name.slice(prefix.length), value, state)), offs.push(start())
214
+ if (name.startsWith(prefix)) {
215
+ el.removeAttribute(name)
216
+ // strip master too: later clones come out clean; unprocessed tail (after a stop) stays for subsprae
217
+ mel?.removeAttribute(name)
218
+ let short = name.slice(prefix.length)
219
+ rec?.push([name, short, value])
199
220
 
200
- // stop after subsprae directives (:each, :if, :scope) that change element's state identity
201
- if (el[_state] !== prev) return
202
- } else i++
221
+ if (apply(el, name, short, value)) return
222
+ } else i++
223
+ }
203
224
  }
204
225
 
205
226
  // custom elements own their children — don't descend
@@ -208,13 +229,14 @@ const sprae = (root = document.body, state) => {
208
229
  // :if and :each replace element with text node, which tweaks .children length, but .childNodes length persists
209
230
  // real DOM: firstChild/nextSibling avoids array copy; frag.childNodes is already snapshot array
210
231
  if (el.firstChild !== undefined) {
211
- let child = el.firstChild, next
212
- while (child) (next = child.nextSibling, child.nodeType == 1 && add(child), child = next)
232
+ // master is never mutated structurally, so its pointers stay aligned with pre-captured clone pointers
233
+ let child = el.firstChild, mchild = mel?.firstChild, next
234
+ while (child) (next = child.nextSibling, child.nodeType == 1 && add(child, mchild), mchild &&= mchild.nextSibling, child = next)
213
235
  }
214
236
  else for (let child of el.childNodes) child.nodeType == 1 && add(child)
215
237
  };
216
238
 
217
- add(el);
239
+ add(el, master);
218
240
 
219
241
  currentDir = currentEl = null;
220
242
 
@@ -443,8 +465,11 @@ export const clsx = (c) => !c ? '' : typeof c === 'string' ? c : (
443
465
  * @param {number|Function} [ms] - Delay in ms or scheduler function (default: microtask)
444
466
  * @returns {T} Throttled function
445
467
  */
468
+ /** Makes a scheduler from ms delay, custom scheduler fn, or default microtask */
469
+ const sched = (ms) => typeof ms === 'function' ? ms : ms ? (fn) => setTimeout(fn, ms) : queueMicrotask
470
+
446
471
  export const throttle = (fn, ms) => {
447
- let _planned = 0, _depth = 0, arg, schedule = typeof ms === 'function' ? ms : ms ? (fn) => setTimeout(fn, ms) : queueMicrotask;
472
+ let _planned = 0, _depth = 0, arg, schedule = sched(ms);
448
473
  const throttled = (e) => {
449
474
  arg = e
450
475
  if (!_planned++) fn(arg), schedule(() => {
@@ -468,7 +493,7 @@ export const throttle = (fn, ms) => {
468
493
  * @returns {T} Debounced function
469
494
  */
470
495
  export const debounce = (fn, ms, immediate) => {
471
- let schedule = typeof ms === 'function' ? ms : ms ? (fn) => setTimeout(fn, ms) : queueMicrotask;
496
+ let schedule = sched(ms);
472
497
  return immediate
473
498
  ? ((_blocked) => (arg) => !_blocked && (fn(arg), _blocked = 1, schedule(() => _blocked = 0)))()
474
499
  : ((_count = 0) => (arg, _c = ++_count) => schedule(() => _c == _count && fn(arg)))()
package/directive/each.js CHANGED
@@ -1,18 +1,12 @@
1
- import sprae, { parse, _state, _off, effect, _change, _signals, frag, throttle, mutate } from "../core.js";
1
+ import sprae, { parse, _state, _off, effect, untracked, _change, _touch, _signals, frag, throttle, mutate } from "../core.js"
2
2
 
3
- // Row scope proxies — positional reads item via cur[idx], keyed holds direct ref
4
- const posHandler = {
5
- get: (s, k) => k === s.v ? s.c?.[s.i] : k === s.k ? (s.o ? s.o[s.i] : s.i) : k === _signals ? s : s.l?.[k] !== undefined ? s.l[k] : s.p?.[k],
6
- set: (s, k, v) => (k === s.v ? (s.c && (s.c[s.i] = 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),
7
- has: () => true
8
- }
9
- const keyHandler = {
10
- get: (s, k) => k === s.v ? s.r : k === s.k ? s.i : k === _signals ? s : s.l?.[k] !== undefined ? s.l[k] : s.p?.[k],
11
- set: (s, k, v) => (k === s.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),
3
+ // Row scope proxy — positional rows (s.c = source array) read item live via c[i], keyed rows hold direct ref s.r
4
+ const rowHandler = {
5
+ 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 : s.l?.[k] !== undefined ? s.l[k] : s.p?.[k],
6
+ 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),
12
7
  has: () => true
13
8
  }
14
9
 
15
- const rm = r => { r.el[Symbol.dispose]?.(); r.el.remove() }
16
10
 
17
11
  /**
18
12
  * Each directive - renders list items from array/object/number.
@@ -22,17 +16,23 @@ const rm = r => { r.el[Symbol.dispose]?.(); r.el.remove() }
22
16
  * Primitives use positional (index-based) mode.
23
17
  */
24
18
  export default (tpl, state, expr) => {
25
- const [lhs, rhs] = expr.split(/\bin|of\b/)
19
+ // first standalone `in`/`of` splits the expression — `\b` on both sides so `includes`, `index`, `typeof` etc. in rhs don't match
20
+ const [, lhs, rhs] = expr.match(/^(.*?)\b(?:in|of)\b(.*)$/s) || []
26
21
  let [itemVar, idxVar = "$"] = lhs.trim().replace(/\(|\)/g, '').split(/\s*,\s*/)
27
22
 
28
23
  let doc = tpl.ownerDocument
29
24
  let holder = tpl._eachHolder || (tpl._eachHolder = doc.createTextNode(""))
30
25
  let rowMap = new Map, rows = [], items, keys, cur, keyed = false
31
26
 
32
- let mkrow = (scope, h) => {
33
- let proxy = new Proxy(scope, h)
27
+ // removal always evicts from rowMap — every path (keyed diff, positional shrink, clear) must, or rows leak
28
+ let rm = r => { rowMap.delete(r.scope.r); r.el.remove(); r.el[Symbol.dispose]?.() }
29
+
30
+ // _di tracks current DOM index so swap/reorder only touches actually-moved rows
31
+ // scope shape is uniform (keyed: r, positional: c/o) — monomorphic for the proxy traps
32
+ let mkrow = (r, c, i) => {
33
+ let scope = { p: state, v: itemVar, k: idxVar, r, c, i, o: keys, l: null }
34
34
  let el = tpl.content ? frag(tpl) : tpl.cloneNode(true)
35
- return { el, scope, proxy, node: el.content || el }
35
+ return { el, scope, proxy: new Proxy(scope, rowHandler), node: el.content || el, _di: i }
36
36
  }
37
37
 
38
38
  let insert = pending => {
@@ -40,11 +40,14 @@ export default (tpl, state, expr) => {
40
40
  let f = pending.length > 1 ? doc.createDocumentFragment() : null
41
41
  for (let r of pending) f ? f.appendChild(r.node) : holder.before(r.node)
42
42
  if (f) holder.before(f)
43
- for (let r of pending) sprae(r.el, r.proxy)
43
+ // element rows pair with tpl as clone master: first row records the directive scan, rest replay it
44
+ for (let r of pending) sprae(r.el, r.proxy, tpl.content ? undefined : tpl)
44
45
  }
45
46
 
46
- let update = throttle(() => mutate(() => {
47
- let src = items, newl = src.length, prevl = rows.length
47
+ // untracked: update reads item signals (src[i]) but must not subscribe the :each effect to them —
48
+ // it re-runs via _change/_touch only, else every index write re-notifies it (O(N) per splice)
49
+ let update = throttle(() => untracked(() => mutate(() => {
50
+ let src = items, newl = src.length, prevl = rows.length, lenChanged = newl !== prevl
48
51
 
49
52
  // detect keyed: array of objects (store items are shallow proxies — keyed by proxy identity)
50
53
  keyed = false
@@ -53,7 +56,7 @@ export default (tpl, state, expr) => {
53
56
  }
54
57
 
55
58
  if (keyed && prevl) {
56
- let newRows = [], pending = [], seen = new Set(), moved = false
59
+ let newRows = [], pending = [], seen = new Set(), moved = false, misplaced = false
57
60
 
58
61
  for (let i = 0; i < newl; i++) {
59
62
  let id = src[i]
@@ -63,28 +66,55 @@ export default (tpl, state, expr) => {
63
66
  }
64
67
  let row = rowMap.get(id)
65
68
  if (row) {
66
- if (row.scope.i !== i) moved = true
67
- row.scope.i = i; row.scope.r = id
69
+ // index-only shifts from remove/append keep DOM order — reorder only for same-length permutes (swap)
70
+ if (!lenChanged && row.scope.i !== i) moved = true
71
+ // insert() appends new rows at the tail — a reused row after a new one means wrong placement
72
+ if (pending.length) misplaced = true
73
+ row.scope.i = i; row.scope.r = id; row.scope.o = keys
68
74
  } else {
69
- row = mkrow({ p: state, v: itemVar, k: idxVar, r: id, i, l: null }, keyHandler)
75
+ row = mkrow(id, null, i)
70
76
  rowMap.set(id, row)
71
77
  pending.push(row)
72
78
  }
73
79
  newRows.push(row)
74
80
  }
75
81
 
76
- for (let [id, row] of rowMap) if (!seen.has(id)) { rm(row); rowMap.delete(id); moved = true }
82
+ for (let [id, row] of rowMap) if (!seen.has(id)) rm(row)
77
83
 
78
84
  insert(pending)
79
85
 
80
- if (moved) {
86
+ if (misplaced) {
87
+ // new rows sit at the tail — sweep everything into list order
81
88
  let next = holder
82
89
  for (let i = newRows.length - 1; i >= 0; i--) {
83
- let n = newRows[i].el._holder || newRows[i].el.content || newRows[i].el
90
+ let n = newRows[i].node
84
91
  if (n.nextSibling !== next) next.before(n)
85
92
  next = n
93
+ newRows[i]._di = i
86
94
  }
87
95
  }
96
+ else if (moved) {
97
+ // collect rows whose DOM position no longer matches their list index
98
+ let fix = []
99
+ for (let i = 0; i < newRows.length; i++) {
100
+ let row = newRows[i]
101
+ if (row._di !== i) fix.push(row)
102
+ row._di = i
103
+ }
104
+ // 2-node swap fast path (common case: JFB swap rows)
105
+ if (fix.length === 2) {
106
+ let a = fix[0].node, b = fix[1].node, t = doc.createTextNode('')
107
+ a.replaceWith(t); b.replaceWith(a); t.replaceWith(b)
108
+ } else {
109
+ // general reorder: backward sweep, only move rows that aren't already in place
110
+ let next = holder
111
+ for (let i = newRows.length - 1; i >= 0; i--) {
112
+ let n = newRows[i].node
113
+ if (n.nextSibling !== next) next.before(n)
114
+ next = n
115
+ }
116
+ }
117
+ } else for (let i = 0; i < newRows.length; i++) newRows[i]._di = i
88
118
 
89
119
  rows = newRows
90
120
  } else {
@@ -108,9 +138,7 @@ export default (tpl, state, expr) => {
108
138
  if (newl > prevl) {
109
139
  let pending = []
110
140
  for (let i = prevl; i < newl; i++) {
111
- let row = keyed
112
- ? mkrow({ p: state, v: itemVar, k: idxVar, r: src[i], i, l: null }, keyHandler)
113
- : mkrow({ p: state, v: itemVar, k: idxVar, c: src, i, o: keys, l: null }, posHandler)
141
+ let row = keyed ? mkrow(src[i], null, i) : mkrow(null, src, i)
114
142
  rows.push(row)
115
143
  if (keyed) rowMap.set(src[i], row)
116
144
  pending.push(row)
@@ -118,7 +146,7 @@ export default (tpl, state, expr) => {
118
146
  insert(pending)
119
147
  }
120
148
  }
121
- }))
149
+ })))
122
150
 
123
151
  if (tpl.parentNode) mutate(() => tpl.replaceWith(holder))
124
152
  tpl[_state] = null
@@ -130,7 +158,7 @@ export default (tpl, state, expr) => {
130
158
  else items = value || []
131
159
  let off = effect(() => {
132
160
  items[_change]?.value
133
- if (items[_signals]) for (let i = 0, n = items.length; i < n; i++) items[i] // index writes (swap/replace)
161
+ items[_touch] // O(1) subscribe to index/content changes (swap, splice) on list stores
134
162
  update()
135
163
  })
136
164
  return () => off()
package/directive/if.js CHANGED
@@ -53,3 +53,26 @@ export default (el, state) => {
53
53
  cb[_off] = () => { _el._holder._match?.[0][_off]?.(); _el._holder._match = null }
54
54
  return cb
55
55
  }
56
+
57
+ /**
58
+ * Else directive - conditional branch following :if (`:else` or `:else :if="cond"`).
59
+ * Shares the holder clause machinery above.
60
+ * @param {Element | HTMLTemplateElement} el - Element with directive
61
+ * @returns {() => void} Update function
62
+ */
63
+ export const _else = (el) => {
64
+ let _el, _prev = el
65
+
66
+ _el = el.content ? frag(el) : el
67
+ _el[_state] ??= null // mark _el (frag) as needing sprae
68
+
69
+ // find holder
70
+ while (_prev && !(_el._holder = _prev._holder)) _prev = _prev.previousSibling
71
+
72
+ el.remove()
73
+ el[_state] = null // mark as fake-spraed to stop further init, to lazy-sprae when branch matches
74
+
75
+ _el._holder._clauses.push(_el._clause = [_el, true])
76
+
77
+ return _el._holder.update
78
+ }