jq79 0.6.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jq79",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Mini reactive component library: single-file components, Svelte-style setup scripts, fine-grained proxy reactivity. Single-file build, zero dependencies.",
5
5
  "keywords": [
6
6
  "reactive",
package/src/jq79.ts CHANGED
@@ -502,6 +502,18 @@ const scanComponentKey = (scope: Record<string, any>, tag: string): string | nul
502
502
  let tagMemo: Map<string, string | null> | null = null
503
503
  let memoBase: object | null = null
504
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
+
505
517
  // opened and closed by hand rather than by a wrapper taking a callback: a
506
518
  // component that renders itself through :each stacks one renderEach per level,
507
519
  // and a callback would add a frame to each of them. The cyclic-component test
@@ -532,6 +544,7 @@ const findComponentKey = (scope: Record<string, any>, tag: string): string | nul
532
544
  tagMemo.set(tag, key)
533
545
  return key
534
546
  }
547
+ if (plainScopes.has(obj)) continue
535
548
  for (const key of Object.keys(obj)) {
536
549
  if (/^[A-Z]/.test(key) && key.replace(/-/g, "").toLowerCase() === normalized) return key
537
550
  }
@@ -1193,6 +1206,202 @@ const applyAttr = (el: Element, name: string, value: any) => {
1193
1206
  // normally. :if/:elseif/:else/:each are handled by renderNodes, which decides
1194
1207
  // *whether*/*how many times* a node is rendered before calling this. Tags
1195
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
+
1196
1405
  const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
1197
1406
  // :with applies to the element's own bindings (@events, :attrs) and its
1198
1407
  // whole subtree. On a :each element the item scope is already in place, so
@@ -1209,6 +1418,18 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1209
1418
  const componentKey = findComponentKey(scope, node.tag)
1210
1419
  if (componentKey) return renderNestedComponent(componentKey, node, scope, fx, shadow)
1211
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
+
1212
1433
  const el = document.createElement(node.tag)
1213
1434
 
1214
1435
  // <UserCrad /> - written as a component (node.component), resolving to no
@@ -1395,12 +1616,18 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1395
1616
  if ((el as HTMLInputElement).value !== value) (el as HTMLInputElement).value = value
1396
1617
  })
1397
1618
  }
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
- })
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
+ }
1404
1631
 
1405
1632
  return el
1406
1633
  }
@@ -1556,10 +1783,37 @@ const identifierIn = (text: string, name: string): boolean => {
1556
1783
  return false
1557
1784
  }
1558
1785
 
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"]}"`)
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
1562
1811
 
1812
+ const match = node.attrs[":each"].match(EACH_PATTERN)
1813
+ if (!match) {
1814
+ eachPlans.set(node, null)
1815
+ return null
1816
+ }
1563
1817
  const [, itemName, atName, listExpr] = match
1564
1818
  const keyExpr = node.attrs[":key"]
1565
1819
 
@@ -1592,6 +1846,28 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1592
1846
  const positionalNames = ["$index", ...(atName ? [atName] : [])]
1593
1847
  const readsPosition = mentionsAny(itemNode, positionalNames)
1594
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
1595
1871
  const anchor = document.createComment("each")
1596
1872
  const wrapper = document.createDocumentFragment()
1597
1873
  wrapper.appendChild(anchor)
@@ -1707,6 +1983,8 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1707
1983
  defineScopeVar(itemScope, itemName, item)
1708
1984
  if (atName) defineScopeVar(itemScope, atName, at)
1709
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)
1710
1988
  const itemFx = createEffectScope(scope)
1711
1989
  // bounds captured before the positioning pass inserts the entry: a
1712
1990
  // component entry is a fragment, which empties on insertion (see boundsOf)
@@ -1753,6 +2031,168 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1753
2031
  return wrapper
1754
2032
  }
1755
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
+
1756
2196
  // renders a list of sibling template nodes (text + elements), grouping
1757
2197
  // consecutive :if/:elseif/:else nodes into a single conditional block
1758
2198
  // `into` renders straight into an element that is not in the document yet -
@@ -1802,31 +2242,9 @@ const renderNodes = <T extends ParentNode>(
1802
2242
  }
1803
2243
 
1804
2244
  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))
2245
+ const chain = chainOf(nodes, node, i)
2246
+ fragment.appendChild(renderConditional(chain.branches, scope, fx, shadow))
2247
+ i = chain.next
1830
2248
  continue
1831
2249
  }
1832
2250
 
@@ -2194,6 +2612,10 @@ const componentPartsFrom = (elements: Element[], hashSource: string): ComponentP
2194
2612
  })
2195
2613
  }
2196
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
+
2197
2619
  return { template, scripts, styles }
2198
2620
  }
2199
2621
 
@@ -2884,6 +3306,26 @@ export class Component79 {
2884
3306
  //
2885
3307
  // Component79.fetch("./app.html").mount("main")
2886
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
+
2887
3329
  static fetch(url: string): PendingComponent79 {
2888
3330
  if (Array.isArray(url)) throw new TypeError("Component79.fetch takes one URL; use fetchAll for an array")
2889
3331
  return new PendingComponent79(fetchComponent(url))