jq79 0.6.2 → 0.6.4

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
@@ -25,6 +25,13 @@ type TemplateNode = {
25
25
  // parse (see stampComponentTag) and lifted off attrs here, where it stops
26
26
  // looking like an attribute to every loop downstream
27
27
  component?: string
28
+ // the element's namespace, present only when it is NOT HTML - an <svg>
29
+ // subtree, or MathML. Read straight off the parsed tree, because the HTML
30
+ // parser has already run the foreign-content algorithm over it and knows
31
+ // things a tag name cannot say: whether this <title> is SVG's or HTML's, and
32
+ // where a <foreignObject> hands the namespace back. Absent is the common
33
+ // case and means HTML, so an ordinary node is exactly the shape it was
34
+ ns?: string
28
35
  }
29
36
 
30
37
  type TagBlock = {
@@ -51,17 +58,25 @@ const elementAttrs = (el: Element): Record<string, string> =>
51
58
  // they are not in the AST at all - which is where slot content is written
52
59
  // (<template :slot.name>), and why a nested <template> used to render as an
53
60
  // empty element whatever was inside it
61
+ const HTML_NS = "http://www.w3.org/1999/xhtml"
62
+
54
63
  const elementToAST = (el: Element): TemplateNode => {
55
64
  const attrs = elementAttrs(el)
65
+ // `tagName` is uppercase for HTML and as-authored for everything else, so the
66
+ // lowercasing that normalizes <DIV> would destroy <clipPath>, <linearGradient>
67
+ // and <feGaussianBlur>, whose names are case-sensitive
68
+ const ns = el.namespaceURI
56
69
  // the pre-parse stamp becomes a field and leaves attrs entirely: it is not a
57
70
  // prop, not a directive and not an attribute, and every loop that walks attrs
58
71
  // would otherwise need to know its name
59
72
  const component = attrs[COMPONENT_TAG_ATTR]
60
73
  delete attrs[COMPONENT_TAG_ATTR]
74
+ const foreign = ns !== null && ns !== HTML_NS
61
75
  return {
62
- tag: el.tagName.toLowerCase(),
76
+ tag: foreign ? el.tagName : el.tagName.toLowerCase(),
63
77
  attrs,
64
78
  ...(component === undefined ? {} : { component }),
79
+ ...(foreign ? { ns } : {}),
65
80
  children: Array.from((el instanceof HTMLTemplateElement ? el.content : el).childNodes).flatMap((node): (TemplateNode | string)[] => {
66
81
  if (node.nodeType === Node.TEXT_NODE) {
67
82
  const text = node.textContent ?? ""
@@ -502,6 +517,18 @@ const scanComponentKey = (scope: Record<string, any>, tag: string): string | nul
502
517
  let tagMemo: Map<string, string | null> | null = null
503
518
  let memoBase: object | null = null
504
519
 
520
+ // Scope objects known to declare no PascalCase key of their own, so the walk
521
+ // below can skip them without calling Object.keys - which allocates an array
522
+ // and scans it, per element, per row. An :each item scope holds `item`,
523
+ // `$index` and maybe the `, at` name, and whether any of those can be a
524
+ // component name is decided by the template, once (see EachPlan).
525
+ //
526
+ // Only scopes jq79 creates and never adds a key to go in here. A store must
527
+ // never: a setup script's `const Row = await $import(...)` arrives as a new key
528
+ // after the template has already rendered, which is the whole reason the tag
529
+ // memo lives for exactly one pass
530
+ const plainScopes = new WeakSet<object>()
531
+
505
532
  // opened and closed by hand rather than by a wrapper taking a callback: a
506
533
  // component that renders itself through :each stacks one renderEach per level,
507
534
  // and a callback would add a frame to each of them. The cyclic-component test
@@ -532,6 +559,7 @@ const findComponentKey = (scope: Record<string, any>, tag: string): string | nul
532
559
  tagMemo.set(tag, key)
533
560
  return key
534
561
  }
562
+ if (plainScopes.has(obj)) continue
535
563
  for (const key of Object.keys(obj)) {
536
564
  if (/^[A-Z]/.test(key) && key.replace(/-/g, "").toLowerCase() === normalized) return key
537
565
  }
@@ -1181,6 +1209,14 @@ const BOOLEAN_ATTRS = new Set([
1181
1209
  // browser doesn't have - and `readonly`/`novalidate`/`ismap` reflect under
1182
1210
  // camelCase property names no kebab->camel pass can produce, failing toward
1183
1211
  // `readonly="false"`, which is read-only
1212
+ // the one place an element is built, so the interpreted path and the cloner
1213
+ // cannot disagree about what a tag means. `ns` is set only for a foreign
1214
+ // element (see TemplateNode) - and creating one in its own namespace is what
1215
+ // makes `viewBox` keep its case, because setAttribute only lowercases a
1216
+ // qualified name on an HTML element
1217
+ const createFor = (node: TemplateNode): Element =>
1218
+ node.ns === undefined ? document.createElement(node.tag) : document.createElementNS(node.ns, node.tag)
1219
+
1184
1220
  const applyAttr = (el: Element, name: string, value: any) => {
1185
1221
  const boolean = BOOLEAN_ATTRS.has(name)
1186
1222
  if (boolean ? !value : value == null) el.removeAttribute(name)
@@ -1193,6 +1229,338 @@ const applyAttr = (el: Element, name: string, value: any) => {
1193
1229
  // normally. :if/:elseif/:else/:each are handled by renderNodes, which decides
1194
1230
  // *whether*/*how many times* a node is rendered before calling this. Tags
1195
1231
  // matching a PascalCase scope variable render as nested components instead
1232
+
1233
+ // ---------------------------------------------------------------------------
1234
+ // Cloning a fixed shape instead of deriving it per instance.
1235
+ //
1236
+ // renderNode asks the same questions of the same AST node for every instance of
1237
+ // it: is this a slot, a component, an unknown tag; which of these attributes is
1238
+ // a directive; split this text on `{{`. For a :each of 1,000 rows that is ~25
1239
+ // questions per element per row whose answers were fixed by the source text.
1240
+ // Where a subtree's *shape* is fixed - the elements, their static attributes and
1241
+ // their nesting never vary, only the values bound into them - the shape is built
1242
+ // once per definition into a detached skeleton, and each instance is one
1243
+ // cloneNode plus a walk to each binding point.
1244
+ //
1245
+ // Worth -20 to -49% of create1k depending on how much fixed structure a row
1246
+ // has, and nothing at all on a row that has none. Measured, with the method and
1247
+ // the caveats, in TODOS/2026-08-24.clone-skeletons-measured.md.
1248
+ //
1249
+ // Two rules keep this from becoming the bug it could be:
1250
+ //
1251
+ // 1. **The holes are an allowlist, never a denylist.** `plannableAttr` names
1252
+ // what a skeleton knows how to fill; every other attribute makes the subtree
1253
+ // unplannable. So a directive added to renderNode later is *slower* until
1254
+ // somebody teaches it here - never silently mis-rendered, which is the
1255
+ // failure a second render path invites.
1256
+ //
1257
+ // The allowlist is where the one divergence found so far came from, and it
1258
+ // came from *widening* rather than from renderNode growing: `:model` is the
1259
+ // single directive renderNode treats specially that CONTROL_ATTRS does not
1260
+ // name, so it slipped through the generic `:<name>` clause. Adding to this
1261
+ // list is the dangerous edit in this file - see
1262
+ // TODOS/2026-08-24.more-holes-in-the-cloner.md.
1263
+ // 2. **The interpreted path stays the fallback for everything else**, including
1264
+ // every tag that could still turn into a component. The upgrade watch and
1265
+ // the unresolved-component throw are not reimplemented here; they are never
1266
+ // reached from here.
1267
+ //
1268
+ // tests/skeleton.test.ts renders a corpus both ways and diffs the DOM *and the
1269
+ // order the bindings register in*, which is what makes rule 1 enforceable
1270
+ // rather than a promise. The order axis is not decoration: :value on a <select>
1271
+ // has to run after its <option>s are bound, and no DOM diff can see that.
1272
+ // ---------------------------------------------------------------------------
1273
+
1274
+ // Flipping this must never change what renders, only how - which is what
1275
+ // tests/skeleton.test.ts exists to keep true. It is on, and switchable through
1276
+ // `Component79.debug({ cloneSkeletons: false })`, because a second render path
1277
+ // is the kind of change that wants an off switch a user can reach without a
1278
+ // rebuild: a page that renders wrong is a bug report either way, but one whose
1279
+ // reporter can say "it goes away with cloning off" is a bug report that names
1280
+ // the file
1281
+ const debugFlags: DebugFlags = { cloneSkeletons: true }
1282
+
1283
+ // What `Component79.debug()` can switch. One flag today; the shape is an object
1284
+ // so the next one does not change the call
1285
+ export type DebugFlags = {
1286
+ // build a fixed-shape subtree by cloning a skeleton made once per definition,
1287
+ // instead of walking the AST for every instance of it. Off means every
1288
+ // element goes through renderNode, exactly as before this existed
1289
+ cloneSkeletons: boolean
1290
+ }
1291
+
1292
+ // the control attributes a skeleton knows how to fill. The rest of
1293
+ // CONTROL_ATTRS stays rejected on purpose: :if/:elseif/:else/:each/:key change
1294
+ // the shape rather than filling a hole, :with changes the scope its subtree
1295
+ // evaluates in, :props belongs to a component tag, and :html carries a
1296
+ // sanitizer plus a second attribute (:html.allowed) whose "without :html"
1297
+ // warning fires once per render interpreted and would fire once per definition
1298
+ // here - see TODOS/2026-08-24.more-holes-in-the-cloner.md
1299
+ const PLANNABLE_CONTROL_ATTRS = new Set([":text", ":attrs", ":value", ":checked", ":selected"])
1300
+
1301
+ // What a hole can be, in the order renderNode registers them.
1302
+ // A `:` attribute with a dot in it is rejected wholesale except `:class.`:
1303
+ // `:model.`, `:props.`, `:slot.` and `:html.allowed` all live in that shape, and
1304
+ // so would the next directive family somebody invents
1305
+ const plannableAttr = (name: string): boolean => {
1306
+ if (name.startsWith("@")) return true
1307
+ if (name === ":class") return true
1308
+ if (name.startsWith(":class.")) return true
1309
+ if (PLANNABLE_CONTROL_ATTRS.has(name)) return true
1310
+ if (!name.startsWith(":")) return name !== COMPONENT_TAG_ATTR // a static attribute
1311
+ // `:model` is the one directive renderNode treats specially that CONTROL_ATTRS
1312
+ // does not name, so the clause below would let it through as a generic
1313
+ // `:<name>` binding and the skeleton would write `model="..."` where the
1314
+ // interpreted path warns and writes nothing (`:model` binds component tags
1315
+ // only). `:model.<name>` is caught by the dot; the bare form needs saying
1316
+ if (name === ":model") return false
1317
+ return !isControlAttr(name) && !name.includes(".")
1318
+ }
1319
+
1320
+ const plannableNode = (node: TemplateNode): boolean => {
1321
+ if (node.component || node.tag.includes("-")) return false
1322
+ if (isSlotTag(node.tag) || node.tag === "template") return false
1323
+ // an unknown tag may still become a component, so it stays interpreted - the
1324
+ // upgrade watch lives there and a clone cannot carry it. A *foreign* element
1325
+ // skips the test rather than failing it: <circle> is an HTMLUnknownElement
1326
+ // when built with createElement, which is exactly the mistake this used to
1327
+ // make, and nothing in an <svg> subtree can ever become a component (no SVG
1328
+ // tag is uppercase-initial, so none is ever stamped as one)
1329
+ if (node.ns === undefined && document.createElement(node.tag) instanceof HTMLUnknownElement) return false
1330
+ for (const key in node.attrs) if (!plannableAttr(key)) return false
1331
+ // an element with :text has no children on either path (see buildSkeleton), so
1332
+ // what the source wrote inside it cannot make the subtree unplannable - a
1333
+ // component tag under a :text is markup nobody renders, not markup the clone
1334
+ // path would get wrong
1335
+ if (node.attrs[":text"] !== undefined) return true
1336
+ return node.children.every(child => typeof child === "string" || plannableNode(child))
1337
+ }
1338
+
1339
+ // A hole, and the path from the skeleton root to the node it fills: child
1340
+ // indices rather than a query, resolved by walking childNodes. The AST keeps
1341
+ // whitespace text nodes on purpose, and the skeleton keeps them too, so the
1342
+ // indices line up on both sides by construction
1343
+ type SkeletonOp =
1344
+ | { kind: "text"; path: number[]; parts: TextPart[] }
1345
+ | { kind: "event"; path: number[]; attr: string; expr: string }
1346
+ | { kind: "attr"; path: number[]; name: string; expr: string }
1347
+ | { kind: "attrs"; path: number[]; expr: string }
1348
+ | { kind: "class"; path: number[]; classExpr?: string; toggles: [string, string][] | null; staticClasses: Set<string> }
1349
+ | { kind: "textContent"; path: number[]; expr: string }
1350
+ | { kind: "value"; path: number[]; expr: string }
1351
+ | { kind: "checked"; path: number[]; expr: string }
1352
+ | { kind: "selected"; path: number[]; expr: string }
1353
+
1354
+ type SkeletonPlan = { skeleton: Element; ops: SkeletonOp[]; tags: string[] }
1355
+
1356
+ // mirrors renderNode's own order: the attribute walk (events and attribute
1357
+ // bindings as they appear), then :class, then the children. Effects run in
1358
+ // registration order, so this is not cosmetic
1359
+ const buildSkeleton = (node: TemplateNode, path: number[], ops: SkeletonOp[], tags: Set<string>): Element => {
1360
+ tags.add(node.tag)
1361
+ const el = createFor(node)
1362
+
1363
+ let classExpr: string | undefined
1364
+ let toggles: [string, string][] | null = null
1365
+ for (const key in node.attrs) {
1366
+ const value = node.attrs[key]
1367
+ if (key.startsWith("@")) ops.push({ kind: "event", path, attr: key, expr: value })
1368
+ else if (key === ":class") classExpr = value
1369
+ else if (key.startsWith(":class.")) (toggles ??= []).push([key.slice(":class.".length), value])
1370
+ // a directive of its own, bound below - the same skip renderNode's walk
1371
+ // makes, and for the same reason: without it :text would be written out as
1372
+ // an attribute named `text`. :class/:class. are control attrs too and are
1373
+ // already caught above
1374
+ else if (isControlAttr(key)) { /* handled after the walk */ }
1375
+ else if (key.startsWith(":")) {
1376
+ const name = key.slice(1)
1377
+ ops.push({ kind: "attr", path, name, expr: value || kebabToCamel(name) })
1378
+ } else el.setAttribute(key, value)
1379
+ }
1380
+ const attrsExpr = node.attrs[":attrs"]
1381
+ if (attrsExpr !== undefined) ops.push({ kind: "attrs", path, expr: attrsExpr })
1382
+
1383
+ if (classExpr !== undefined || toggles) {
1384
+ ops.push({ kind: "class", path, classExpr, toggles, staticClasses: new Set(classNames(node.attrs.class ?? "")) })
1385
+ }
1386
+
1387
+ // :text replaces the element's content, and renderNode never renders the
1388
+ // children of an element carrying one. So the skeleton gives it none either:
1389
+ // a :text node is a leaf on both paths, whatever the source wrote inside it
1390
+ const textExpr = node.attrs[":text"]
1391
+ if (textExpr !== undefined) ops.push({ kind: "textContent", path, expr: textExpr })
1392
+ else node.children.forEach((child, index) => {
1393
+ if (typeof child === "string") {
1394
+ // an interpolated text node is a hole; the skeleton holds the empty node
1395
+ // it will be written into, so the child indices match either way
1396
+ if (child.includes("{{")) {
1397
+ ops.push({ kind: "text", path: [...path, index], parts: splitText(child) })
1398
+ el.appendChild(document.createTextNode(""))
1399
+ } else el.appendChild(document.createTextNode(child))
1400
+ return
1401
+ }
1402
+ el.appendChild(buildSkeleton(child, [...path, index], ops, tags))
1403
+ })
1404
+
1405
+ // after the children, because renderNode registers them there and for its
1406
+ // reason: :value on a <select> can only pick an <option> that already exists.
1407
+ // `ops` is flat and in registration order, and this is the recursive call's
1408
+ // tail, so a parent's form-state ops land after every op of every descendant -
1409
+ // which is exactly what renderNode's own recursion does
1410
+ const valueExpr = node.attrs[":value"]
1411
+ if (valueExpr !== undefined) ops.push({ kind: "value", path, expr: valueExpr })
1412
+ const checkedExpr = node.attrs[":checked"]
1413
+ if (checkedExpr !== undefined) ops.push({ kind: "checked", path, expr: checkedExpr })
1414
+ const selectedExpr = node.attrs[":selected"]
1415
+ if (selectedExpr !== undefined) ops.push({ kind: "selected", path, expr: selectedExpr })
1416
+
1417
+ return el
1418
+ }
1419
+
1420
+ // how many elements a subtree is worth cloning for. Below this the fixed cost
1421
+ // of the plan - the lookup, the tag check, the path walks - is the whole
1422
+ // saving: planning fragments of one or two elements measured as a wash at best
1423
+ // and a regression on a row whose only fragments are that small
1424
+ const MIN_SKELETON_ELEMENTS = 3
1425
+
1426
+ // children under a :text are not built by either path, so they are not elements
1427
+ // this threshold should be counting - a <p :text="v"> with two <span>s written
1428
+ // inside it is one element's worth of cloning, not three
1429
+ const countElements = (node: TemplateNode): number =>
1430
+ node.attrs[":text"] !== undefined
1431
+ ? 1
1432
+ : 1 + node.children.reduce((total, child) => total + (typeof child === "string" ? 0 : countElements(child)), 0)
1433
+
1434
+ const skeletonPlans = new WeakMap<TemplateNode, SkeletonPlan | null>()
1435
+
1436
+ // A definition rendered ONCE pays for a plan it never reuses: +23% at
1437
+ // MIN_SKELETON_ELEMENTS, +11.5% at six elements, measured in
1438
+ // TODOS/2026-08-24.one-shot-render-measured.md. So the plan is built on the
1439
+ // SECOND render, not the first - a one-shot definition never builds one at all,
1440
+ // and a :each of 1,000 rows interprets row 1 and clones the other 999.
1441
+ //
1442
+ // The element count could not answer this. It is a proxy for "will this be
1443
+ // rendered again", and raising it to protect the one-shot case would take a
1444
+ // 9-element list row - which amortizes beautifully - off the clone path. This
1445
+ // keys on the thing itself.
1446
+ //
1447
+ // renderEach calls renderNode per row and renderNode calls planOf, so the list
1448
+ // case needs no special handling; it falls out.
1449
+ //
1450
+ // The third state is a WeakSet rather than a sentinel in the map, so the map's
1451
+ // type keeps saying what it means: absent is "never seen", null is "examined,
1452
+ // not plannable"
1453
+ const seenOnce = new WeakSet<TemplateNode>()
1454
+
1455
+ const planOf = (node: TemplateNode): SkeletonPlan | null => {
1456
+ const cached = skeletonPlans.get(node)
1457
+ if (cached !== undefined) return cached
1458
+
1459
+ // first sighting: interpret it, and decide nothing. Examining it here is the
1460
+ // cost the one-shot case was paying
1461
+ if (!seenOnce.has(node)) {
1462
+ seenOnce.add(node)
1463
+ return null
1464
+ }
1465
+
1466
+ let plan: SkeletonPlan | null = null
1467
+ if (plannableNode(node) && countElements(node) >= MIN_SKELETON_ELEMENTS) {
1468
+ const ops: SkeletonOp[] = []
1469
+ const tags = new Set<string>()
1470
+ const skeleton = buildSkeleton(node, [], ops, tags)
1471
+ plan = { skeleton, ops, tags: Array.from(tags) }
1472
+ }
1473
+ skeletonPlans.set(node, plan)
1474
+ return plan
1475
+ }
1476
+
1477
+ const atPath = (root: Node, path: number[]): Node => {
1478
+ let at = root
1479
+ for (let i = 0; i < path.length; i++) at = at.childNodes[path[i]]
1480
+ return at
1481
+ }
1482
+
1483
+ const renderFromSkeleton = (plan: SkeletonPlan, scope: Record<string, any>, fx: EffectScope): Node => {
1484
+ const root = plan.skeleton.cloneNode(true) as Element
1485
+
1486
+ // ordered by how often a row actually carries the kind, not by when it was
1487
+ // added: this chain runs once per op per instance, so the four holes a
1488
+ // benchmark row is made of are matched before the five a form is
1489
+ for (const op of plan.ops) {
1490
+ const target = op.path.length === 0 ? root : atPath(root, op.path)
1491
+
1492
+ if (op.kind === "text") {
1493
+ const textNode = target as Text
1494
+ const parts = op.parts
1495
+ fx.effect(() => {
1496
+ const text = renderText(parts, scope)
1497
+ if (textNode.textContent !== text) textNode.textContent = text
1498
+ })
1499
+ } else if (op.kind === "event") {
1500
+ bindEvent(target as Element, op.attr, op.expr, scope)
1501
+ } else if (op.kind === "attr") {
1502
+ const el = target as Element
1503
+ const { name, expr } = op
1504
+ fx.effect(() => applyAttr(el, name, evalExpr(expr, scope)))
1505
+ } else if (op.kind === "class") {
1506
+ const el = target as Element
1507
+ const { classExpr, toggles, staticClasses } = op
1508
+ let bound: string[] = []
1509
+ fx.effect(() => {
1510
+ const next = classExpr !== undefined ? classNames(evalExpr(classExpr, scope)) : []
1511
+ toggles?.forEach(([name, expr]) => {
1512
+ if (evalExpr(expr, scope)) next.push(...classNames(name))
1513
+ })
1514
+ bound.forEach(name => {
1515
+ if (!next.includes(name) && !staticClasses.has(name)) el.classList.remove(name)
1516
+ })
1517
+ el.classList.add(...next)
1518
+ bound = next
1519
+ })
1520
+ } else if (op.kind === "attrs") {
1521
+ const el = target as Element
1522
+ const { expr } = op
1523
+ let boundKeys: string[] = []
1524
+ fx.effect(() => {
1525
+ boundKeys.forEach(key => el.removeAttribute(key))
1526
+ const bound = evalExpr(expr, scope)
1527
+ boundKeys = bound && typeof bound === "object" ? Object.keys(bound) : []
1528
+ boundKeys.forEach(key => applyAttr(el, key, bound[key]))
1529
+ })
1530
+ } else if (op.kind === "textContent") {
1531
+ const el = target as Element
1532
+ const { expr } = op
1533
+ fx.effect(() => { el.textContent = String(evalExpr(expr, scope) ?? "") })
1534
+ } else if (op.kind === "value") {
1535
+ // the property, not the attribute, and skipping a write that would not
1536
+ // change it - renderNode's reasons apply here unchanged
1537
+ const el = target as HTMLInputElement
1538
+ const { expr } = op
1539
+ fx.effect(() => {
1540
+ const value = String(evalExpr(expr, scope) ?? "")
1541
+ if (el.value !== value) el.value = value
1542
+ })
1543
+ } else if (op.kind === "checked") {
1544
+ const el = target as HTMLInputElement
1545
+ const { expr } = op
1546
+ fx.effect(() => { el.checked = !!evalExpr(expr, scope) })
1547
+ } else if (op.kind === "selected") {
1548
+ const el = target as HTMLOptionElement
1549
+ const { expr } = op
1550
+ fx.effect(() => { el.selected = !!evalExpr(expr, scope) })
1551
+ } else {
1552
+ // every kind is named above, so this is unreachable - and the assignment
1553
+ // is what makes the compiler say so. A kind added to SkeletonOp and
1554
+ // forgotten here is the exact failure this whole file is arranged to
1555
+ // prevent, and it is cheaper to catch it in tsc than in the corpus
1556
+ const unhandled: never = op
1557
+ void unhandled
1558
+ }
1559
+ }
1560
+
1561
+ return root
1562
+ }
1563
+
1196
1564
  const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
1197
1565
  // :with applies to the element's own bindings (@events, :attrs) and its
1198
1566
  // whole subtree. On a :each element the item scope is already in place, so
@@ -1209,7 +1577,19 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1209
1577
  const componentKey = findComponentKey(scope, node.tag)
1210
1578
  if (componentKey) return renderNestedComponent(componentKey, node, scope, fx, shadow)
1211
1579
 
1212
- const el = document.createElement(node.tag)
1580
+ // A planned subtree is cloned - unless a scope key captures one of its tags.
1581
+ // findComponentKey strips dashes and lowercases, and every PascalCase scope
1582
+ // key participates, so a variable named `Td` makes every <td> under it a
1583
+ // component and `Map`, `Data`, `Table`, `Form` and `Label` are all HTML tags
1584
+ // somebody might name a component after. "It is a known HTML tag" is not on
1585
+ // its own an answer; this is. It costs what the interpreted path already
1586
+ // pays - one findComponentKey per distinct tag, memoized per render pass
1587
+ if (debugFlags.cloneSkeletons) {
1588
+ const plan = planOf(node)
1589
+ if (plan && !plan.tags.some(tag => findComponentKey(scope, tag))) return renderFromSkeleton(plan, scope, fx)
1590
+ }
1591
+
1592
+ const el = createFor(node)
1213
1593
 
1214
1594
  // <UserCrad /> - written as a component (node.component), resolving to no
1215
1595
  // component, and not an element either. Nothing else on the page can supply
@@ -1395,12 +1775,18 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1395
1775
  if ((el as HTMLInputElement).value !== value) (el as HTMLInputElement).value = value
1396
1776
  })
1397
1777
  }
1398
- ;([":checked", ":selected"] as const).forEach(attr => {
1399
- const expr = node.attrs[attr]
1400
- if (expr === undefined) return
1401
- const prop = attr.slice(1) as "checked" | "selected"
1402
- fx.effect(() => { (el as any)[prop] = !!evalExpr(expr, scope) })
1403
- })
1778
+ // written out rather than looped over a literal array: the loop allocated the
1779
+ // array *and* its closure for every element rendered - 8,000 of each per
1780
+ // create1k, almost all of them to find nothing. Same reason the attribute
1781
+ // walk above is a `for...in` (TODOS/2026-08-23.where-the-create-time-goes.md)
1782
+ const checkedExpr = node.attrs[":checked"]
1783
+ if (checkedExpr !== undefined) {
1784
+ fx.effect(() => { (el as HTMLInputElement).checked = !!evalExpr(checkedExpr, scope) })
1785
+ }
1786
+ const selectedExpr = node.attrs[":selected"]
1787
+ if (selectedExpr !== undefined) {
1788
+ fx.effect(() => { (el as HTMLOptionElement).selected = !!evalExpr(selectedExpr, scope) })
1789
+ }
1404
1790
 
1405
1791
  return el
1406
1792
  }
@@ -1556,10 +1942,37 @@ const identifierIn = (text: string, name: string): boolean => {
1556
1942
  return false
1557
1943
  }
1558
1944
 
1559
- const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
1560
- const match = node.attrs[":each"].match(EACH_PATTERN)
1561
- if (!match) return document.createComment(`invalid :each expression "${node.attrs[":each"]}"`)
1945
+ // Everything renderEach reads off the template and nothing else: the parsed
1946
+ // clause, how the key is read, the item node, and whether anything in the
1947
+ // subtree names a position. All of it is fixed by the source, and none of it
1948
+ // was cached - renderEach runs once per render of its parent, which for a
1949
+ // :each nested inside another is once per row of the outer one. `mentionsAny`
1950
+ // walks the whole item subtree, so that was a subtree walk per row per pass
1951
+ type EachPlan = {
1952
+ itemName: string
1953
+ atName: string | undefined
1954
+ listExpr: string
1955
+ keyExpr: string | undefined
1956
+ keyIsItem: boolean
1957
+ keyProp: string | undefined
1958
+ itemNode: TemplateNode
1959
+ readsPosition: boolean
1960
+ // can either loop name be mistaken for a component? Decided from the
1961
+ // template, so the item scope can be marked plain without being scanned
1962
+ namesComponent: boolean
1963
+ }
1964
+
1965
+ const eachPlans = new WeakMap<TemplateNode, EachPlan | null>()
1966
+
1967
+ const eachPlanOf = (node: TemplateNode): EachPlan | null => {
1968
+ const cached = eachPlans.get(node)
1969
+ if (cached !== undefined) return cached
1562
1970
 
1971
+ const match = node.attrs[":each"].match(EACH_PATTERN)
1972
+ if (!match) {
1973
+ eachPlans.set(node, null)
1974
+ return null
1975
+ }
1563
1976
  const [, itemName, atName, listExpr] = match
1564
1977
  const keyExpr = node.attrs[":key"]
1565
1978
 
@@ -1592,6 +2005,28 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1592
2005
  const positionalNames = ["$index", ...(atName ? [atName] : [])]
1593
2006
  const readsPosition = mentionsAny(itemNode, positionalNames)
1594
2007
 
2008
+ const namesComponent = /^[A-Z]/.test(itemName) || (atName !== undefined && /^[A-Z]/.test(atName))
2009
+ const plan: EachPlan = { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent }
2010
+ eachPlans.set(node, plan)
2011
+ return plan
2012
+ }
2013
+
2014
+ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
2015
+ const plan = eachPlanOf(node)
2016
+ if (!plan) return document.createComment(`invalid :each expression "${node.attrs[":each"]}"`)
2017
+
2018
+ const { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent } = plan
2019
+
2020
+ // `:key="row.id"`, or the loop variable itself, is what a key almost always
2021
+ // is - and reading one needs neither a scope to resolve names against nor a
2022
+ // compiled expression, because the item is already in hand. A pass evaluates
2023
+ // one key per row, so a 1,000-row list paid 1,000 `with`-scoped calls through
2024
+ // the store proxy to discover that nothing had changed: most of the 37% of a
2025
+ // pass that goes on evaluating expressions
2026
+ // (TODOS/2026-08-23.where-the-list-operations-go.md). Anything else - a call,
2027
+ // an index, a deeper path, a name from the outer scope - still goes through
2028
+ // evalExpr, and so does a non-object item, which keeps every diagnostic a
2029
+ // property read of a null row would have raised
1595
2030
  const anchor = document.createComment("each")
1596
2031
  const wrapper = document.createDocumentFragment()
1597
2032
  wrapper.appendChild(anchor)
@@ -1707,6 +2142,8 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1707
2142
  defineScopeVar(itemScope, itemName, item)
1708
2143
  if (atName) defineScopeVar(itemScope, atName, at)
1709
2144
  defineScopeVar(itemScope, "$index", index)
2145
+ // one WeakSet write per row against one Object.keys per element in it
2146
+ if (!namesComponent) plainScopes.add(itemScope)
1710
2147
  const itemFx = createEffectScope(scope)
1711
2148
  // bounds captured before the positioning pass inserts the entry: a
1712
2149
  // component entry is a fragment, which empties on insertion (see boundsOf)
@@ -1753,6 +2190,168 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1753
2190
  return wrapper
1754
2191
  }
1755
2192
 
2193
+ // the first sibling from `from` that is not indentation. The branches of a
2194
+ // chain are written on their own lines, so whitespace-only text sits between
2195
+ // them in the AST and must not break the chain up. One copy of the rule, used
2196
+ // by the renderer that groups a chain and by the validator that checks its
2197
+ // grammar - the two can never disagree about what "adjacent" means
2198
+ const nextSiblingAt = (nodes: (TemplateNode | string)[], from: number): number => {
2199
+ let at = from
2200
+ while (at < nodes.length && typeof nodes[at] === "string" && !(nodes[at] as string).trim()) at++
2201
+ return at
2202
+ }
2203
+
2204
+ // The grammar of a conditional chain: `:if`, then any number of `:elseif`,
2205
+ // then an optional `:else`, on adjacent sibling elements. Both ways of getting
2206
+ // it wrong render *something*, which is why they need saying out loud:
2207
+ //
2208
+ // - two of the three on one element: the first in precedence order applies and
2209
+ // the rest are control attrs, so they are silently dropped
2210
+ // - a branch no chain claimed - no `:if` before it, or one separated from it by
2211
+ // an element (a `:each` row, a component tag, any sibling that isn't
2212
+ // whitespace): it falls through to renderNode, where `:elseif`/`:else` are
2213
+ // control attrs skipped by the attribute walk, and the element renders
2214
+ // **unconditionally**. That one had no diagnostic at all
2215
+ //
2216
+ // Reported once per definition, from the parse-time walk below
2217
+ const warnChainAttrs = (node: TemplateNode) => {
2218
+ const hasIf = ":if" in node.attrs
2219
+ const hasElseif = ":elseif" in node.attrs
2220
+ const hasElse = ":else" in node.attrs
2221
+ if ((hasIf ? 1 : 0) + (hasElseif ? 1 : 0) + (hasElse ? 1 : 0) < 2) return
2222
+ // allocated only on the way to a warning, never on the path that finds none
2223
+ const present = [hasIf ? ":if" : null, hasElseif ? ":elseif" : null, hasElse ? ":else" : null].filter(Boolean)
2224
+ console.warn(
2225
+ `jq79: ${present.join(" and ")} on the same <${node.tag}> - only ${present[0]} applies; ` +
2226
+ "the branches of a chain are sibling elements, one directive each"
2227
+ )
2228
+ }
2229
+
2230
+ // `afterClosedChain`: the branch chain immediately before this node ended with
2231
+ // an `:else`, so this is a *second* one rather than a stray - which is the
2232
+ // difference between a useful message and a puzzling one ("continues no :if"
2233
+ // reads as nonsense when there is an :if two lines up)
2234
+ const warnOrphanBranch = (node: TemplateNode, afterClosedChain: boolean) => {
2235
+ const attr = ":elseif" in node.attrs ? ":elseif" : ":else"
2236
+ console.warn(
2237
+ afterClosedChain
2238
+ ? `jq79: a second ${attr} on <${node.tag}> - the chain before it already ended with :else, ` +
2239
+ "which closes it. One :if, any number of :elseif, at most one :else"
2240
+ : `jq79: ${attr} on <${node.tag}> continues no :if - it renders unconditionally. ` +
2241
+ "A chain is :if, then :elseif, then :else, on adjacent siblings: anything but whitespace between them breaks it"
2242
+ )
2243
+ }
2244
+
2245
+ // Checks one node list's chains, and every list below it, against that grammar.
2246
+ // Run once per definition from componentPartsFrom, not per render: a template
2247
+ // says what it says before any data exists, so a stray :else is reported when
2248
+ // the component is defined - once, whatever the list it sits in later renders
2249
+ // a thousand rows of, and even if it sits in a branch that never becomes
2250
+ // active. Rendering is left alone entirely; nothing below costs an instance
2251
+ // anything.
2252
+ //
2253
+ // The dispatch mirrors renderNodes' loop, because that is what decides which
2254
+ // node ends up a branch of what: a :each node is claimed before the chain
2255
+ // grouping ever sees it, which is exactly why it breaks a chain
2256
+ const validateChains = (nodes: (TemplateNode | string)[]) => {
2257
+ nodes.forEach(node => {
2258
+ if (typeof node !== "string") validateChains(node.children)
2259
+ })
2260
+
2261
+ // the chain that ended immediately before this point closed itself with an
2262
+ // :else, so a further branch here is a second one rather than a stray
2263
+ let afterClosedChain = false
2264
+
2265
+ for (let i = 0; i < nodes.length; ) {
2266
+ const node = nodes[i]
2267
+
2268
+ if (typeof node === "string") {
2269
+ if (node.trim()) afterClosedChain = false
2270
+ i++
2271
+ continue
2272
+ }
2273
+
2274
+ // renderEach speaks for a :each element carrying a branch attribute of its
2275
+ // own, and says something more useful than the grammar would
2276
+ if (":each" in node.attrs) {
2277
+ afterClosedChain = false
2278
+ i++
2279
+ continue
2280
+ }
2281
+
2282
+ warnChainAttrs(node)
2283
+
2284
+ if (":if" in node.attrs) {
2285
+ i++
2286
+ // the same walk renderNodes does, so the nodes claimed here are the ones
2287
+ // it will claim: any number of :elseif, then at most one :else
2288
+ const claim = (attr: string): TemplateNode | undefined => {
2289
+ const next = nextSiblingAt(nodes, i)
2290
+ const candidate = nodes[next]
2291
+ if (typeof candidate === "object" && attr in candidate.attrs) {
2292
+ i = next + 1
2293
+ return candidate
2294
+ }
2295
+ return undefined
2296
+ }
2297
+ // a claimed branch never reaches the check above - `:elseif :else` on one
2298
+ // element is claimed as an :elseif and its :else dropped, in silence
2299
+ for (let elseif = claim(":elseif"); elseif; elseif = claim(":elseif")) warnChainAttrs(elseif)
2300
+ const elseNode = claim(":else")
2301
+ if (elseNode) warnChainAttrs(elseNode)
2302
+ // an :else closes the chain. A chain that ended without one cannot be
2303
+ // followed by a stray at all - claim() would have taken it
2304
+ afterClosedChain = elseNode !== undefined
2305
+ continue
2306
+ }
2307
+
2308
+ // no chain claimed this node, so a branch attribute on it is an orphan and
2309
+ // the element renders unconditionally
2310
+ if (":elseif" in node.attrs || ":else" in node.attrs) warnOrphanBranch(node, afterClosedChain)
2311
+ afterClosedChain = false
2312
+ i++
2313
+ }
2314
+ }
2315
+
2316
+ // The chain a `:if` node heads, and the index the sibling walk resumes at.
2317
+ // Both are fixed by the template - the node list is the same array on every
2318
+ // render - and renderNodes runs per instance, so a chain inside a :each row was
2319
+ // re-grouped, and its two arrays re-allocated, once per row per pass.
2320
+ // renderConditional only reads the branches, so one array serves every instance
2321
+ type Chain = { branches: ConditionalBranch[]; next: number }
2322
+
2323
+ const chains = new WeakMap<TemplateNode, Chain>()
2324
+
2325
+ const chainOf = (nodes: (TemplateNode | string)[], node: TemplateNode, from: number): Chain => {
2326
+ const cached = chains.get(node)
2327
+ if (cached) return cached
2328
+
2329
+ const branches: ConditionalBranch[] = [{ expr: node.attrs[":if"], node }]
2330
+ let at = from + 1
2331
+ // the whitespace between the branches is indentation and nothing else, so it
2332
+ // is skipped rather than rendered (nextSiblingAt): only one branch is ever in
2333
+ // the DOM, so there is nothing for it to be a space *between*
2334
+ const claim = (attr: string): TemplateNode | undefined => {
2335
+ const next = nextSiblingAt(nodes, at)
2336
+ const candidate = nodes[next]
2337
+ if (typeof candidate === "object" && attr in candidate.attrs) {
2338
+ at = next + 1
2339
+ return candidate
2340
+ }
2341
+ return undefined
2342
+ }
2343
+
2344
+ for (let elseif = claim(":elseif"); elseif; elseif = claim(":elseif")) {
2345
+ branches.push({ expr: elseif.attrs[":elseif"], node: elseif })
2346
+ }
2347
+ const elseNode = claim(":else")
2348
+ if (elseNode) branches.push({ node: elseNode })
2349
+
2350
+ const chain: Chain = { branches, next: at }
2351
+ chains.set(node, chain)
2352
+ return chain
2353
+ }
2354
+
1756
2355
  // renders a list of sibling template nodes (text + elements), grouping
1757
2356
  // consecutive :if/:elseif/:else nodes into a single conditional block
1758
2357
  // `into` renders straight into an element that is not in the document yet -
@@ -1802,31 +2401,9 @@ const renderNodes = <T extends ParentNode>(
1802
2401
  }
1803
2402
 
1804
2403
  if (":if" in node.attrs) {
1805
- const branches: ConditionalBranch[] = [{ expr: node.attrs[":if"], node }]
1806
- i++
1807
-
1808
- // the branches of a chain are siblings in the AST, but the template writes
1809
- // them on their own lines - so the whitespace between them is indentation
1810
- // and nothing else, and it's dropped rather than rendered: only one branch
1811
- // is ever in the DOM, so there is nothing for it to be a space *between*
1812
- const nextBranch = (attr: string): TemplateNode | undefined => {
1813
- let next = i
1814
- while (next < nodes.length && typeof nodes[next] === "string" && !(nodes[next] as string).trim()) next++
1815
- const candidate = nodes[next]
1816
- if (typeof candidate === "object" && attr in candidate.attrs) {
1817
- i = next + 1
1818
- return candidate
1819
- }
1820
- return undefined
1821
- }
1822
-
1823
- for (let elseif = nextBranch(":elseif"); elseif; elseif = nextBranch(":elseif")) {
1824
- branches.push({ expr: elseif.attrs[":elseif"], node: elseif })
1825
- }
1826
- const elseNode = nextBranch(":else")
1827
- if (elseNode) branches.push({ node: elseNode })
1828
-
1829
- fragment.appendChild(renderConditional(branches, scope, fx, shadow))
2404
+ const chain = chainOf(nodes, node, i)
2405
+ fragment.appendChild(renderConditional(chain.branches, scope, fx, shadow))
2406
+ i = chain.next
1830
2407
  continue
1831
2408
  }
1832
2409
 
@@ -2194,6 +2771,10 @@ const componentPartsFrom = (elements: Element[], hashSource: string): ComponentP
2194
2771
  })
2195
2772
  }
2196
2773
 
2774
+ // the template says what it says before any data exists, so its conditional
2775
+ // chains are checked here, once per definition
2776
+ validateChains(template)
2777
+
2197
2778
  return { template, scripts, styles }
2198
2779
  }
