sprae 13.2.2 → 13.3.1

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
@@ -161,8 +161,18 @@ const sprae = (root = document.body, state) => {
161
161
  // on/off all effects
162
162
  // we don't call prevOn as convention: everything defined before :else :if won't be disabled by :if
163
163
  // imagine <x :onx="..." :if="..."/> - when :if is false, it disables directives after :if (calls _off) but ignores :onx
164
- el[_on] = () => (!offs && (offs = fx.map(fn => fn())))
165
- el[_off] = () => (offs?.map(off => off?.()), offs = null)
164
+ el[_on] = () => {
165
+ if (offs) return offs
166
+ offs = Array(fx.length)
167
+ for (let i = 0; i < fx.length; i++) offs[i] = fx[i]()
168
+ return offs
169
+ }
170
+ el[_off] = () => {
171
+ if (!offs) return
172
+ let current = offs
173
+ offs = null
174
+ for (let i = 0; i < current.length; i++) current[i]?.()
175
+ }
166
176
 
167
177
  // destroy
168
178
  el[_dispose] ||= () => {
@@ -10,14 +10,29 @@ import { clsx } from "../core.js";
10
10
  * @returns {(v: string | string[] | Record<string, boolean>) => void} Update function
11
11
  */
12
12
  export default (el, st, ex, name) => {
13
- let _cur = new Set, _new
13
+ let _cur = null, _new, _prev = null
14
14
 
15
15
  return (v) => {
16
- _new = new Set
17
- if (v) for (let c of clsx(typeof v === 'function' ? v(el.className) : v).split(' ')) c && _new.add(c)
18
- for (let c of _cur) if (!_new.has(c)) el.classList.remove(c);
19
- for (let c of _new) if (!_cur.has(c)) el.classList.add(c);
20
- if (!el.classList.length) el.removeAttribute('class')
16
+ v = typeof v === 'function' ? v(el.className) : v
17
+
18
+ if (v?.constructor === Object) {
19
+ _prev = null
20
+ if (_cur) for (let c of _cur) if (!v[c]) el.classList.remove(c), _cur.delete(c)
21
+ if (!_cur?.size) _cur = null
22
+ for (let c in v) if (v[c] && !_cur?.has(c)) el.classList.add(c), (_cur ||= new Set).add(c)
23
+ if (!_cur) el.removeAttribute('class')
24
+ return
25
+ }
26
+
27
+ v = clsx(v)
28
+ if (v === _prev) return
29
+ _prev = v
30
+
31
+ _new = null
32
+ if (v) for (let c of v.split(' ')) c && (_new ||= new Set).add(c)
33
+ if (_cur) for (let c of _cur) if (!_new?.has(c)) el.classList.remove(c)
34
+ if (_new) for (let c of _new) if (!_cur?.has(c)) el.classList.add(c)
35
+ if (!_new) el.removeAttribute('class')
21
36
  _cur = _new
22
37
  }
23
38
  }
package/directive/each.js CHANGED
@@ -1,117 +1,206 @@
1
- import sprae, { store, parse, _state, effect, _change, _signals, frag, throttle, debounce, mutate } from "../core.js";
1
+ import sprae, { parse, _state, _off, effect, _change, _signals, frag, throttle, mutate } from "../core.js";
2
+
3
+ // Lightweight row scope — reads item via cur[idx] (positional) or direct ref (keyed).
4
+ // Local vars (e.g. :ref) stored in `l` on first write.
5
+ const posHandler = {
6
+ 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],
7
+ 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),
8
+ has: () => true
9
+ }
10
+ // Keyed: `r` holds direct item reference — immune to store index shifts
11
+ const keyHandler = {
12
+ 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],
13
+ 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),
14
+ has: () => true
15
+ }
2
16
 
