sprae 13.2.2 → 13.3.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
@@ -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,204 @@
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
35
+
36
+ // Can we use identity keying? Only for object items.
37
+ let keyed = false
23
38
 
24
39
  let update = throttle(() => mutate(() => {
25
- let i = 0, newItems = items, newl = newItems.length
40
+ let newItems = items, newl = newItems.length, prevl = rows.length
26
41
 
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
42
+ // detect keyed mode: first non-null item is object keyed
43
+ keyed = false
44
+ for (let i = 0; i < newl; i++) {
45
+ let item = newItems[i]
46
+ if (item != null) { keyed = typeof item === 'object'; break }
31
47
  }
32
48
 
49
+ if (keyed && prevl) {
50
+ // --- KEYED DIFF: only when we have existing rows to diff against ---
51
+
52
+ // fast path: pure append (list only grew, no removals possible)
53
+ if (newl > prevl && rowMap.size === prevl) {
54
+ let batch = (newl - prevl) > 1 ? doc.createDocumentFragment() : null
55
+ let pending = batch ? [] : null
56
+ for (let i = prevl; i < newl; i++) {
57
+ let identity = newItems[i]
58
+ let scope = { p: state, v: itemVar, k: idxVar, r: identity, i, l: null }
59
+ let proxy = new Proxy(scope, keyHandler)
60
+ let el = tpl.content ? frag(tpl) : tpl.cloneNode(true)
61
+ let insertNode = el.content || el
62
+ let row = { el, scope, proxy }
63
+ rowMap.set(identity, row)
64
+ rows.push(row)
65
+ if (batch) { batch.appendChild(insertNode); pending.push([el, proxy]) }
66
+ else { holder.before(insertNode); sprae(el, proxy) }
67
+ }
68
+ if (batch && pending.length) {
69
+ holder.before(batch)
70
+ for (let [el, proxy] of pending) sprae(el, proxy)
71
+ }
72
+ } else {
73
+ // full diff: removals, reorders, mixed insert/remove
74
+ let removed = 0
75
+ if (newl <= prevl) {
76
+ let newSet = new Set
77
+ for (let i = 0; i < newl; i++) newSet.add(newItems[i])
78
+ for (let [identity, row] of rowMap) {
79
+ if (!newSet.has(identity)) {
80
+ row.el[Symbol.dispose]?.(); row.el.remove()
81
+ rowMap.delete(identity); removed++
82
+ }
83
+ }
84
+ }
33
85
 
34
- // delete
35
- if (newl < prevl) cur.length = newl
86
+ let newRows = [], moved = false
87
+ let batch = null, pending = null
88
+ if (newl - rowMap.size > 1) batch = doc.createDocumentFragment(), pending = []
89
+
90
+ for (let i = 0; i < newl; i++) {
91
+ let identity = newItems[i], row = rowMap.get(identity)
92
+ if (row) {
93
+ if (row.scope.i !== i) moved = true
94
+ row.scope.i = i; row.scope.r = identity
95
+ newRows.push(row)
96
+ } else {
97
+ let scope = { p: state, v: itemVar, k: idxVar, r: identity, i, l: null }
98
+ let proxy = new Proxy(scope, keyHandler)
99
+ let el = tpl.content ? frag(tpl) : tpl.cloneNode(true)
100
+ let insertNode = el.content || el
101
+ row = { el, scope, proxy }
102
+ rowMap.set(identity, row)
103
+ newRows.push(row)
104
+ if (batch) { batch.appendChild(insertNode); pending.push([el, proxy]) }
105
+ else { holder.before(insertNode); sprae(el, proxy) }
106
+ }
107
+ }
36
108
 
37
- // update, append, init
38
- else {
39
- // init
40
- if (!cur) cur = newItems
41
- // update
42
- else while (i < prevl) cur[i] = newItems[i++]
109
+ if (batch && pending.length) {
110
+ holder.before(batch)
111
+ for (let [el, proxy] of pending) sprae(el, proxy)
112
+ }
43
113
 
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
114
+ if (moved || removed) {
115
+ let next = holder
116
+ for (let i = newRows.length - 1; i >= 0; i--) {
117
+ let node = newRows[i].el._holder || newRows[i].el.content || newRows[i].el
118
+ if (node.nextSibling !== next) next.before(node)
119
+ next = node
120
+ }
121
+ }
48
122
 
49
- // append
50
- for (; i < newl; i++) {
51
- cur[i] = newItems[i]
123
+ rows = newRows
124
+ }
125
+ } else {
126
+ // --- POSITIONAL PATH: index-based (primitives, numbers) ---
52
127
 
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
128
+ // plain array replaced — full reset
129
+ if (prevl && cur !== newItems && !newItems[_change]) {
130
+ for (let r of rows) { r.el[Symbol.dispose]?.(); r.el.remove() }
131
+ rows.length = 0; prevl = 0; rowMap.clear()
132
+ }
133
+ cur = newItems
57
134
 
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
- }
135
+ // shrink
136
+ if (newl < prevl) {
137
+ for (let i = newl; i < prevl; i++) { rows[i].el[Symbol.dispose]?.(); rows[i].el.remove() }
138
+ rows.length = newl
139
+ }
70
140
 
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
- };
141
+ // update surviving rows' item source
142
+ for (let i = 0; i < Math.min(prevl, newl); i++) {
143
+ rows[i].scope.c = newItems
144
+ if (keys) rows[i].scope.o = keys
76
145
  }
77
146
 
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)
147
+ // append
148
+ if (newl > prevl) {
149
+ let batch = (newl - prevl) > 1 ? doc.createDocumentFragment() : null
150
+ let pending = batch ? [] : null
151
+
152
+ for (let i = prevl; i < newl; i++) {
153
+ let handler = keyed ? keyHandler : posHandler
154
+ let scope = keyed
155
+ ? { p: state, v: itemVar, k: idxVar, r: newItems[i], i, l: null }
156
+ : { p: state, v: itemVar, k: idxVar, c: newItems, i, o: keys, l: null }
157
+ let proxy = new Proxy(scope, handler)
158
+ let el = tpl.content ? frag(tpl) : tpl.cloneNode(true)
159
+ let insertNode = el.content || el
160
+ let row = { el, scope }
161
+ rows.push(row)
162
+ if (keyed) rowMap.set(newItems[i], row)
163
+
164
+ if (batch) {
165
+ batch.appendChild(insertNode)
166
+ pending.push([el, proxy])
167
+ } else {
168
+ holder.before(insertNode)
169
+ sprae(el, proxy)
170
+ }
171
+ }
172
+
173
+ if (batch) {
174
+ holder.before(batch)
175
+ for (let [el, proxy] of pending) sprae(el, proxy)
87
176
  }
88
177
  }
89
178
  }
90
-
91
- prevl = newl
92
179
  }))