2199
2780
 
@@ -2884,6 +3465,26 @@ export class Component79 {
2884
3465
  //
2885
3466
  // Component79.fetch("./app.html").mount("main")
2886
3467
  // const app = await Component79.fetch("./app.html")
3468
+ // Reads the debug flags, and sets the ones it is given:
3469
+ //
3470
+ // Component79.debug() // what is on right now
3471
+ // Component79.debug({ cloneSkeletons: false }) // turn one off
3472
+ //
3473
+ // Returns the flags as they stand after the call, so a caller can put them
3474
+ // back. Global to the module, not per component: these switch how the
3475
+ // renderer works, and a page rendering two ways at once is the one state
3476
+ // nobody could debug
3477
+ static debug(options?: Partial<DebugFlags>): DebugFlags {
3478
+ if (options) {
3479
+ for (const key in options) {
3480
+ const value = options[key as keyof DebugFlags]
3481
+ if (typeof value === "boolean") debugFlags[key as keyof DebugFlags] = value
3482
+ else console.warn(`jq79: Component79.debug ignored "${key}" - the flags are booleans, and the ones it knows are: ${Object.keys(debugFlags).join(", ")}`)
3483
+ }
3484
+ }
3485
+ return { ...debugFlags }
3486
+ }
3487
+
2887
3488
  static fetch(url: string): PendingComponent79 {
2888
3489
  if (Array.isArray(url)) throw new TypeError("Component79.fetch takes one URL; use fetchAll for an array")
2889
3490
  return new PendingComponent79(fetchComponent(url))