jq79 0.6.1 → 0.6.3

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/src/jq79.ts CHANGED
@@ -267,8 +267,49 @@ const evalHandler = (expr: string, scope: Record<string, any>, extras: Record<st
267
267
 
268
268
  // [\s\S] rather than `.` so an expression can span lines, like the ones in
269
269
  // directive attributes (which reach evalExpr wrapped in parens either way)
270
- const interpolate = (template: string, scope: Record<string, any>): string =>
271
- template.replace(/{{\s*([\s\S]+?)\s*}}/g, (_, expr) => evalExpr(expr, scope) ?? "")
270
+ const INTERPOLATION_RE = /{{\s*([\s\S]+?)\s*}}/g
271
+
272
+ // A text template split once into its literal and expression parts. The split
273
+ // used to happen on every run of every instance - `String.replace` over the
274
+ // whole text, a fresh match object and a callback per expression - and a
275
+ // :each over 1,000 rows runs it 1,000 times per text node to reach the same
276
+ // answer about the same string. Keyed by the template text, like compileExpr's
277
+ // cache and bounded the same way: by how many distinct texts the source holds
278
+ //
279
+ // An expression part is boxed so a literal `"x"` and an expression `x` stay
280
+ // distinguishable without a second array
281
+ type TextPart = string | { expr: string }
282
+
283
+ const textParts = new Map<string, TextPart[]>()
284
+
285
+ const splitText = (template: string): TextPart[] => {
286
+ const cached = textParts.get(template)
287
+ if (cached) return cached
288
+ const parts: TextPart[] = []
289
+ let at = 0
290
+ INTERPOLATION_RE.lastIndex = 0
291
+ for (let match = INTERPOLATION_RE.exec(template); match; match = INTERPOLATION_RE.exec(template)) {
292
+ if (match.index > at) parts.push(template.slice(at, match.index))
293
+ parts.push({ expr: match[1] })
294
+ at = match.index + match[0].length
295
+ }
296
+ if (at < template.length) parts.push(template.slice(at))
297
+ textParts.set(template, parts)
298
+ return parts
299
+ }
300
+
301
+ // what an interpolated text node renders to, from the parts. `?? ""` on each
302
+ // expression, and String() over the join, is what template.replace did: a
303
+ // nullish value contributes nothing and everything else is coerced
304
+ const renderText = (parts: TextPart[], scope: Record<string, any>): string => {
305
+ if (parts.length === 1) {
306
+ const only = parts[0]
307
+ return typeof only === "string" ? only : String(evalExpr(only.expr, scope) ?? "")
308
+ }
309
+ let out = ""
310
+ for (const part of parts) out += typeof part === "string" ? part : String(evalExpr(part.expr, scope) ?? "")
311
+ return out
312
+ }
272
313
 
273
314
 
274
315
  const CONTROL_ATTRS = new Set([":attrs", ":class", ":value", ":checked", ":selected", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html", ":html.allowed", ":props"])
@@ -378,16 +419,16 @@ const removeRange = ({ first, last }: NodeRange) => {
378
419
  // single span: unlinking 10,000 rows one at a time is 40% of that operation,
379
420
  // profiled - see TODOS/2026-08-23.batch-range-removal.md. Runs are built by the
380
421
  // caller, which is the only place that knows what else is going
381
- const removeRuns = (runs: NodeRange[][]) => {
422
+ const removeRuns = (runs: NodeRange[]) => {
382
423
  runs.forEach(run => {
383
- if (run.length === 1) return removeRange(run[0])
384
- const parent = run[0].first.parentNode
424
+ if (run.first === run.last) return removeRange(run)
425
+ const parent = run.first.parentNode
385
426
  if (!parent) return
386
427
  // both ends sit between nodes, so nothing is partially selected and whole
387
428
  // nodes are what gets unlinked
388
429
  const range = document.createRange()
389
- range.setStartBefore(run[0].first)
390
- range.setEndAfter(run[run.length - 1].last)
430
+ range.setStartBefore(run.first)
431
+ range.setEndAfter(run.last)
391
432
  range.deleteContents()
392
433
  })
393
434
  }
@@ -396,16 +437,19 @@ const removeRuns = (runs: NodeRange[][]) => {
396
437
  // `ordered` (which is in DOM order). Adjacency is confirmed rather than assumed:
397
438
  // a gap - an entry removed earlier in the same pass - starts a new run, so a
398
439
  // live entry can never end up inside one
399
- const contiguousRuns = <T extends { range: NodeRange }>(ordered: T[], isDead: (entry: T) => boolean): NodeRange[][] => {
400
- const runs: NodeRange[][] = []
401
- let run: NodeRange[] | null = null
440
+ const contiguousRuns = <T extends { range: NodeRange }>(ordered: T[], isDead: (entry: T) => boolean): NodeRange[] => {
441
+ const runs: NodeRange[] = []
442
+ let run: NodeRange | null = null
402
443
  ordered.forEach(entry => {
403
444
  if (!isDead(entry)) {
404
445
  run = null
405
446
  return
406
447
  }
407
- if (run && run[run.length - 1].last.nextSibling === entry.range.first) run.push(entry.range)
408
- else runs.push((run = [entry.range]))
448
+ // a run is a span, not the list of ranges inside it: only its two ends are
449
+ // ever read, and a list dropping 10,000 rows was building an array of
450
+ // 10,000 entries to hand over two of them
451
+ if (run && run.last.nextSibling === entry.range.first) run.last = entry.range.last
452
+ else runs.push((run = { first: entry.range.first, last: entry.range.last }))
409
453
  })
410
454
  return runs
411
455
  }
@@ -458,6 +502,18 @@ const scanComponentKey = (scope: Record<string, any>, tag: string): string | nul
458
502
  let tagMemo: Map<string, string | null> | null = null
459
503
  let memoBase: object | null = null
460
504
 
505
+ // Scope objects known to declare no PascalCase key of their own, so the walk
506
+ // below can skip them without calling Object.keys - which allocates an array
507
+ // and scans it, per element, per row. An :each item scope holds `item`,
508
+ // `$index` and maybe the `, at` name, and whether any of those can be a
509
+ // component name is decided by the template, once (see EachPlan).
510
+ //
511
+ // Only scopes jq79 creates and never adds a key to go in here. A store must
512
+ // never: a setup script's `const Row = await $import(...)` arrives as a new key
513
+ // after the template has already rendered, which is the whole reason the tag
514
+ // memo lives for exactly one pass
515
+ const plainScopes = new WeakSet<object>()
516
+
461
517
  // opened and closed by hand rather than by a wrapper taking a callback: a
462
518
  // component that renders itself through :each stacks one renderEach per level,
463
519
  // and a callback would add a frame to each of them. The cyclic-component test
@@ -488,6 +544,7 @@ const findComponentKey = (scope: Record<string, any>, tag: string): string | nul
488
544
  tagMemo.set(tag, key)
489
545
  return key
490
546
  }
547
+ if (plainScopes.has(obj)) continue
491
548
  for (const key of Object.keys(obj)) {
492
549
  if (/^[A-Z]/.test(key) && key.replace(/-/g, "").toLowerCase() === normalized) return key
493
550
  }
@@ -1149,6 +1206,202 @@ const applyAttr = (el: Element, name: string, value: any) => {
1149
1206
  // normally. :if/:elseif/:else/:each are handled by renderNodes, which decides
1150
1207
  // *whether*/*how many times* a node is rendered before calling this. Tags
1151
1208
  // matching a PascalCase scope variable render as nested components instead
1209
+
1210
+ // ---------------------------------------------------------------------------
1211
+ // Cloning a fixed shape instead of deriving it per instance.
1212
+ //
1213
+ // renderNode asks the same questions of the same AST node for every instance of
1214
+ // it: is this a slot, a component, an unknown tag; which of these attributes is
1215
+ // a directive; split this text on `{{`. For a :each of 1,000 rows that is ~25
1216
+ // questions per element per row whose answers were fixed by the source text.
1217
+ // Where a subtree's *shape* is fixed - the elements, their static attributes and
1218
+ // their nesting never vary, only the values bound into them - the shape is built
1219
+ // once per definition into a detached skeleton, and each instance is one
1220
+ // cloneNode plus a walk to each binding point.
1221
+ //
1222
+ // Worth -20 to -49% of create1k depending on how much fixed structure a row
1223
+ // has, and nothing at all on a row that has none. Measured, with the method and
1224
+ // the caveats, in TODOS/2026-08-24.clone-skeletons-measured.md.
1225
+ //
1226
+ // Two rules keep this from becoming the bug it could be:
1227
+ //
1228
+ // 1. **The holes are an allowlist, never a denylist.** `plannableAttr` names
1229
+ // the four things a skeleton knows how to fill; every other attribute makes
1230
+ // the subtree unplannable. So a directive added to renderNode later is
1231
+ // *slower* until somebody teaches it here - never silently mis-rendered,
1232
+ // which is the failure a second render path invites.
1233
+ // 2. **The interpreted path stays the fallback for everything else**, including
1234
+ // every tag that could still turn into a component. The upgrade watch and
1235
+ // the unresolved-component throw are not reimplemented here; they are never
1236
+ // reached from here.
1237
+ //
1238
+ // tests/skeleton.test.ts renders a corpus both ways and diffs the DOM, which is
1239
+ // what makes rule 1 enforceable rather than a promise.
1240
+ // ---------------------------------------------------------------------------
1241
+
1242
+ // Flipping this must never change what renders, only how - which is what
1243
+ // tests/skeleton.test.ts exists to keep true. It is on, and switchable through
1244
+ // `Component79.debug({ cloneSkeletons: false })`, because a second render path
1245
+ // is the kind of change that wants an off switch a user can reach without a
1246
+ // rebuild: a page that renders wrong is a bug report either way, but one whose
1247
+ // reporter can say "it goes away with cloning off" is a bug report that names
1248
+ // the file
1249
+ const debugFlags: DebugFlags = { cloneSkeletons: true }
1250
+
1251
+ // What `Component79.debug()` can switch. One flag today; the shape is an object
1252
+ // so the next one does not change the call
1253
+ export type DebugFlags = {
1254
+ // build a fixed-shape subtree by cloning a skeleton made once per definition,
1255
+ // instead of walking the AST for every instance of it. Off means every
1256
+ // element goes through renderNode, exactly as before this existed
1257
+ cloneSkeletons: boolean
1258
+ }
1259
+
1260
+ // The four things a hole can be, in the order renderNode registers them.
1261
+ // A `:` attribute with a dot in it is rejected wholesale except `:class.`:
1262
+ // `:model.`, `:props.`, `:slot.` and `:html.allowed` all live in that shape, and
1263
+ // so would the next directive family somebody invents
1264
+ const plannableAttr = (name: string): boolean => {
1265
+ if (name.startsWith("@")) return true
1266
+ if (name === ":class") return true
1267
+ if (name.startsWith(":class.")) return true
1268
+ if (!name.startsWith(":")) return name !== COMPONENT_TAG_ATTR // a static attribute
1269
+ return !isControlAttr(name) && !name.includes(".")
1270
+ }
1271
+
1272
+ const plannableNode = (node: TemplateNode): boolean => {
1273
+ if (node.component || node.tag.includes("-")) return false
1274
+ if (isSlotTag(node.tag) || node.tag === "template") return false
1275
+ // an unknown tag may still become a component, and <svg> is one of them:
1276
+ // createElement builds SVG names in the HTML namespace (see renderNode)
1277
+ if (document.createElement(node.tag) instanceof HTMLUnknownElement) return false
1278
+ for (const key in node.attrs) if (!plannableAttr(key)) return false
1279
+ return node.children.every(child => typeof child === "string" || plannableNode(child))
1280
+ }
1281
+
1282
+ // A hole, and the path from the skeleton root to the node it fills: child
1283
+ // indices rather than a query, resolved by walking childNodes. The AST keeps
1284
+ // whitespace text nodes on purpose, and the skeleton keeps them too, so the
1285
+ // indices line up on both sides by construction
1286
+ type SkeletonOp =
1287
+ | { kind: "text"; path: number[]; parts: TextPart[] }
1288
+ | { kind: "event"; path: number[]; attr: string; expr: string }
1289
+ | { kind: "attr"; path: number[]; name: string; expr: string }
1290
+ | { kind: "class"; path: number[]; classExpr?: string; toggles: [string, string][] | null; staticClasses: Set<string> }
1291
+
1292
+ type SkeletonPlan = { skeleton: Element; ops: SkeletonOp[]; tags: string[] }
1293
+
1294
+ // mirrors renderNode's own order: the attribute walk (events and attribute
1295
+ // bindings as they appear), then :class, then the children. Effects run in
1296
+ // registration order, so this is not cosmetic
1297
+ const buildSkeleton = (node: TemplateNode, path: number[], ops: SkeletonOp[], tags: Set<string>): Element => {
1298
+ tags.add(node.tag)
1299
+ const el = document.createElement(node.tag)
1300
+
1301
+ let classExpr: string | undefined
1302
+ let toggles: [string, string][] | null = null
1303
+ for (const key in node.attrs) {
1304
+ const value = node.attrs[key]
1305
+ if (key.startsWith("@")) ops.push({ kind: "event", path, attr: key, expr: value })
1306
+ else if (key === ":class") classExpr = value
1307
+ else if (key.startsWith(":class.")) (toggles ??= []).push([key.slice(":class.".length), value])
1308
+ else if (key.startsWith(":")) {
1309
+ const name = key.slice(1)
1310
+ ops.push({ kind: "attr", path, name, expr: value || kebabToCamel(name) })
1311
+ } else el.setAttribute(key, value)
1312
+ }
1313
+ if (classExpr !== undefined || toggles) {
1314
+ ops.push({ kind: "class", path, classExpr, toggles, staticClasses: new Set(classNames(node.attrs.class ?? "")) })
1315
+ }
1316
+
1317
+ node.children.forEach((child, index) => {
1318
+ if (typeof child === "string") {
1319
+ // an interpolated text node is a hole; the skeleton holds the empty node
1320
+ // it will be written into, so the child indices match either way
1321
+ if (child.includes("{{")) {
1322
+ ops.push({ kind: "text", path: [...path, index], parts: splitText(child) })
1323
+ el.appendChild(document.createTextNode(""))
1324
+ } else el.appendChild(document.createTextNode(child))
1325
+ return
1326
+ }
1327
+ el.appendChild(buildSkeleton(child, [...path, index], ops, tags))
1328
+ })
1329
+
1330
+ return el
1331
+ }
1332
+
1333
+ // how many elements a subtree is worth cloning for. Below this the fixed cost
1334
+ // of the plan - the lookup, the tag check, the path walks - is the whole
1335
+ // saving: planning fragments of one or two elements measured as a wash at best
1336
+ // and a regression on a row whose only fragments are that small
1337
+ const MIN_SKELETON_ELEMENTS = 3
1338
+
1339
+ const countElements = (node: TemplateNode): number =>
1340
+ 1 + node.children.reduce((total, child) => total + (typeof child === "string" ? 0 : countElements(child)), 0)
1341
+
1342
+ const skeletonPlans = new WeakMap<TemplateNode, SkeletonPlan | null>()
1343
+
1344
+ const planOf = (node: TemplateNode): SkeletonPlan | null => {
1345
+ const cached = skeletonPlans.get(node)
1346
+ if (cached !== undefined) return cached
1347
+
1348
+ let plan: SkeletonPlan | null = null
1349
+ if (plannableNode(node) && countElements(node) >= MIN_SKELETON_ELEMENTS) {
1350
+ const ops: SkeletonOp[] = []
1351
+ const tags = new Set<string>()
1352
+ const skeleton = buildSkeleton(node, [], ops, tags)
1353
+ plan = { skeleton, ops, tags: Array.from(tags) }
1354
+ }
1355
+ skeletonPlans.set(node, plan)
1356
+ return plan
1357
+ }
1358
+
1359
+ const atPath = (root: Node, path: number[]): Node => {
1360
+ let at = root
1361
+ for (let i = 0; i < path.length; i++) at = at.childNodes[path[i]]
1362
+ return at
1363
+ }
1364
+
1365
+ const renderFromSkeleton = (plan: SkeletonPlan, scope: Record<string, any>, fx: EffectScope): Node => {
1366
+ const root = plan.skeleton.cloneNode(true) as Element
1367
+
1368
+ for (const op of plan.ops) {
1369
+ const target = op.path.length === 0 ? root : atPath(root, op.path)
1370
+
1371
+ if (op.kind === "text") {
1372
+ const textNode = target as Text
1373
+ const parts = op.parts
1374
+ fx.effect(() => {
1375
+ const text = renderText(parts, scope)
1376
+ if (textNode.textContent !== text) textNode.textContent = text
1377
+ })
1378
+ } else if (op.kind === "event") {
1379
+ bindEvent(target as Element, op.attr, op.expr, scope)
1380
+ } else if (op.kind === "attr") {
1381
+ const el = target as Element
1382
+ const { name, expr } = op
1383
+ fx.effect(() => applyAttr(el, name, evalExpr(expr, scope)))
1384
+ } else {
1385
+ const el = target as Element
1386
+ const { classExpr, toggles, staticClasses } = op
1387
+ let bound: string[] = []
1388
+ fx.effect(() => {
1389
+ const next = classExpr !== undefined ? classNames(evalExpr(classExpr, scope)) : []
1390
+ toggles?.forEach(([name, expr]) => {
1391
+ if (evalExpr(expr, scope)) next.push(...classNames(name))
1392
+ })
1393
+ bound.forEach(name => {
1394
+ if (!next.includes(name) && !staticClasses.has(name)) el.classList.remove(name)
1395
+ })
1396
+ el.classList.add(...next)
1397
+ bound = next
1398
+ })
1399
+ }
1400
+ }
1401
+
1402
+ return root
1403
+ }
1404
+
1152
1405
  const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
1153
1406
  // :with applies to the element's own bindings (@events, :attrs) and its
1154
1407
  // whole subtree. On a :each element the item scope is already in place, so
@@ -1165,6 +1418,18 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1165
1418
  const componentKey = findComponentKey(scope, node.tag)
1166
1419
  if (componentKey) return renderNestedComponent(componentKey, node, scope, fx, shadow)
1167
1420
 
1421
+ // A planned subtree is cloned - unless a scope key captures one of its tags.
1422
+ // findComponentKey strips dashes and lowercases, and every PascalCase scope
1423
+ // key participates, so a variable named `Td` makes every <td> under it a
1424
+ // component and `Map`, `Data`, `Table`, `Form` and `Label` are all HTML tags
1425
+ // somebody might name a component after. "It is a known HTML tag" is not on
1426
+ // its own an answer; this is. It costs what the interpreted path already
1427
+ // pays - one findComponentKey per distinct tag, memoized per render pass
1428
+ if (debugFlags.cloneSkeletons) {
1429
+ const plan = planOf(node)
1430
+ if (plan && !plan.tags.some(tag => findComponentKey(scope, tag))) return renderFromSkeleton(plan, scope, fx)
1431
+ }
1432
+
1168
1433
  const el = document.createElement(node.tag)
1169
1434
 
1170
1435
  // <UserCrad /> - written as a component (node.component), resolving to no
@@ -1218,7 +1483,14 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1218
1483
  })
1219
1484
  }
1220
1485
 
1221
- Object.entries(node.attrs).forEach(([key, value]) => {
1486
+ // walked with `for...in` rather than Object.entries().forEach: a 1,000-row
1487
+ // :each renders every element of its template a thousand times, and the
1488
+ // entries form allocates one array of pairs plus one two-element array per
1489
+ // attribute *per instance*. Nothing here reads the pairs as pairs, so the
1490
+ // allocation buys nothing and the garbage it makes is measurable - see
1491
+ // TODOS/2026-08-23.where-the-create-time-goes.md
1492
+ for (const key in node.attrs) {
1493
+ const value = node.attrs[key]
1222
1494
  if (key.startsWith("@")) bindEvent(el, key, value, scope)
1223
1495
  else if (key === ":model" || key.startsWith(":model.")) {
1224
1496
  // :model binds component tags only (see TODOS/2026-07-15.model-directive.md;
@@ -1249,7 +1521,7 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1249
1521
  fx.effect(() => applyAttr(el, name, evalExpr(expr, scope)))
1250
1522
  }
1251
1523
  } else el.setAttribute(key, value)
1252
- })
1524
+ }
1253
1525
 
1254
1526
  const bindExpr = node.attrs[":attrs"]
1255
1527
  if (bindExpr !== undefined) {
@@ -1271,17 +1543,23 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1271
1543
  // static list survives every re-run, even when the expression names one of
1272
1544
  // its classes and then drops it (class="btn" :class="{ btn: cond }" keeps
1273
1545
  // btn on false)
1546
+ //
1547
+ // The toggle list stays null until a `:class.` attribute is actually found,
1548
+ // for the reason the attribute walk above is a `for...in`: entries + filter +
1549
+ // map allocated three arrays for every element rendered, and the
1550
+ // overwhelming majority of elements carry no `:class.` at all
1274
1551
  const classExpr = node.attrs[":class"]
1275
- const classToggles = Object.entries(node.attrs)
1276
- .filter(([key]) => key.startsWith(":class."))
1277
- .map(([key, expr]): [string, string] => [key.slice(":class.".length), expr])
1278
- if (classExpr !== undefined || classToggles.length) {
1552
+ let classToggles: [string, string][] | null = null
1553
+ for (const key in node.attrs) {
1554
+ if (key.startsWith(":class.")) (classToggles ??= []).push([key.slice(":class.".length), node.attrs[key]])
1555
+ }
1556
+ if (classExpr !== undefined || classToggles) {
1279
1557
  const staticClasses = new Set(classNames(node.attrs.class ?? ""))
1280
1558
  let bound: string[] = []
1281
1559
 
1282
1560
  fx.effect(() => {
1283
1561
  const next = classExpr !== undefined ? classNames(evalExpr(classExpr, scope)) : []
1284
- classToggles.forEach(([name, expr]) => {
1562
+ classToggles?.forEach(([name, expr]) => {
1285
1563
  if (evalExpr(expr, scope)) next.push(...classNames(name))
1286
1564
  })
1287
1565
  bound.forEach(name => {
@@ -1338,12 +1616,18 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1338
1616
  if ((el as HTMLInputElement).value !== value) (el as HTMLInputElement).value = value
1339
1617
  })
1340
1618
  }
1341
- ;([":checked", ":selected"] as const).forEach(attr => {
1342
- const expr = node.attrs[attr]
1343
- if (expr === undefined) return
1344
- const prop = attr.slice(1) as "checked" | "selected"
1345
- fx.effect(() => { (el as any)[prop] = !!evalExpr(expr, scope) })
1346
- })
1619
+ // written out rather than looped over a literal array: the loop allocated the
1620
+ // array *and* its closure for every element rendered - 8,000 of each per
1621
+ // create1k, almost all of them to find nothing. Same reason the attribute
1622
+ // walk above is a `for...in` (TODOS/2026-08-23.where-the-create-time-goes.md)
1623
+ const checkedExpr = node.attrs[":checked"]
1624
+ if (checkedExpr !== undefined) {
1625
+ fx.effect(() => { (el as HTMLInputElement).checked = !!evalExpr(checkedExpr, scope) })
1626
+ }
1627
+ const selectedExpr = node.attrs[":selected"]
1628
+ if (selectedExpr !== undefined) {
1629
+ fx.effect(() => { (el as HTMLOptionElement).selected = !!evalExpr(selectedExpr, scope) })
1630
+ }
1347
1631
 
1348
1632
  return el
1349
1633
  }
@@ -1404,7 +1688,10 @@ const defineScopeVar = (scope: Record<string, any>, key: string, value: any) =>
1404
1688
  // `pos` is where the entry sat in the previous pass, refreshed as the buckets
1405
1689
  // are built - the positioning pass needs the old order to work out which rows
1406
1690
  // are already where they belong (see longestIncreasingRun)
1407
- type EachEntry = { key: any; item: any; scope: Record<string, any>; range: NodeRange; fx: EffectScope; pos: number }
1691
+ // `dead` is set by the pass that disposes the entry, and read by the run walk
1692
+ // right after: a Set membership test per row was the alternative, and a list
1693
+ // dropping 10,000 rows does 10,000 of them
1694
+ type EachEntry = { key: any; item: any; scope: Record<string, any>; range: NodeRange; fx: EffectScope; pos: number; dead?: boolean }
1408
1695
 
1409
1696
  // the indices of one longest strictly increasing subsequence of `positions`,
1410
1697
  // as a flag per index. Fed the old position of every entry in the new order
@@ -1496,10 +1783,37 @@ const identifierIn = (text: string, name: string): boolean => {
1496
1783
  return false
1497
1784
  }
1498
1785
 
1499
- const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
1500
- const match = node.attrs[":each"].match(EACH_PATTERN)
1501
- if (!match) return document.createComment(`invalid :each expression "${node.attrs[":each"]}"`)
1786
+ // Everything renderEach reads off the template and nothing else: the parsed
1787
+ // clause, how the key is read, the item node, and whether anything in the
1788
+ // subtree names a position. All of it is fixed by the source, and none of it
1789
+ // was cached - renderEach runs once per render of its parent, which for a
1790
+ // :each nested inside another is once per row of the outer one. `mentionsAny`
1791
+ // walks the whole item subtree, so that was a subtree walk per row per pass
1792
+ type EachPlan = {
1793
+ itemName: string
1794
+ atName: string | undefined
1795
+ listExpr: string
1796
+ keyExpr: string | undefined
1797
+ keyIsItem: boolean
1798
+ keyProp: string | undefined
1799
+ itemNode: TemplateNode
1800
+ readsPosition: boolean
1801
+ // can either loop name be mistaken for a component? Decided from the
1802
+ // template, so the item scope can be marked plain without being scanned
1803
+ namesComponent: boolean
1804
+ }
1805
+
1806
+ const eachPlans = new WeakMap<TemplateNode, EachPlan | null>()
1807
+
1808
+ const eachPlanOf = (node: TemplateNode): EachPlan | null => {
1809
+ const cached = eachPlans.get(node)
1810
+ if (cached !== undefined) return cached
1502
1811
 
1812
+ const match = node.attrs[":each"].match(EACH_PATTERN)
1813
+ if (!match) {
1814
+ eachPlans.set(node, null)
1815
+ return null
1816
+ }
1503
1817
  const [, itemName, atName, listExpr] = match
1504
1818
  const keyExpr = node.attrs[":key"]
1505
1819
 
@@ -1532,6 +1846,28 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1532
1846
  const positionalNames = ["$index", ...(atName ? [atName] : [])]
1533
1847
  const readsPosition = mentionsAny(itemNode, positionalNames)
1534
1848
 
1849
+ const namesComponent = /^[A-Z]/.test(itemName) || (atName !== undefined && /^[A-Z]/.test(atName))
1850
+ const plan: EachPlan = { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent }
1851
+ eachPlans.set(node, plan)
1852
+ return plan
1853
+ }
1854
+
1855
+ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
1856
+ const plan = eachPlanOf(node)
1857
+ if (!plan) return document.createComment(`invalid :each expression "${node.attrs[":each"]}"`)
1858
+
1859
+ const { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent } = plan
1860
+
1861
+ // `:key="row.id"`, or the loop variable itself, is what a key almost always
1862
+ // is - and reading one needs neither a scope to resolve names against nor a
1863
+ // compiled expression, because the item is already in hand. A pass evaluates
1864
+ // one key per row, so a 1,000-row list paid 1,000 `with`-scoped calls through
1865
+ // the store proxy to discover that nothing had changed: most of the 37% of a
1866
+ // pass that goes on evaluating expressions
1867
+ // (TODOS/2026-08-23.where-the-list-operations-go.md). Anything else - a call,
1868
+ // an index, a deeper path, a name from the outer scope - still goes through
1869
+ // evalExpr, and so does a non-object item, which keeps every diagnostic a
1870
+ // property read of a null row would have raised
1535
1871
  const anchor = document.createComment("each")
1536
1872
  const wrapper = document.createDocumentFragment()
1537
1873
  wrapper.appendChild(anchor)
@@ -1647,6 +1983,8 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1647
1983
  defineScopeVar(itemScope, itemName, item)
1648
1984
  if (atName) defineScopeVar(itemScope, atName, at)
1649
1985
  defineScopeVar(itemScope, "$index", index)
1986
+ // one WeakSet write per row against one Object.keys per element in it
1987
+ if (!namesComponent) plainScopes.add(itemScope)
1650
1988
  const itemFx = createEffectScope(scope)
1651
1989
  // bounds captured before the positioning pass inserts the entry: a
1652
1990
  // component entry is a fragment, which empties on insertion (see boundsOf)
@@ -1661,8 +1999,11 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1661
1999
  const dead = new Set<EachEntry>()
1662
2000
  previous.forEach(bucket => bucket.forEach(entry => dead.add(entry)))
1663
2001
  if (dead.size) {
1664
- dead.forEach(entry => entry.fx.dispose())
1665
- removeRuns(contiguousRuns(entries, entry => dead.has(entry)))
2002
+ dead.forEach(entry => {
2003
+ entry.dead = true
2004
+ entry.fx.dispose()
2005
+ })
2006
+ removeRuns(contiguousRuns(entries, entry => entry.dead === true))
1666
2007
  }
1667
2008
 
1668
2009
  let prevNode: Node = anchor
@@ -1690,6 +2031,168 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1690
2031
  return wrapper
1691
2032
  }
1692
2033
 
2034
+ // the first sibling from `from` that is not indentation. The branches of a
2035
+ // chain are written on their own lines, so whitespace-only text sits between
2036
+ // them in the AST and must not break the chain up. One copy of the rule, used
2037
+ // by the renderer that groups a chain and by the validator that checks its
2038
+ // grammar - the two can never disagree about what "adjacent" means
2039
+ const nextSiblingAt = (nodes: (TemplateNode | string)[], from: number): number => {
2040
+ let at = from
2041
+ while (at < nodes.length && typeof nodes[at] === "string" && !(nodes[at] as string).trim()) at++
2042
+ return at
2043
+ }
2044
+
2045
+ // The grammar of a conditional chain: `:if`, then any number of `:elseif`,
2046
+ // then an optional `:else`, on adjacent sibling elements. Both ways of getting
2047
+ // it wrong render *something*, which is why they need saying out loud:
2048
+ //
2049
+ // - two of the three on one element: the first in precedence order applies and
2050
+ // the rest are control attrs, so they are silently dropped
2051
+ // - a branch no chain claimed - no `:if` before it, or one separated from it by
2052
+ // an element (a `:each` row, a component tag, any sibling that isn't
2053
+ // whitespace): it falls through to renderNode, where `:elseif`/`:else` are
2054
+ // control attrs skipped by the attribute walk, and the element renders
2055
+ // **unconditionally**. That one had no diagnostic at all
2056
+ //
2057
+ // Reported once per definition, from the parse-time walk below
2058
+ const warnChainAttrs = (node: TemplateNode) => {
2059
+ const hasIf = ":if" in node.attrs
2060
+ const hasElseif = ":elseif" in node.attrs
2061
+ const hasElse = ":else" in node.attrs
2062
+ if ((hasIf ? 1 : 0) + (hasElseif ? 1 : 0) + (hasElse ? 1 : 0) < 2) return
2063
+ // allocated only on the way to a warning, never on the path that finds none
2064
+ const present = [hasIf ? ":if" : null, hasElseif ? ":elseif" : null, hasElse ? ":else" : null].filter(Boolean)
2065
+ console.warn(
2066
+ `jq79: ${present.join(" and ")} on the same <${node.tag}> - only ${present[0]} applies; ` +
2067
+ "the branches of a chain are sibling elements, one directive each"
2068
+ )
2069
+ }
2070
+
2071
+ // `afterClosedChain`: the branch chain immediately before this node ended with
2072
+ // an `:else`, so this is a *second* one rather than a stray - which is the
2073
+ // difference between a useful message and a puzzling one ("continues no :if"
2074
+ // reads as nonsense when there is an :if two lines up)
2075
+ const warnOrphanBranch = (node: TemplateNode, afterClosedChain: boolean) => {
2076
+ const attr = ":elseif" in node.attrs ? ":elseif" : ":else"
2077
+ console.warn(
2078
+ afterClosedChain
2079
+ ? `jq79: a second ${attr} on <${node.tag}> - the chain before it already ended with :else, ` +
2080
+ "which closes it. One :if, any number of :elseif, at most one :else"
2081
+ : `jq79: ${attr} on <${node.tag}> continues no :if - it renders unconditionally. ` +
2082
+ "A chain is :if, then :elseif, then :else, on adjacent siblings: anything but whitespace between them breaks it"
2083
+ )
2084
+ }
2085
+
2086
+ // Checks one node list's chains, and every list below it, against that grammar.
2087
+ // Run once per definition from componentPartsFrom, not per render: a template
2088
+ // says what it says before any data exists, so a stray :else is reported when
2089
+ // the component is defined - once, whatever the list it sits in later renders
2090
+ // a thousand rows of, and even if it sits in a branch that never becomes
2091
+ // active. Rendering is left alone entirely; nothing below costs an instance
2092
+ // anything.
2093
+ //
2094
+ // The dispatch mirrors renderNodes' loop, because that is what decides which
2095
+ // node ends up a branch of what: a :each node is claimed before the chain
2096
+ // grouping ever sees it, which is exactly why it breaks a chain
2097
+ const validateChains = (nodes: (TemplateNode | string)[]) => {
2098
+ nodes.forEach(node => {
2099
+ if (typeof node !== "string") validateChains(node.children)
2100
+ })
2101
+
2102
+ // the chain that ended immediately before this point closed itself with an
2103
+ // :else, so a further branch here is a second one rather than a stray
2104
+ let afterClosedChain = false
2105
+
2106
+ for (let i = 0; i < nodes.length; ) {
2107
+ const node = nodes[i]
2108
+
2109
+ if (typeof node === "string") {
2110
+ if (node.trim()) afterClosedChain = false
2111
+ i++
2112
+ continue
2113
+ }
2114
+
2115
+ // renderEach speaks for a :each element carrying a branch attribute of its
2116
+ // own, and says something more useful than the grammar would
2117
+ if (":each" in node.attrs) {
2118
+ afterClosedChain = false
2119
+ i++
2120
+ continue
2121
+ }
2122
+
2123
+ warnChainAttrs(node)
2124
+
2125
+ if (":if" in node.attrs) {
2126
+ i++
2127
+ // the same walk renderNodes does, so the nodes claimed here are the ones
2128
+ // it will claim: any number of :elseif, then at most one :else
2129
+ const claim = (attr: string): TemplateNode | undefined => {
2130
+ const next = nextSiblingAt(nodes, i)
2131
+ const candidate = nodes[next]
2132
+ if (typeof candidate === "object" && attr in candidate.attrs) {
2133
+ i = next + 1
2134
+ return candidate
2135
+ }
2136
+ return undefined
2137
+ }
2138
+ // a claimed branch never reaches the check above - `:elseif :else` on one
2139
+ // element is claimed as an :elseif and its :else dropped, in silence
2140
+ for (let elseif = claim(":elseif"); elseif; elseif = claim(":elseif")) warnChainAttrs(elseif)
2141
+ const elseNode = claim(":else")
2142
+ if (elseNode) warnChainAttrs(elseNode)
2143
+ // an :else closes the chain. A chain that ended without one cannot be
2144
+ // followed by a stray at all - claim() would have taken it
2145
+ afterClosedChain = elseNode !== undefined
2146
+ continue
2147
+ }
2148
+
2149
+ // no chain claimed this node, so a branch attribute on it is an orphan and
2150
+ // the element renders unconditionally
2151
+ if (":elseif" in node.attrs || ":else" in node.attrs) warnOrphanBranch(node, afterClosedChain)
2152
+ afterClosedChain = false
2153
+ i++
2154
+ }
2155
+ }
2156
+
2157
+ // The chain a `:if` node heads, and the index the sibling walk resumes at.
2158
+ // Both are fixed by the template - the node list is the same array on every
2159
+ // render - and renderNodes runs per instance, so a chain inside a :each row was
2160
+ // re-grouped, and its two arrays re-allocated, once per row per pass.
2161
+ // renderConditional only reads the branches, so one array serves every instance
2162
+ type Chain = { branches: ConditionalBranch[]; next: number }
2163
+
2164
+ const chains = new WeakMap<TemplateNode, Chain>()
2165
+
2166
+ const chainOf = (nodes: (TemplateNode | string)[], node: TemplateNode, from: number): Chain => {
2167
+ const cached = chains.get(node)
2168
+ if (cached) return cached
2169
+
2170
+ const branches: ConditionalBranch[] = [{ expr: node.attrs[":if"], node }]
2171
+ let at = from + 1
2172
+ // the whitespace between the branches is indentation and nothing else, so it
2173
+ // is skipped rather than rendered (nextSiblingAt): only one branch is ever in
2174
+ // the DOM, so there is nothing for it to be a space *between*
2175
+ const claim = (attr: string): TemplateNode | undefined => {
2176
+ const next = nextSiblingAt(nodes, at)
2177
+ const candidate = nodes[next]
2178
+ if (typeof candidate === "object" && attr in candidate.attrs) {
2179
+ at = next + 1
2180
+ return candidate
2181
+ }
2182
+ return undefined
2183
+ }
2184
+
2185
+ for (let elseif = claim(":elseif"); elseif; elseif = claim(":elseif")) {
2186
+ branches.push({ expr: elseif.attrs[":elseif"], node: elseif })
2187
+ }
2188
+ const elseNode = claim(":else")
2189
+ if (elseNode) branches.push({ node: elseNode })
2190
+
2191
+ const chain: Chain = { branches, next: at }
2192
+ chains.set(node, chain)
2193
+ return chain
2194
+ }
2195
+
1693
2196
  // renders a list of sibling template nodes (text + elements), grouping
1694
2197
  // consecutive :if/:elseif/:else nodes into a single conditional block
1695
2198
  // `into` renders straight into an element that is not in the document yet -
@@ -1716,7 +2219,17 @@ const renderNodes = <T extends ParentNode>(
1716
2219
  const textNode = document.createTextNode(node)
1717
2220
  // static text is most of a template (all of its indentation, for a start):
1718
2221
  // only text with a {{ expression }} in it needs an effect to stay in sync
1719
- if (node.includes("{{")) fx.effect(() => { textNode.textContent = interpolate(node, scope) })
2222
+ // the write is guarded: an effect woken by a sibling's change (one entry
2223
+ // of a :each refreshed on a move, a grouped notification) recomputes the
2224
+ // same string it already wrote, and assigning it back is a DOM mutation
2225
+ // the browser has to take seriously
2226
+ if (node.includes("{{")) {
2227
+ const parts = splitText(node)
2228
+ fx.effect(() => {
2229
+ const text = renderText(parts, scope)
2230
+ if (textNode.textContent !== text) textNode.textContent = text
2231
+ })
2232
+ }
1720
2233
  fragment.appendChild(textNode)
1721
2234
  i++
1722
2235
  continue
@@ -1729,31 +2242,9 @@ const renderNodes = <T extends ParentNode>(
1729
2242
  }
1730
2243
 
1731
2244
  if (":if" in node.attrs) {
1732
- const branches: ConditionalBranch[] = [{ expr: node.attrs[":if"], node }]
1733
- i++
1734
-
1735
- // the branches of a chain are siblings in the AST, but the template writes
1736
- // them on their own lines - so the whitespace between them is indentation
1737
- // and nothing else, and it's dropped rather than rendered: only one branch
1738
- // is ever in the DOM, so there is nothing for it to be a space *between*
1739
- const nextBranch = (attr: string): TemplateNode | undefined => {
1740
- let next = i
1741
- while (next < nodes.length && typeof nodes[next] === "string" && !(nodes[next] as string).trim()) next++
1742
- const candidate = nodes[next]
1743
- if (typeof candidate === "object" && attr in candidate.attrs) {
1744
- i = next + 1
1745
- return candidate
1746
- }
1747
- return undefined
1748
- }
1749
-
1750
- for (let elseif = nextBranch(":elseif"); elseif; elseif = nextBranch(":elseif")) {
1751
- branches.push({ expr: elseif.attrs[":elseif"], node: elseif })
1752
- }
1753
- const elseNode = nextBranch(":else")
1754
- if (elseNode) branches.push({ node: elseNode })
1755
-
1756
- fragment.appendChild(renderConditional(branches, scope, fx, shadow))
2245
+ const chain = chainOf(nodes, node, i)
2246
+ fragment.appendChild(renderConditional(chain.branches, scope, fx, shadow))
2247
+ i = chain.next
1757
2248
  continue
1758
2249
  }
1759
2250
 
@@ -2121,6 +2612,10 @@ const componentPartsFrom = (elements: Element[], hashSource: string): ComponentP
2121
2612
  })
2122
2613
  }
2123
2614
 
2615
+ // the template says what it says before any data exists, so its conditional
2616
+ // chains are checked here, once per definition
2617
+ validateChains(template)
2618
+
2124
2619
  return { template, scripts, styles }
2125
2620
  }
2126
2621
 
@@ -2811,6 +3306,26 @@ export class Component79 {
2811
3306
  //
2812
3307
  // Component79.fetch("./app.html").mount("main")
2813
3308
  // const app = await Component79.fetch("./app.html")
3309
+ // Reads the debug flags, and sets the ones it is given:
3310
+ //
3311
+ // Component79.debug() // what is on right now
3312
+ // Component79.debug({ cloneSkeletons: false }) // turn one off
3313
+ //
3314
+ // Returns the flags as they stand after the call, so a caller can put them
3315
+ // back. Global to the module, not per component: these switch how the
3316
+ // renderer works, and a page rendering two ways at once is the one state
3317
+ // nobody could debug
3318
+ static debug(options?: Partial<DebugFlags>): DebugFlags {
3319
+ if (options) {
3320
+ for (const key in options) {
3321
+ const value = options[key as keyof DebugFlags]
3322
+ if (typeof value === "boolean") debugFlags[key as keyof DebugFlags] = value
3323
+ else console.warn(`jq79: Component79.debug ignored "${key}" - the flags are booleans, and the ones it knows are: ${Object.keys(debugFlags).join(", ")}`)
3324
+ }
3325
+ }
3326
+ return { ...debugFlags }
3327
+ }
3328
+
2814
3329
  static fetch(url: string): PendingComponent79 {
2815
3330
  if (Array.isArray(url)) throw new TypeError("Component79.fetch takes one URL; use fetchAll for an array")
2816
3331
  return new PendingComponent79(fetchComponent(url))
@@ -3050,7 +3565,7 @@ export class Component79 {
3050
3565
  }
3051
3566
  declareProps(store, parseFactoryProps(script.content))
3052
3567
  const body = deferred ? defer(factoryCode) : factoryCode
3053
- return runFactoryScript(body, store, fx.effect, instanceHelpers, $import, at)
3568
+ return runFactoryScript(body, store, run => fx.effect(run), instanceHelpers, $import, at)
3054
3569
  }
3055
3570
  const { vars, code } = transformSetupScript(script.content)
3056
3571
  declareProps(store, setupSignature(script))
@@ -3058,7 +3573,7 @@ export class Component79 {
3058
3573
  // to them (and reads of them) through the reactive proxy
3059
3574
  vars.forEach(name => { if (!(name in store)) (store as any)[name] = undefined })
3060
3575
  const body = deferred ? defer(code) : code
3061
- return runSetupScript(body, store, fx.effect, instanceHelpers, $import, at)
3576
+ return runSetupScript(body, store, run => fx.effect(run), instanceHelpers, $import, at)
3062
3577
  })()
3063
3578
  // a script that threw has nothing left to contribute, so its rejection
3064
3579
  // releases the gate exactly as completion does - the error is already