3
17
  /**
4
18
  * Each directive - renders list items from array/object/number.
5
19
  * Syntax: `:each="item in items"` or `:each="(item, idx) of items"`
6
- * @param {HTMLTemplateElement | Element} tpl - Template element
7
- * @param {Object} state - State object
8
- * @param {string} expr - Iterator expression
9
- * @returns {{ eval: Function, [Symbol.dispose]: () => void }} Directive result
20
+ *
21
+ * Keyed by object identity: when items are objects, splice/remove only
22
+ * disposes the removed row survivors keep their DOM and don't re-evaluate.
23
+ * Primitives fall back to index-based (positional) updates.
10
24
  */
11
25
  export default (tpl, state, expr) => {
12
26
  const [lhs, rhs] = expr.split(/\bin|of\b/)
13
-
14
27
  let [itemVar, idxVar = "$"] = lhs.trim().replace(/\(|\)/g, '').split(/\s*,\s*/);
15
28
 
16
- // we need :if to be able to replace holder instead of tpl for :if :each case
17
- // Reuse holder from previous init (survives :if off/on cycle)
18
29
  let doc = tpl.ownerDocument
19
30
  let holder = tpl._eachHolder || (tpl._eachHolder = doc.createTextNode(""));
20
31
 
21
- // we re-create items any time new items are produced
22
- let cur, keys, items, prevl = 0
32
+ // Map<identity, {el, scope, proxy}> for keyed mode
33
+ // Array<{el, scope, proxy}> for positional mode
34
+ let rowMap = new Map, rows = [], items, keys, cur
23
35
 
24
- let update = throttle(() => mutate(() => {
25
- let i = 0, newItems = items, newl = newItems.length
36
+ // Can we use identity keying? Only for object items.
37
+ let keyed = false
26
38
 
27
- // plain array update, not store (signal with array) - updates full list
28
- if (cur && !cur[_change]) {
29
- for (let s of cur[_signals] || []) s[Symbol.dispose]()
30
- cur = null, prevl = 0
39
+ let update = throttle(() => mutate(() => {
40
+ let newItems = items, newl = newItems.length, prevl = rows.length
41
+
42
+ // detect keyed mode: object items in plain (non-store) arrays only.
43
+ // store arrays must use positional mode: store wraps items in new Proxies,
44
+ // breaking identity matching on replace (every item looks "new").
45
+ keyed = false
46
+ if (!newItems[_change]) for (let i = 0; i < newl; i++) {
47
+ let item = newItems[i]
48
+ if (item != null) { keyed = typeof item === 'object'; break }
31
49
  }
32
50
 
51
+ if (keyed && prevl) {
52
+ // --- KEYED DIFF: only when we have existing rows to diff against ---
53
+
54
+ // fast path: pure append (list only grew, no removals possible)
55
+ if (newl > prevl && rowMap.size === prevl) {
56
+ let batch = (newl - prevl) > 1 ? doc.createDocumentFragment() : null
57
+ let pending = batch ? [] : null
58
+ for (let i = prevl; i < newl; i++) {
59
+ let identity = newItems[i]
60
+ let scope = { p: state, v: itemVar, k: idxVar, r: identity, i, l: null }
61
+ let proxy = new Proxy(scope, keyHandler)
62
+ let el = tpl.content ? frag(tpl) : tpl.cloneNode(true)
63
+ let insertNode = el.content || el
64
+ let row = { el, scope, proxy }
65
+ rowMap.set(identity, row)
66
+ rows.push(row)
67
+ if (batch) { batch.appendChild(insertNode); pending.push([el, proxy]) }
68
+ else { holder.before(insertNode); sprae(el, proxy) }
69
+ }
70
+ if (batch && pending.length) {
71
+ holder.before(batch)
72
+ for (let [el, proxy] of pending) sprae(el, proxy)
73
+ }
74
+ } else {
75
+ // full diff: removals, reorders, mixed insert/remove
76
+ let removed = 0
77
+ if (newl <= prevl) {
78
+ let newSet = new Set
79
+ for (let i = 0; i < newl; i++) newSet.add(newItems[i])
80
+ for (let [identity, row] of rowMap) {
81
+ if (!newSet.has(identity)) {
82
+ row.el[Symbol.dispose]?.(); row.el.remove()
83
+ rowMap.delete(identity); removed++
84
+ }
85
+ }
86
+ }
33
87
 
34
- // delete
35
- if (newl < prevl) cur.length = newl
88
+ let newRows = [], moved = false
89
+ let batch = null, pending = null
90
+ if (newl - rowMap.size > 1) batch = doc.createDocumentFragment(), pending = []
91
+
92
+ for (let i = 0; i < newl; i++) {
93
+ let identity = newItems[i], row = rowMap.get(identity)
94
+ if (row) {
95
+ if (row.scope.i !== i) moved = true
96
+ row.scope.i = i; row.scope.r = identity
97
+ newRows.push(row)
98
+ } else {
99
+ let scope = { p: state, v: itemVar, k: idxVar, r: identity, i, l: null }
100
+ let proxy = new Proxy(scope, keyHandler)
101
+ let el = tpl.content ? frag(tpl) : tpl.cloneNode(true)
102
+ let insertNode = el.content || el
103
+ row = { el, scope, proxy }
104
+ rowMap.set(identity, row)
105
+ newRows.push(row)
106
+ if (batch) { batch.appendChild(insertNode); pending.push([el, proxy]) }
107
+ else { holder.before(insertNode); sprae(el, proxy) }
108
+ }
109
+ }
36
110
 
37
- // update, append, init
38
- else {
39
- // init
40
- if (!cur) cur = newItems
41
- // update
42
- else while (i < prevl) cur[i] = newItems[i++]
111
+ if (batch && pending.length) {
112
+ holder.before(batch)
113
+ for (let [el, proxy] of pending) sprae(el, proxy)
114
+ }
43
115
 
44
- // batch append using DocumentFragment for efficiency
45
- let batchSize = newl - i
46
- let batch = batchSize > 1 ? doc.createDocumentFragment() : null
47
- let pending = batch ? [] : null
116
+ if (moved || removed) {
117
+ let next = holder
118
+ for (let i = newRows.length - 1; i >= 0; i--) {
119
+ let node = newRows[i].el._holder || newRows[i].el.content || newRows[i].el
120
+ if (node.nextSibling !== next) next.before(node)
121
+ next = node
122
+ }
123
+ }
48
124
 
49
- // append
50
- for (; i < newl; i++) {
51
- cur[i] = newItems[i]
125
+ rows = newRows
126
+ }
127
+ } else {
128
+ // --- POSITIONAL PATH: index-based (primitives, numbers) ---
52
129
 
53
- let idx = i
54
- let el = tpl.content ? frag(tpl) : tpl.cloneNode(true);
55
- // el.content is DocumentFragment for frag() output, el itself for cloneNode
56
- let insertNode = el.content || el
130
+ // plain array replaced — full reset
131
+ if (prevl && cur !== newItems && !newItems[_change]) {
132
+ for (let r of rows) { r.el[Symbol.dispose]?.(); r.el.remove() }
133
+ rows.length = 0; prevl = 0; rowMap.clear()
134
+ }
135
+ cur = newItems
57
136
 
58
- // collect for batch insert
59
- if (batch) {
60
- batch.appendChild(insertNode)
61
- pending.push([ el, idx ])
62
- } else {
63
- holder.before(insertNode)
64
- let subscope = store({
65
- get [itemVar]() { return cur?.[idx] },
66
- [idxVar]: keys ? keys[idx] : idx
67
- }, state)
68
- sprae(el, subscope)
69
- }
137
+ // shrink
138
+ if (newl < prevl) {
139
+ for (let i = newl; i < prevl; i++) { rows[i].el[Symbol.dispose]?.(); rows[i].el.remove() }
140
+ rows.length = newl
141
+ }
70
142
 
71
- // signal/holder disposal removes element
72
- let _prev = ((cur[_signals] ||= [])[i] ||= {})[Symbol.dispose]
73
- cur[_signals][i][Symbol.dispose] = () => {
74
- _prev?.(), el[Symbol.dispose]?.(), el.remove()
75
- };
143
+ // update surviving rows' item source
144
+ for (let i = 0; i < Math.min(prevl, newl); i++) {
145
+ rows[i].scope.c = newItems
146
+ if (keys) rows[i].scope.o = keys
76
147
  }
77
148
 
78
- // batch insert all at once, then sprae
79
- if (batch) {
80
- holder.before(batch)
81
- for (let [el, idx] of pending) {
82
- let subscope = store({
83
- get [itemVar]() { return cur?.[idx] },
84
- [idxVar]: keys ? keys[idx] : idx
85
- }, state)
86
- sprae(el, subscope)
149
+ // append
150
+ if (newl > prevl) {
151
+ let batch = (newl - prevl) > 1 ? doc.createDocumentFragment() : null
152
+ let pending = batch ? [] : null
153
+
154
+ for (let i = prevl; i < newl; i++) {
155
+ let handler = keyed ? keyHandler : posHandler
156
+ let scope = keyed
157
+ ? { p: state, v: itemVar, k: idxVar, r: newItems[i], i, l: null }
158
+ : { p: state, v: itemVar, k: idxVar, c: newItems, i, o: keys, l: null }
159
+ let proxy = new Proxy(scope, handler)
160
+ let el = tpl.content ? frag(tpl) : tpl.cloneNode(true)
161
+ let insertNode = el.content || el
162
+ let row = { el, scope }
163
+ rows.push(row)
164
+ if (keyed) rowMap.set(newItems[i], row)
165
+
166
+ if (batch) {
167
+ batch.appendChild(insertNode)
168
+ pending.push([el, proxy])
169
+ } else {
170
+ holder.before(insertNode)
171
+ sprae(el, proxy)
172
+ }
173
+ }
174
+
175
+ if (batch) {
176
+ holder.before(batch)
177
+ for (let [el, proxy] of pending) sprae(el, proxy)
87
178
  }
88
179
  }
89
180
  }
90
-
91
- prevl = newl
92
181
  }))
93
182
 
94
- // Replace template with holder (idempotent: skip if already replaced, e.g. :if re-activation)
95
183
  if (tpl.parentNode) mutate(() => tpl.replaceWith(holder))
96
- tpl[_state] = null // mark as fake-spraed, to preserve :-attribs for template
184
+ tpl[_state] = null
97
185
 
98
- let disposeItems = () => { if (cur) { for (let s of cur[_signals] || []) s[Symbol.dispose]?.(); cur = null; prevl = 0 } }
186
+ let disposeAll = () => {
187
+ for (let r of rows) { r.el[Symbol.dispose]?.(); r.el.remove() }
188
+ rows.length = 0; rowMap.clear()
189
+ }
99
190
 
100
- return Object.assign(value => {
101
- // resolve new items
191
+ let cb = value => {
102
192
  keys = null
103
193
  if (typeof value === "number") items = Array.from({ length: value }, (_, i) => i + 1)
104
194
  else if (value?.constructor === Object) keys = Object.keys(value), items = Object.values(value)
105
195
  else items = value || []
106
196
 
107
- // whenever list changes, we rebind internal change effect
108
197
  let off = effect(() => {
109
- // subscribe to items change (.length) - we do it every time (not just in update) since preact signals unsubscribes unused signals
110
198
  items[_change]?.value
111
-
112
- // make first render immediately, debounce subsequent renders
113
199
  update()
114
200
  })
115
- return () => { off(); disposeItems() }
116
- }, {eval:parse(rhs)})
201
+ return () => off()
202
+ }
203
+ cb.eval = parse(rhs)
204
+ cb[_off] = disposeAll
205
+ return cb
117
206
  }