93
180
 
94
- // Replace template with holder (idempotent: skip if already replaced, e.g. :if re-activation)
95
181
  if (tpl.parentNode) mutate(() => tpl.replaceWith(holder))
96
- tpl[_state] = null // mark as fake-spraed, to preserve :-attribs for template
182
+ tpl[_state] = null
97
183
 
98
- let disposeItems = () => { if (cur) { for (let s of cur[_signals] || []) s[Symbol.dispose]?.(); cur = null; prevl = 0 } }
184
+ let disposeAll = () => {
185
+ for (let r of rows) { r.el[Symbol.dispose]?.(); r.el.remove() }
186
+ rows.length = 0; rowMap.clear()
187
+ }
99
188
 
100
- return Object.assign(value => {
101
- // resolve new items
189
+ let cb = value => {
102
190
  keys = null
103
191
  if (typeof value === "number") items = Array.from({ length: value }, (_, i) => i + 1)
104
192
  else if (value?.constructor === Object) keys = Object.keys(value), items = Object.values(value)
105
193
  else items = value || []
106
194
 
107
- // whenever list changes, we rebind internal change effect
108
195
  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
196
  items[_change]?.value
111
-
112
- // make first render immediately, debounce subsequent renders
113
197
  update()
114
198
  })
115
- return () => { off(); disposeItems() }
116
- }, {eval:parse(rhs)})
199
+ return () => off()
200
+ }
201
+ cb.eval = parse(rhs)
202
+ cb[_off] = disposeAll
203
+ return cb
117
204
  }