Skip to content

Commit 1fdcd3f

Browse files
committed
Faster select/create/replace: destructured no-with compile for read-only exprs, flat row-program replay, shared event evaluator
1 parent c57dd82 commit 1fdcd3f

3 files changed

Lines changed: 111 additions & 38 deletions

File tree

core.js

Lines changed: 66 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,15 @@ const err = (e, expr, el = currentEl) => {
140140
* @property {Record<string, Signal>} [_signals] - Internal signals map
141141
*/
142142

143-
/** Symbol for cached directive scan on clone masters */
144-
const _dirs = Symbol('dirs')
143+
/** Symbol for the flat directive program on clone masters: [{p: childNodes-index path, d: dirs}] in DFS order */
144+
const _prog = Symbol('program')
145+
146+
/** Is q a strict descendant path of p */
147+
const inside = (p, q) => {
148+
if (q.length <= p.length) return false
149+
for (let i = 0; i < p.length; i++) if (p[i] !== q[i]) return false
150+
return true
151+
}
145152

146153
/**
147154
* Applies directives to an HTML element and manages its reactive state.
@@ -196,39 +203,27 @@ const sprae = (root = document.body, state, master) => {
196203
)
197204
let start
198205

206+
// recording state: prog collects (path, dirs) per directive-bearing element while the first clone is scanned
207+
let prog = master ? master[_prog] : undefined, path = prog === undefined && master ? [] : null
208+
199209
const add = el[_add] = (el, mel) => {
200-
let dirs = mel?.[_dirs]
210+
let _attrs = el.attributes, rec = mel && []
201211

202-
// replay master's recorded scan — no attribute reads, no parsing
203-
if (dirs !== undefined) {
204-
// clones from the recording batch still carry directive attrs (more than master's residual) — strip them;
205-
// later clones come from the stripped master and skip removeAttribute entirely
206-
let strip = dirs.length && el.attributes.length > dirs.n, d
207-
for (let i = 0; i < dirs.length; i++) {
208-
d = dirs[i]
209-
if (strip) el.removeAttribute(d[0])
210-
if (apply(el, d[0], d[1], d[2])) return
211-
}
212-
}
213-
else {
214-
let _attrs = el.attributes, rec = mel && (mel[_dirs] = [])
215-
216-
if (_attrs) for (let i = 0; i < _attrs.length;) {
217-
let { name, value } = _attrs[i]
218-
219-
if (name.startsWith(prefix)) {
220-
el.removeAttribute(name)
221-
// strip master too: later clones come out clean; unprocessed tail (after a stop) stays for subsprae
222-
mel?.removeAttribute(name)
223-
let short = name.slice(prefix.length)
224-
rec?.push([name, short, value])
225-
226-
// n = master's residual attr count (directives may add attrs to el, so el.attributes can't be the reference)
227-
if (apply(el, name, short, value)) return rec && (rec.n = mel.attributes.length), undefined
228-
} else i++
229-
}
230-
if (rec) rec.n = mel.attributes.length
212+
if (_attrs) for (let i = 0; i < _attrs.length;) {
213+
let { name, value } = _attrs[i]
214+
215+
if (name.startsWith(prefix)) {
216+
el.removeAttribute(name)
217+
// strip master too: later clones come out clean; unprocessed tail (after a stop) stays for subsprae
218+
mel?.removeAttribute(name)
219+
let short = name.slice(prefix.length)
220+
rec?.push([name, short, value])
221+
222+
// n = master's residual attr count (directives may add attrs to el, so el.attributes can't be the reference)
223+
if (apply(el, name, short, value)) return rec?.length && (rec.n = mel.attributes.length, record(rec)), undefined
224+
} else i++
231225
}
226+
if (rec?.length) rec.n = mel.attributes.length, record(rec)
232227

233228
// custom elements own their children — don't descend
234229
if (el !== root && isCE(el)) return
@@ -237,13 +232,49 @@ const sprae = (root = document.body, state, master) => {
237232
// real DOM: firstChild/nextSibling avoids array copy; frag.childNodes is already snapshot array
238233
if (el.firstChild !== undefined) {
239234
// master is never mutated structurally, so its pointers stay aligned with pre-captured clone pointers
240-
let child = el.firstChild, mchild = mel?.firstChild, next
241-
while (child) (next = child.nextSibling, child.nodeType == 1 && add(child, mchild), mchild &&= mchild.nextSibling, child = next)
235+
let child = el.firstChild, mchild = mel?.firstChild, next, idx = 0
236+
while (child) (
237+
next = child.nextSibling,
238+
child.nodeType == 1 && (path?.push(idx), add(child, mchild), path?.pop()),
239+
mchild &&= mchild.nextSibling, child = next, idx++
240+
)
242241
}
243242
else for (let child of el.childNodes) child.nodeType == 1 && add(child)
244243
};
245244

246-
add(el, master);
245+
const record = rec => path && prog.push({ p: path.slice(), d: rec })
246+
247+
// replay master's flat program — no attribute scans, no tree walk, dir-less elements never visited
248+
const replay = prog => {
249+
let m = prog.length, nodes = Array(m), node, p, d, e, h, i
250+
// resolve all nodes upfront: applying a directive may move nodes (:portal) and shift later paths
251+
for (e = 0; e < m; e++) {
252+
p = prog[e].p, node = el
253+
for (h = 0; h < p.length; h++) node = node.childNodes[p[h]]
254+
nodes[e] = node
255+
}
256+
for (e = 0; e < m; e++) {
257+
p = prog[e].p, d = prog[e].d, node = nodes[e]
258+
// clones from the recording batch still carry directive attrs (more than master's residual) — strip them;
259+
// later clones come from the stripped master and skip removeAttribute entirely
260+
let strip = node.attributes.length > d.n
261+
for (i = 0; i < d.length; i++) {
262+
if (strip) node.removeAttribute(d[i][0])
263+
if (apply(node, d[i][0], d[i][1], d[i][2])) {
264+
// stop: skip this element's recorded descendants (subsprae owns them)
265+
while (e + 1 < m && inside(p, prog[e + 1].p)) e++
266+
break
267+
}
268+
}
269+
}
270+
}
271+
272+
if (prog !== undefined) replay(prog)
273+
else {
274+
if (path) prog = master[_prog] = []
275+
add(el, master)
276+
path = null // MO-added nodes reuse `add` later — no recording for those
277+
}
247278

248279
currentDir = currentEl = null;
249280

directive/event.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ export default (el, state, expr, name) => {
1717
// if (!/^(?:[\w$]+|\([^()]*\))\s*=>/.test(expr) && !/^function\b/.test(expr)) expr = `()=>{${expr}}`;
1818

1919
const [type, mods] = names[name] ??= (([t, ...m]) => [t, m])(name.slice(2).split('.')),
20-
evaluate = parse(expr).bind(el),
20+
evaluate = parse(expr),
2121
// decorate pops mods — pass a copy to keep the memo intact
22-
trigger = decorate(Object.assign(e => evaluate(state, (fn) => typeof fn === 'function' ? fn(e) : fn), { target: el }), mods.length ? [...mods] : mods),
22+
trigger = decorate(Object.assign(e => evaluate.call(el, state, (fn) => typeof fn === 'function' ? fn(e) : fn), { target: el }), mods.length ? [...mods] : mods),
2323
// stable dispatcher: dispose neutralizes by nulling — removeEventListener is undo work a dying node doesn't need
2424
handler = e => live && live(e);
2525
let live = trigger

sprae.js

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,42 @@ const hasSemi = s => {
222222
return false
223223
}
224224

225+
// Words that cannot (or must not) become destructuring bindings
226+
const RESERVED = new Set('true,false,null,undefined,this,typeof,instanceof,in,of,new,void,return,function,class,const,let,var,if,else,for,while,do,switch,case,default,break,continue,try,catch,finally,throw,yield,async,await,delete,with,super,import,export,extends,arguments'.split(','))
227+
228+
// `with(scope)` disables identifier caching for the whole function — every read is a dynamic runtime
229+
// lookup, on every run of every effect. For gated read-only expressions we compile a destructured
230+
// prologue instead: identifiers become plain property reads V8 can optimize. Resolution is equivalent —
231+
// scope `has` always answers true and `get` falls through row → state → globalThis, exactly like `with`.
232+
// Gates (fall back to `with`): assignments/inc/dec (scope writes need the trap), ternaries/short-circuits
233+
// (destructuring would read both branches eagerly), templates/regex/semicolons (tokenizer simplicity).
234+
const destructurable = expr => !/(?<![=!<>])=(?![=>])|\+\+|--|\?(?!\.)|&&|\|\||[;`/]|\bdelete\b/.test(expr)
235+
236+
// Free identifier candidates: skip strings, property access (after `.`), numeric tails, reserved words,
237+
// and object keys (`name:` can only be a key — ternaries are gated out). Over-extraction is harmless:
238+
// the extra name resolves through the same scope chain a `with` lookup would.
239+
const free = expr => {
240+
let names = new Set, i = 0, n = expr.length, last = '', ch, j, k, word
241+
while (i < n) {
242+
ch = expr[i]
243+
if (ch === '"' || ch === "'") { i++; while (i < n && expr[i] !== ch) i += expr[i] === '\\' ? 2 : 1; i++; last = ch; continue }
244+
if (/[A-Za-z_$]/.test(ch)) {
245+
j = i
246+
while (j < n && /[\w$]/.test(expr[j])) j++
247+
word = expr.slice(i, j)
248+
if (last !== '.' && !/\d/.test(last) && !RESERVED.has(word)) {
249+
k = j
250+
while (k < n && /\s/.test(expr[k])) k++
251+
if (expr[k] !== ':') names.add(word)
252+
}
253+
i = j; last = word[word.length - 1]; continue
254+
}
255+
if (!/\s/.test(ch)) last = ch
256+
i++
257+
}
258+
return names
259+
}
260+
225261
// Configure sprae with default compiler and signals
226262
use({
227263

@@ -231,7 +267,13 @@ use({
231267
if (/^(if|let|const)\b/.test(expr));
232268
// first-level semicolons - no return
233269
else if (hasSemi(expr));
234-
else expr = `return ${expr}`
270+
else {
271+
if (destructurable(expr)) try {
272+
let names = free(expr)
273+
return sprae.constructor(`${names.size ? `const{${[...names].join(',')}}=arguments[0];` : ''}return(${expr})`)
274+
} catch (e) { } // odd extraction → with-mode
275+
expr = `return ${expr}`
276+
}
235277
// async expression
236278
if (/\bawait\s/.test(expr)) expr = `return (async()=>{${expr}})()`
237279
return sprae.constructor(`with(arguments[0]){${expr}}`)

0 commit comments

Comments
 (0)