jq79 0.6.3 → 0.6.5

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
@@ -3,7 +3,7 @@ import { $, $$, $create, sanitizeHTML, allowedHosts } from "./dom"
3
3
  import type { AllowUrl } from "./dom"
4
4
  import { $reactive, $toRaw, untracked, createEffectScope, ALSO_WAKEN_BY } from "./reactive"
5
5
  import type { ReactiveDeepData, EffectScope } from "./reactive"
6
- import { transformSetupScript, transformFactoryScript, parsePropsPattern, parseFactoryProps, type PropDecl } from "./transform"
6
+ import { transformSetupScript, transformFactoryScript, parsePropsPattern, parseFactoryProps, freeIdentifiers, type PropDecl } from "./transform"
7
7
 
8
8
  export { $, $$, $create } from "./dom"
9
9
  export { $reactive, $toRaw } from "./reactive"
@@ -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 ?? ""
@@ -89,24 +104,107 @@ const elementToAST = (el: Element): TemplateNode => {
89
104
  // expression compiled with and without $event is two different functions. A
90
105
  // syntactically invalid expression caches its failure (null) so it isn't
91
106
  // recompiled, and rethrown as undefined, exactly as before
92
- const compiled = new Map<string, Function | null>()
107
+ // One entry per (extras, expression). `scoped` says which form `fn` is, so the
108
+ // evaluation path never rebuilds the key to ask - it is a string concatenation
109
+ // per evaluation, and this is the hottest loop in the library
110
+ type CompiledExpr = { fn: Function | null; scoped: boolean }
93
111
 
94
- const compileExpr = (expr: string, params: string[]): Function | null => {
95
- const key = `${params.join(",")}|${expr}`
96
- let fn = compiled.get(key)
97
- if (fn === undefined) {
98
- try {
99
- // the newline before `)` ends a trailing line comment in the
100
- // expression ({{ msg // greeting }}); ASI doesn't apply inside parens,
101
- // so everything else is untouched. Without it the comment eats the
102
- // rest of this single-line body and the expression never compiles
103
- fn = new Function("$scope", ...params, `with ($scope) { return (${expr}\n); }`)
104
- } catch {
105
- fn = null // a syntax error: it will never compile, so don't try again
106
- }
107
- compiled.set(key, fn)
112
+ const compiled = new Map<string, CompiledExpr>()
113
+
114
+ // Resolves a name the `const` prologue could not: its fast read came back
115
+ // undefined, which means one of three different things. `with` told them apart
116
+ // by consulting [[HasProperty]] on every read of every name; this consults it
117
+ // only here, which is why the fast path has no `has` trap in it at all
118
+ //
119
+ // The `in scope` branch is load-bearing beyond "declared but undefined": a
120
+ // deleted key leaves a tombstone the store still claims, so `user ? user.name :
121
+ // "none"` takes its else branch instead of dying against globalThis (see
122
+ // RECORD/2026-08-23.narrow-the-wake-rule.md)
123
+ const resolveName = (scope: Record<string, any>, name: string): any => {
124
+ if (name in scope) return undefined
125
+ if (name in globalThis) return (globalThis as any)[name]
126
+ // the same error `with` threw, with the same wording, so reportExprError's
127
+ // MISSING_NAME_RE reads it exactly as it always has
128
+ throw new ReferenceError(`${name} is not defined`)
129
+ }
130
+
131
+ // the newline before `)` ends a trailing line comment in the expression
132
+ // ({{ msg // greeting }}); ASI doesn't apply inside parens, so everything else
133
+ // is untouched. Without it the comment eats the rest of this single-line body
134
+ // and the expression never compiles
135
+ const compileWith = (expr: string, params: string[]): Function | null => {
136
+ try {
137
+ return new Function("$scope", "$r", ...params, `with ($scope) { return (${expr}\n); }`)
138
+ } catch {
139
+ return null // a syntax error: it will never compile, so don't try again
140
+ }
141
+ }
142
+
143
+ // The same evaluation with the names resolved by hand: each free identifier
144
+ // becomes a `const` read straight off the scope, and the expression's own text
145
+ // is left exactly as authored inside the return. One proxy `get` per name -
146
+ // the same read `with` would have made, so the deps tracked are the same ones.
147
+ // Worth 63% of a one-name evaluation and 72% of a two-name one, measured in
148
+ // RECORD/2026-08-27.name-resolution-without-with.md
149
+ //
150
+ // Returns null for anything freeIdentifiers refuses (an assignment, an arrow,
151
+ // a declaration, a called name - handlers, mostly, where an evaluation's cost
152
+ // does not matter), and for a prologue that will not compile: the caller falls
153
+ // back to `with`.
154
+ //
155
+ // A name the extractor MISSES is not that benign, and a sabotage measured it:
156
+ // the prologue does not declare it, so the expression reads it off globalThis.
157
+ // Where nothing is there it throws and runExpr demotes the whole expression to
158
+ // `with` - slower, same answer - but where the page has a global of that name
159
+ // (`name`, `status`, `length`, `top`, `event` ... window has hundreds) it reads
160
+ // the global instead of the store, and nothing throws. So the extractor's
161
+ // misses are a correctness surface, not only a performance one, and the
162
+ // differential in tests/expressions.test.ts is what stands behind it
163
+ const compileScoped = (expr: string, params: string[]): Function | null => {
164
+ if (!debugFlags.scopedNames) return null
165
+ const free = freeIdentifiers(expr)
166
+ if (free === null) return null
167
+ // an extra is already a parameter of this function: declaring it again would
168
+ // shadow the value the caller passed in
169
+ const names = free.filter(name => !params.includes(name))
170
+ const prologue = names.length === 0 ? "" : `let $t; ${names.map(name =>
171
+ `const ${name} = ($t = $scope.${name}) !== undefined ? $t : $r($scope, ${JSON.stringify(name)});`).join(" ")}`
172
+ try {
173
+ return new Function("$scope", "$r", ...params, `${prologue} return (${expr}\n);`)
174
+ } catch {
175
+ return null
176
+ }
177
+ }
178
+
179
+ const entryFor = (key: string, expr: string, params: string[]): CompiledExpr => {
180
+ let entry = compiled.get(key)
181
+ if (entry === undefined) {
182
+ const scoped = compileScoped(expr, params)
183
+ entry = scoped ? { fn: scoped, scoped: true } : { fn: compileWith(expr, params), scoped: false }
184
+ compiled.set(key, entry)
108
185
  }
109
- return fn
186
+ return entry
187
+ }
188
+
189
+ const exprKey = (expr: string, params: string[]): string => `${params.join(",")}|${expr}`
190
+
191
+ const compileExpr = (expr: string, params: string[]): Function | null =>
192
+ entryFor(exprKey(expr, params), expr, params).fn
193
+
194
+ // The safety net under the extractor. A free name it fails to collect is not
195
+ // declared by the prologue, so the expression reaches for it against globalThis
196
+ // and throws - and that is indistinguishable, from here, from the name being
197
+ // genuinely undeclared. So the first ReferenceError out of a scoped function
198
+ // demotes that expression to `with` permanently and evaluates it again: a name
199
+ // the scanner missed costs one wasted evaluation, and a name that really is
200
+ // missing throws again from `with`, reported exactly as before.
201
+ //
202
+ // The retry can run a call in the expression twice, which is why it happens
203
+ // once per expression and never for the `with` form
204
+ const demoteToWith = (key: string, expr: string, params: string[]): Function | null => {
205
+ const fallback = compileWith(expr, params)
206
+ compiled.set(key, { fn: fallback, scoped: false })
207
+ return fallback
110
208
  }
111
209
 
112
210
  // a template expression is re-evaluated constantly - once per effect run, once
@@ -225,9 +323,20 @@ const reportExprError = (expr: string, scope: Record<string, any>, error: unknow
225
323
  }
226
324
 
227
325
  const runExpr = (expr: string, scope: Record<string, any>, extras?: Record<string, any>): any => {
228
- const fn = compileExpr(expr, extras ? Object.keys(extras) : [])
229
- if (!fn) return undefined // a syntax error: compileExpr cached the failure, and it stays undefined
230
- return fn(scope, ...(extras ? Object.values(extras) : []))
326
+ const params = extras ? Object.keys(extras) : []
327
+ const key = exprKey(expr, params)
328
+ const { fn, scoped } = entryFor(key, expr, params)
329
+ if (!fn) return undefined // a syntax error: the failure is cached, and it stays undefined
330
+ const args = extras ? Object.values(extras) : []
331
+ if (!scoped) return fn(scope, resolveName, ...args)
332
+ try {
333
+ return fn(scope, resolveName, ...args)
334
+ } catch (error) {
335
+ if (!(error instanceof ReferenceError)) throw error
336
+ const fallback = demoteToWith(key, expr, params)
337
+ if (!fallback) throw error
338
+ return fallback(scope, resolveName, ...args)
339
+ }
231
340
  }
232
341
 
233
342
  const evalExpr = (expr: string, scope: Record<string, any>, extras?: Record<string, any>): any => {
@@ -312,7 +421,7 @@ const renderText = (parts: TextPart[], scope: Record<string, any>): string => {
312
421
  }
313
422
 
314
423
 
315
- const CONTROL_ATTRS = new Set([":attrs", ":class", ":value", ":checked", ":selected", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html", ":html.allowed", ":props"])
424
+ const CONTROL_ATTRS = new Set([":class", ":value", ":checked", ":selected", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html", ":html.allowed", ":props"])
316
425
 
317
426
  // a control attribute is one the static-attr loop and nested-component prop
318
427
  // collection must skip. The set holds the fixed names; `:class.<name>` (the
@@ -417,7 +526,7 @@ const removeRange = ({ first, last }: NodeRange) => {
417
526
  // removes several ranges that sit next to each other, in one DOM call each run
418
527
  // rather than one per node. A list dropping all its rows hands them over as a
419
528
  // single span: unlinking 10,000 rows one at a time is 40% of that operation,
420
- // profiled - see TODOS/2026-08-23.batch-range-removal.md. Runs are built by the
529
+ // profiled - see RECORD/2026-08-23.batch-range-removal.md. Runs are built by the
421
530
  // caller, which is the only place that knows what else is going
422
531
  const removeRuns = (runs: NodeRange[]) => {
423
532
  runs.forEach(run => {
@@ -491,7 +600,7 @@ const scanComponentKey = (scope: Record<string, any>, tag: string): string | nul
491
600
  // a template renders before its setup script settles, so `const Row = await
492
601
  // $import(...)` arrives as a new store key *after* elements are on the page -
493
602
  // and a cached "no component called Row" that outlived the pass would never be
494
- // revisited. See TODOS/2026-08-23.component-key-scan.md
603
+ // revisited. See RECORD/2026-08-23.component-key-scan.md
495
604
  // The memo answers for one *base* scope - the one the pass was opened with -
496
605
  // and nothing below it. A lookup walks from wherever it starts up to that base,
497
606
  // checking own keys as it goes (an :each item scope has two or three, a :with
@@ -534,6 +643,31 @@ const closeRenderPass = (outer: RenderPass) => {
534
643
  memoBase = outer.base
535
644
  }
536
645
 
646
+ // which scope key a tag resolves to, and the only place that decides it. Two
647
+ // spellings reach a component and nothing else does:
648
+ //
649
+ // - **the name the author capitalized**, read off `component` rather than off
650
+ // the tag, because the tag no longer holds it: the pre-parse rewrite renames
651
+ // <Circle /> to <c79-circle> precisely so the parser cannot build a native
652
+ // element from it (see componentTagName)
653
+ // - **a dashed tag** - <drop-area> resolves DropArea - which is custom-element
654
+ // shaped and so cannot collide with a native element either
655
+ //
656
+ // An undashed lowercase tag resolves to nothing, whatever is in scope. That is
657
+ // the other half of the capture this closes: a `Td` in scope no longer turns
658
+ // every <td> under it into a component, and <mychip> no longer becomes MyChip
659
+ // when the key arrives. See RECORD/2026-08-25.component-tag-prefix.md
660
+ const componentKeyOf = (node: TemplateNode, scope: Record<string, any>): string | null =>
661
+ node.component !== undefined
662
+ ? findComponentKey(scope, node.component)
663
+ : node.tag.includes("-")
664
+ ? findComponentKey(scope, node.tag)
665
+ : null
666
+
667
+ // what to call a tag in a message: the name the author wrote. A component tag's
668
+ // `tag` is the renamed c79-* one, which nobody typed and nobody should read
669
+ const tagLabel = (node: TemplateNode): string => node.component ?? node.tag
670
+
537
671
  const findComponentKey = (scope: Record<string, any>, tag: string): string | null => {
538
672
  if (!tagMemo) return scanComponentKey(scope, tag)
539
673
  const normalized = tag.replace(/-/g, "").toLowerCase()
@@ -679,7 +813,7 @@ const partitionSlots = (node: TemplateNode): Record<string, SlotContent> => {
679
813
  // first wins, like two <template name="X"> in one file: a duplicate is a
680
814
  // typo, and the fix is to delete one - not to guess which
681
815
  if (name in contents) {
682
- console.warn(`jq79: two <template ${slotAttrName(name)}> in <${node.tag}>; the second was ignored`)
816
+ console.warn(`jq79: two <template ${slotAttrName(name)}> in <${tagLabel(node)}>; the second was ignored`)
683
817
  return
684
818
  }
685
819
  contents[name] = { nodes: child.children, binder: child.attrs[attr] || undefined }
@@ -688,7 +822,7 @@ const partitionSlots = (node: TemplateNode): Record<string, SlotContent> => {
688
822
  const hasLoose = loose.some(isMeaningful)
689
823
  if (hasLoose && "default" in contents) {
690
824
  console.warn(
691
- `jq79: <${node.tag}> has both a <template :slot> and content outside it - ` +
825
+ `jq79: <${tagLabel(node)}> has both a <template :slot> and content outside it - ` +
692
826
  "the <template> is the default slot's content, and the rest was ignored"
693
827
  )
694
828
  } else if (hasLoose) {
@@ -809,6 +943,44 @@ const renderSlot = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
809
943
  return wrapper
810
944
  }
811
945
 
946
+ // the element a component instance renders in - <c79-user-card> - and the
947
+ // reason step 2 of the tag rename exists: a component stops being a pair of
948
+ // comments around loose content and becomes a box that names itself in the
949
+ // inspector and can be addressed by name in CSS.
950
+ //
951
+ // Named after the *component*, not the usage site, so `<UserCard />` and
952
+ // `<user-card>` render the same box and a stylesheet has one name to target.
953
+ //
954
+ // It carries the PARENT's scope stamp, which is not an arbitrary pick: a
955
+ // <style scoped> is rewritten to demand the stamp of whoever wrote it, and
956
+ // `Circle { … }` is written by the parent. The stamp is already on the node -
957
+ // stampScope walks the parent's template and the component tag is part of it -
958
+ // and today it is dropped for want of an element to put it on. The child's own
959
+ // root still carries no stamp from the parent, so a parent gets the box and
960
+ // never what is inside it.
961
+ //
962
+ // EXCEPT inside <svg> or <math>: SVG's rendering model renders neither an
963
+ // unknown element nor its children, and `display: contents` is not the escape
964
+ // hatch there that it is in HTML, so a wrapper could turn a diagram into a
965
+ // blank. A foreign-namespace usage site keeps the anchors alone, as it always
966
+ // had. See RECORD/2026-08-25.the-wrapper-and-the-css-rename.md
967
+ const COMPONENT_BOX_ATTR = "data-c79-box"
968
+
969
+ const componentBox = (key: string, node: TemplateNode, shadow: boolean): DocumentFragment | HTMLElement => {
970
+ if (node.ns !== undefined) return document.createDocumentFragment()
971
+ const box = document.createElement(componentTagName(key))
972
+ // the marker the default stylesheet selects on. A tag name cannot be
973
+ // prefix-matched in CSS, and one attribute is one rule for every box on the
974
+ // page - see wrapperStyle()
975
+ box.setAttribute(COMPONENT_BOX_ATTR, "")
976
+ const stamp = node.attrs[SCOPE_ATTR]
977
+ if (stamp !== undefined) box.setAttribute(SCOPE_ATTR, stamp)
978
+ // a shadow-rendered tree leaves document.head alone - its copy of the rule
979
+ // rides with the instance's own styles instead (see renderWith)
980
+ if (!shadow) ensureWrapperStyle()
981
+ return box
982
+ }
983
+
812
984
  // <MyComponent :user :title="'str'"></MyComponent> - renders a child
813
985
  // component instance at this position. Props: `:name="expr"` evaluates expr
814
986
  // in the parent scope (`:name` alone is shorthand for `:name="name"`), plain
@@ -822,15 +994,51 @@ const renderSlot = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
822
994
  // <style> has to go in there with it - document.head can't reach into a shadow
823
995
  // tree, and a style that never applies to its own component would still be
824
996
  // restyling the page around it
997
+ // once per usage site, not per instance: a :each of 1,000 rows shares one AST
998
+ // node, and the message is about the position rather than the row
999
+ const warnedForeignRoots = new WeakSet<TemplateNode>()
1000
+
1001
+ // A namespace is a PARSE-time fact and a usage site is a RENDER-time one, and a
1002
+ // nested component is the first thing that separates them: a definition's
1003
+ // template is parsed on its own, so a template rooted at a bare <circle> comes
1004
+ // out of the parser in HTML - it lands inside the <svg> and never draws, in
1005
+ // every engine. Nothing about that is visible: no error, no gap, an element in
1006
+ // the DOM with nothing on the screen.
1007
+ //
1008
+ // A component's template decides its own namespace, which is the answer this
1009
+ // project chose (RECORD/2026-08-26.the-namespace-of-a-component.md): a component
1010
+ // used inside an <svg> roots its template at <svg>. This is what says so when it
1011
+ // does not, instead of leaving a blank diagram
1012
+ const warnForeignRoot = (node: TemplateNode, definition: Component79) => {
1013
+ if (node.ns === undefined || warnedForeignRoots.has(node)) return
1014
+ const root = definition.template.find(child => typeof child === "object") as TemplateNode | undefined
1015
+ if (root === undefined || root.ns !== undefined) return
1016
+ warnedForeignRoots.add(node)
1017
+ const wrapper = node.ns === "http://www.w3.org/1998/Math/MathML" ? "<math>" : "<svg>"
1018
+ console.warn(
1019
+ `jq79: <${tagLabel(node)}> is used inside ${wrapper} and its template starts with <${root.tag.toLowerCase()}>, ` +
1020
+ `which is parsed as HTML - it renders and never draws. A component's template decides its own namespace, ` +
1021
+ `so root it at ${wrapper}`
1022
+ )
1023
+ }
1024
+
825
1025
  const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
826
- // two anchors bracketing everything this usage site ever renders: the
827
- // instance's DOM is dynamic (the definition can resolve late or be swapped),
828
- // so a caller that needs to move or remove this chunk later can't hold any
829
- // of it - it holds the anchors, which never move on their own (see boundsOf)
830
- const anchor = document.createComment(key)
831
- const endAnchor = document.createComment(`/${key}`)
832
- const wrapper = document.createDocumentFragment()
833
- wrapper.append(anchor, endAnchor)
1026
+ // What this usage site ever renders needs stable bounds: the instance's DOM
1027
+ // is dynamic (the definition can resolve late or be swapped), so a caller
1028
+ // that moves or removes the chunk later cannot hold any of it.
1029
+ //
1030
+ // The BOX is those bounds where there is one - it is created once here and
1031
+ // never replaced, and boundsOf resolves an element as { first: el, last: el }.
1032
+ // So a boxed usage site renders no anchors at all: two comment nodes per
1033
+ // instance that nothing read (RECORD/2026-08-25.retire-the-anchors.md).
1034
+ //
1035
+ // In a foreign namespace there is no box (componentBox returns a fragment,
1036
+ // because a wrapper inside <svg> takes the drawing with it - measured in
1037
+ // three engines), and there the anchors are still doing the whole job
1038
+ const wrapper = componentBox(key, node, shadow)
1039
+ const boxed = !(wrapper instanceof DocumentFragment)
1040
+ const endAnchor = boxed ? null : document.createComment(`/${key}`)
1041
+ if (!boxed) wrapper.append(document.createComment(key), endAnchor!)
834
1042
 
835
1043
  // the tag's children, as content for the child's <slot>s. Built once per
836
1044
  // usage site (the AST doesn't change) and closed over the parent's scope
@@ -899,14 +1107,14 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
899
1107
  Object.entries(models).forEach(([name, expr]) => {
900
1108
  const prop = modelProp(name)
901
1109
  if (props[prop] !== undefined) {
902
- console.warn(`jq79: <${node.tag}> binds prop "${prop}" through both :${prop} and ${modelAttr(name)} - ${modelAttr(name)} wins`)
1110
+ console.warn(`jq79: <${tagLabel(node)}> binds prop "${prop}" through both :${prop} and ${modelAttr(name)} - ${modelAttr(name)} wins`)
903
1111
  }
904
1112
  props[prop] = expr
905
1113
  // an expression that can't be an assignment target is a wiring mistake -
906
1114
  // say so now, not on the first update that silently goes nowhere
907
1115
  if (compileExpr(assignment(expr), ["$value"]) === null) {
908
1116
  unassignable.add(name)
909
- console.warn(`jq79: ${modelAttr(name)}="${expr}" is not assignable - updates from <${node.tag}> will be dropped`)
1117
+ console.warn(`jq79: ${modelAttr(name)}="${expr}" is not assignable - updates from <${tagLabel(node)}> will be dropped`)
910
1118
  }
911
1119
  })
912
1120
 
@@ -947,14 +1155,14 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
947
1155
  if (!unfilled?.has(key) || reported.has("unfilled")) return
948
1156
  reported.add("unfilled")
949
1157
  console.error(
950
- `jq79: <${node.tag}> is declared as a prop and the parent passed nothing - nothing renders here. ` +
1158
+ `jq79: <${tagLabel(node)}> is declared as a prop and the parent passed nothing - nothing renders here. ` +
951
1159
  `Pass it (:${key}="…"), or drop it from the signature to use the one declared in this file.`
952
1160
  )
953
1161
  return
954
1162
  }
955
1163
  if (reported.has("type")) return
956
1164
  reported.add("type")
957
- console.error(`jq79: <${node.tag}> is ${typeof value}, not a component - nothing renders here`)
1165
+ console.error(`jq79: <${tagLabel(node)}> is ${typeof value}, not a component - nothing renders here`)
958
1166
  }
959
1167
 
960
1168
  fx.effect(() => {
@@ -970,6 +1178,8 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
970
1178
  currentDef = nextDef
971
1179
  if (!nextDef) return
972
1180
 
1181
+ warnForeignRoot(node, nextDef)
1182
+
973
1183
  // a fresh instance per usage site: the definition's parsed parts (and
974
1184
  // pre-resolved modules) are shared, but store/effects/DOM are per instance
975
1185
  const instance = new Component79({
@@ -1005,7 +1215,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
1005
1215
  if (expr === undefined) {
1006
1216
  if (!warned.has(name)) {
1007
1217
  warned.add(name)
1008
- console.warn(`jq79: <${node.tag}> has no ${modelAttr(name)} - bound: ${Object.keys(models).map(modelAttr).join(", ")}`)
1218
+ console.warn(`jq79: <${tagLabel(node)}> has no ${modelAttr(name)} - bound: ${Object.keys(models).map(modelAttr).join(", ")}`)
1009
1219
  }
1010
1220
  return false
1011
1221
  }
@@ -1043,7 +1253,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
1043
1253
  // cuts an effect that wakes itself
1044
1254
  if (nestingDepth >= MAX_NESTING_DEPTH) {
1045
1255
  console.error(
1046
- `jq79: <${node.tag}> is ${MAX_NESTING_DEPTH} levels deep inside itself; giving up here. ` +
1256
+ `jq79: <${tagLabel(node)}> is ${MAX_NESTING_DEPTH} levels deep inside itself; giving up here. ` +
1047
1257
  "A component that renders itself stops when its data stops - is there a cycle in it?"
1048
1258
  )
1049
1259
  return
@@ -1054,7 +1264,12 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
1054
1264
  } finally {
1055
1265
  nestingDepth--
1056
1266
  }
1057
- endAnchor.parentNode!.insertBefore(holder, endAnchor)
1267
+ // the box holds this instance and nothing else, so appending is the whole
1268
+ // of it; without one, the end anchor is the only fixed point there is - and
1269
+ // its parentNode is the fragment before this site is inserted and the real
1270
+ // parent after, which is why it is read here rather than captured
1271
+ if (endAnchor) endAnchor.parentNode!.insertBefore(holder, endAnchor)
1272
+ else wrapper.appendChild(holder)
1058
1273
 
1059
1274
  // deep: a prop sync forwards whatever the expression evaluates to, whole,
1060
1275
  // into the child's store - it reads `user`, never `user.name`, so it can't
@@ -1177,8 +1392,9 @@ const BOOLEAN_ATTRS = new Set([
1177
1392
  "playsinline", "readonly", "required", "reversed", "selected",
1178
1393
  ])
1179
1394
 
1180
- // the one value rule, shared by `:attr="expr"` and `:attrs` so the two forms
1181
- // can never disagree:
1395
+ // the value rule for `:attr="expr"`. It was shared with `:attrs` until that
1396
+ // directive was retired (RECORD/2026-08-27.retiring-attrs.md), which is why it
1397
+ // reads like a contract rather than an implementation detail:
1182
1398
  //
1183
1399
  // - a boolean attribute is removed by ANY falsy value and set to "" when
1184
1400
  // truthy, so `:disabled="items.length"` enables the button on an empty list
@@ -1194,14 +1410,91 @@ const BOOLEAN_ATTRS = new Set([
1194
1410
  // browser doesn't have - and `readonly`/`novalidate`/`ismap` reflect under
1195
1411
  // camelCase property names no kebab->camel pass can produce, failing toward
1196
1412
  // `readonly="false"`, which is read-only
1413
+ // the one place an element is built, so the interpreted path and the cloner
1414
+ // cannot disagree about what a tag means. `ns` is set only for a foreign
1415
+ // element (see TemplateNode) - and creating one in its own namespace is what
1416
+ // makes `viewBox` keep its case, because setAttribute only lowercases a
1417
+ // qualified name on an HTML element
1418
+ const createFor = (node: TemplateNode): Element =>
1419
+ node.ns === undefined ? document.createElement(node.tag) : document.createElementNS(node.ns, node.tag)
1420
+
1421
+ // A bound camelCase SVG attribute, resolved by asking the parser.
1422
+ //
1423
+ // expandNameCase rewrites `:viewBox` to `:view-box` before the parse, because
1424
+ // the HTML parser lowercases attribute names and `:firstName` has to survive
1425
+ // it. For SVG's camelCase attributes that is wrong: `view-box` is not an
1426
+ // attribute SVG has, and it was wrong in silence.
1427
+ //
1428
+ // The HTML parser carries its own table for adjusting foreign attribute names -
1429
+ // it is what makes a written-out `viewBox="0 0 10 10"` survive at all. So ask
1430
+ // that table rather than shipping a copy of it: write the name into markup in
1431
+ // the element's own namespace, and read back what the parser called it. The
1432
+ // answer comes from the engine that will render the page, so it cannot disagree
1433
+ // with what that same engine does with the attribute written out.
1434
+ //
1435
+ // This is not the IDL trick RECORD/2026-08-24.svg-namespace.md buried. That one
1436
+ // asked "which family does this name belong to", a question with no ground
1437
+ // truth, and Chromium answered wrong for stdDeviation, attributeName and
1438
+ // repeatCount. This asks the table that decides the static case, and it is
1439
+ // right for all three - see RECORD/2026-08-25.svg-attribute-names.md.
1440
+ const FOREIGN_PROBES: Record<string, [wrapper: string, tag: string]> = {
1441
+ "http://www.w3.org/2000/svg": ["svg", "feGaussianBlur"],
1442
+ "http://www.w3.org/1998/Math/MathML": ["math", "mi"],
1443
+ }
1444
+
1445
+ // one parse per namespace per name, ever - measured at 0.027ms, against
1446
+ // 0.000019ms for a hit. Only SVG and MathML have an adjustment table, so every
1447
+ // other namespace (and every HTML element) skips this entirely
1448
+ const adjustedNames = new Map<string, string>()
1449
+
1450
+ const adjustedName = (ns: string, flat: string): string => {
1451
+ const key = `${ns} ${flat}`
1452
+ const cached = adjustedNames.get(key)
1453
+ if (cached !== undefined) return cached
1454
+
1455
+ const probe = FOREIGN_PROBES[ns]
1456
+ let adjusted = flat
1457
+ // the name goes into markup, so it is checked rather than trusted - and a
1458
+ // name the table could adjust is plain letters by construction. No DOMParser
1459
+ // (a non-browser host) falls back to the name as written, which is what
1460
+ // shipped before this existed
1461
+ if (probe !== undefined && typeof DOMParser !== "undefined" && /^[a-z][a-z0-9]*$/.test(flat)) {
1462
+ const [wrapper, tag] = probe
1463
+ const doc = new DOMParser().parseFromString(`<${wrapper}><${tag} ${flat}="x"/></${wrapper}>`, "text/html")
1464
+ adjusted = doc.querySelector(tag)?.getAttributeNames().find(name => name.toLowerCase() === flat) ?? flat
1465
+ }
1466
+ adjustedNames.set(key, adjusted)
1467
+ return adjusted
1468
+ }
1469
+
1470
+ // `name` is what the rewrite left: kebab, whichever way the author spelled it.
1471
+ // Both spellings converge on purpose, so this has to be a pure function of the
1472
+ // kebab name and the namespace - which makes a collision the only thing that
1473
+ // could break it, a real kebab attribute whose de-dashed form the table claims.
1474
+ // Measured over 58 dashed SVG names (every presentation attribute, plus data-*
1475
+ // and aria-*): none collides
1476
+ const foreignAttrName = (el: Element, name: string): string => {
1477
+ const ns = el.namespaceURI
1478
+ if (ns === null || ns === HTML_NS) return name
1479
+ // no shortcut for a name without a dash: `:viewbox` is a spelling somebody
1480
+ // writes, the parser adjusts it written out, and skipping the lookup left the
1481
+ // bound form dead where the static one worked. What it costs is a memoised
1482
+ // Map hit per foreign binding - 83 undashed names measured, none claimed
1483
+ const flat = name.replace(/-/g, "").toLowerCase()
1484
+ const adjusted = adjustedName(ns, flat)
1485
+ // unchanged means the parser does not claim this name, so the author wrote a
1486
+ // real kebab attribute (`stroke-width`) and it stays exactly as written
1487
+ return adjusted === flat ? name : adjusted
1488
+ }
1489
+
1197
1490
  const applyAttr = (el: Element, name: string, value: any) => {
1198
1491
  const boolean = BOOLEAN_ATTRS.has(name)
1199
1492
  if (boolean ? !value : value == null) el.removeAttribute(name)
1200
1493
  else el.setAttribute(name, boolean ? "" : String(value))
1201
1494
  }
1202
1495
 
1203
- // renders a single element node: static attrs, @event listeners, a reactive
1204
- // :attrs object, and its content - :text/:html override the element's own
1496
+ // renders a single element node: static attrs, @event listeners, `:name`
1497
+ // attribute bindings, and its content - :text/:html override the element's own
1205
1498
  // children with a reactive textContent/innerHTML, otherwise children render
1206
1499
  // normally. :if/:elseif/:else/:each are handled by renderNodes, which decides
1207
1500
  // *whether*/*how many times* a node is rendered before calling this. Tags
@@ -1221,22 +1514,31 @@ const applyAttr = (el: Element, name: string, value: any) => {
1221
1514
  //
1222
1515
  // Worth -20 to -49% of create1k depending on how much fixed structure a row
1223
1516
  // 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.
1517
+ // the caveats, in RECORD/2026-08-24.clone-skeletons-measured.md.
1225
1518
  //
1226
1519
  // Two rules keep this from becoming the bug it could be:
1227
1520
  //
1228
1521
  // 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.
1522
+ // what a skeleton knows how to fill; every other attribute makes the subtree
1523
+ // unplannable. So a directive added to renderNode later is *slower* until
1524
+ // somebody teaches it here - never silently mis-rendered, which is the
1525
+ // failure a second render path invites.
1526
+ //
1527
+ // The allowlist is where the one divergence found so far came from, and it
1528
+ // came from *widening* rather than from renderNode growing: `:model` is the
1529
+ // single directive renderNode treats specially that CONTROL_ATTRS does not
1530
+ // name, so it slipped through the generic `:<name>` clause. Adding to this
1531
+ // list is the dangerous edit in this file - see
1532
+ // RECORD/2026-08-24.more-holes-in-the-cloner.md.
1233
1533
  // 2. **The interpreted path stays the fallback for everything else**, including
1234
1534
  // every tag that could still turn into a component. The upgrade watch and
1235
1535
  // the unresolved-component throw are not reimplemented here; they are never
1236
1536
  // reached from here.
1237
1537
  //
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.
1538
+ // tests/skeleton.test.ts renders a corpus both ways and diffs the DOM *and the
1539
+ // order the bindings register in*, which is what makes rule 1 enforceable
1540
+ // rather than a promise. The order axis is not decoration: :value on a <select>
1541
+ // has to run after its <option>s are bound, and no DOM diff can see that.
1240
1542
  // ---------------------------------------------------------------------------
1241
1543
 
1242
1544
  // Flipping this must never change what renders, only how - which is what
@@ -1246,7 +1548,7 @@ const applyAttr = (el: Element, name: string, value: any) => {
1246
1548
  // rebuild: a page that renders wrong is a bug report either way, but one whose
1247
1549
  // reporter can say "it goes away with cloning off" is a bug report that names
1248
1550
  // the file
1249
- const debugFlags: DebugFlags = { cloneSkeletons: true }
1551
+ const debugFlags: DebugFlags = { cloneSkeletons: true, scopedNames: true }
1250
1552
 
1251
1553
  // What `Component79.debug()` can switch. One flag today; the shape is an object
1252
1554
  // so the next one does not change the call
@@ -1255,9 +1557,28 @@ export type DebugFlags = {
1255
1557
  // instead of walking the AST for every instance of it. Off means every
1256
1558
  // element goes through renderNode, exactly as before this existed
1257
1559
  cloneSkeletons: boolean
1560
+
1561
+ // resolve an expression's free names with a `const` prologue instead of
1562
+ // `with ($scope)`. Off means every expression is compiled the way it always
1563
+ // was, which is what makes the two forms comparable on one build - both in
1564
+ // tests/expressions.test.ts and in `npm run benchmark:ab -- --flags`
1565
+ scopedNames: boolean
1258
1566
  }
1259
1567
 
1260
- // The four things a hole can be, in the order renderNode registers them.
1568
+ // the control attributes a skeleton knows how to fill. The rest of
1569
+ // CONTROL_ATTRS stays rejected on purpose: :if/:elseif/:else/:each/:key change
1570
+ // the shape rather than filling a hole, :with changes the scope its subtree
1571
+ // evaluates in, and :props belongs to a component tag.
1572
+ //
1573
+ // `:html` is here and `:html.allowed` is not, which is the whole of why the
1574
+ // warning renderNode emits for an `:html.allowed` with no `:html` is not this
1575
+ // list's problem: `:html.allowed` is rejected by the two clauses below (a
1576
+ // control attr, and a dotted name), so an element carrying one is never planned
1577
+ // and renderNode stays the only place that warning can fire from - once per
1578
+ // render, as before. See RECORD/2026-08-25.html-in-the-cloner.md
1579
+ const PLANNABLE_CONTROL_ATTRS = new Set([":text", ":html", ":value", ":checked", ":selected"])
1580
+
1581
+ // What a hole can be, in the order renderNode registers them.
1261
1582
  // A `:` attribute with a dot in it is rejected wholesale except `:class.`:
1262
1583
  // `:model.`, `:props.`, `:slot.` and `:html.allowed` all live in that shape, and
1263
1584
  // so would the next directive family somebody invents
@@ -1265,17 +1586,35 @@ const plannableAttr = (name: string): boolean => {
1265
1586
  if (name.startsWith("@")) return true
1266
1587
  if (name === ":class") return true
1267
1588
  if (name.startsWith(":class.")) return true
1589
+ if (PLANNABLE_CONTROL_ATTRS.has(name)) return true
1268
1590
  if (!name.startsWith(":")) return name !== COMPONENT_TAG_ATTR // a static attribute
1591
+ // `:model` is the one directive renderNode treats specially that CONTROL_ATTRS
1592
+ // does not name, so the clause below would let it through as a generic
1593
+ // `:<name>` binding and the skeleton would write `model="..."` where the
1594
+ // interpreted path warns and writes nothing (`:model` binds component tags
1595
+ // only). `:model.<name>` is caught by the dot; the bare form needs saying
1596
+ if (name === ":model") return false
1269
1597
  return !isControlAttr(name) && !name.includes(".")
1270
1598
  }
1271
1599
 
1272
1600
  const plannableNode = (node: TemplateNode): boolean => {
1273
1601
  if (node.component || node.tag.includes("-")) return false
1274
1602
  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
1603
+ // an unknown tag stays interpreted. It can no longer become a component - the
1604
+ // upgrade watch narrowed to a stamped or dashed tag, both of which are
1605
+ // rejected above - so this is a conservative rule rather than a load-bearing
1606
+ // one: an undashed name the parser does not know is a typo or a custom
1607
+ // element that can never register, and nothing is measured to be gained by
1608
+ // cloning it. A *foreign* element skips the test rather than failing it:
1609
+ // <circle> is an HTMLUnknownElement when built with createElement, which is
1610
+ // exactly the mistake this used to make
1611
+ if (node.ns === undefined && document.createElement(node.tag) instanceof HTMLUnknownElement) return false
1278
1612
  for (const key in node.attrs) if (!plannableAttr(key)) return false
1613
+ // an element with :text or :html has no children on either path (see
1614
+ // buildSkeleton), so what the source wrote inside it cannot make the subtree
1615
+ // unplannable - a component tag under a :text is markup nobody renders, not
1616
+ // markup the clone path would get wrong
1617
+ if (node.attrs[":text"] !== undefined || node.attrs[":html"] !== undefined) return true
1279
1618
  return node.children.every(child => typeof child === "string" || plannableNode(child))
1280
1619
  }
1281
1620
 
@@ -1288,15 +1627,19 @@ type SkeletonOp =
1288
1627
  | { kind: "event"; path: number[]; attr: string; expr: string }
1289
1628
  | { kind: "attr"; path: number[]; name: string; expr: string }
1290
1629
  | { kind: "class"; path: number[]; classExpr?: string; toggles: [string, string][] | null; staticClasses: Set<string> }
1630
+ | { kind: "textContent"; path: number[]; expr: string }
1631
+ | { kind: "html"; path: number[]; expr: string }
1632
+ | { kind: "value"; path: number[]; expr: string }
1633
+ | { kind: "checked"; path: number[]; expr: string }
1634
+ | { kind: "selected"; path: number[]; expr: string }
1291
1635
 
1292
- type SkeletonPlan = { skeleton: Element; ops: SkeletonOp[]; tags: string[] }
1636
+ type SkeletonPlan = { skeleton: Element; ops: SkeletonOp[] }
1293
1637
 
1294
1638
  // mirrors renderNode's own order: the attribute walk (events and attribute
1295
1639
  // bindings as they appear), then :class, then the children. Effects run in
1296
1640
  // 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)
1641
+ const buildSkeleton = (node: TemplateNode, path: number[], ops: SkeletonOp[]): Element => {
1642
+ const el = createFor(node)
1300
1643
 
1301
1644
  let classExpr: string | undefined
1302
1645
  let toggles: [string, string][] | null = null
@@ -1305,16 +1648,31 @@ const buildSkeleton = (node: TemplateNode, path: number[], ops: SkeletonOp[], ta
1305
1648
  if (key.startsWith("@")) ops.push({ kind: "event", path, attr: key, expr: value })
1306
1649
  else if (key === ":class") classExpr = value
1307
1650
  else if (key.startsWith(":class.")) (toggles ??= []).push([key.slice(":class.".length), value])
1651
+ // a directive of its own, bound below - the same skip renderNode's walk
1652
+ // makes, and for the same reason: without it :text would be written out as
1653
+ // an attribute named `text`. :class/:class. are control attrs too and are
1654
+ // already caught above
1655
+ else if (isControlAttr(key)) { /* handled after the walk */ }
1308
1656
  else if (key.startsWith(":")) {
1309
1657
  const name = key.slice(1)
1310
- ops.push({ kind: "attr", path, name, expr: value || kebabToCamel(name) })
1658
+ // resolved here rather than per instance: the skeleton's element already
1659
+ // exists, so its namespace is known once per definition
1660
+ ops.push({ kind: "attr", path, name: foreignAttrName(el, name), expr: value || kebabToCamel(name) })
1311
1661
  } else el.setAttribute(key, value)
1312
1662
  }
1313
1663
  if (classExpr !== undefined || toggles) {
1314
1664
  ops.push({ kind: "class", path, classExpr, toggles, staticClasses: new Set(classNames(node.attrs.class ?? "")) })
1315
1665
  }
1316
1666
 
1317
- node.children.forEach((child, index) => {
1667
+ // :text and :html replace the element's content, and renderNode never renders
1668
+ // the children of an element carrying either. So the skeleton gives it none
1669
+ // either: both are leaves on both paths, whatever the source wrote inside
1670
+ // them. The else-if order is renderNode's - :text wins when both are written
1671
+ const textExpr = node.attrs[":text"]
1672
+ const htmlExpr = node.attrs[":html"]
1673
+ if (textExpr !== undefined) ops.push({ kind: "textContent", path, expr: textExpr })
1674
+ else if (htmlExpr !== undefined) ops.push({ kind: "html", path, expr: htmlExpr })
1675
+ else node.children.forEach((child, index) => {
1318
1676
  if (typeof child === "string") {
1319
1677
  // an interpolated text node is a hole; the skeleton holds the empty node
1320
1678
  // it will be written into, so the child indices match either way
@@ -1324,9 +1682,21 @@ const buildSkeleton = (node: TemplateNode, path: number[], ops: SkeletonOp[], ta
1324
1682
  } else el.appendChild(document.createTextNode(child))
1325
1683
  return
1326
1684
  }
1327
- el.appendChild(buildSkeleton(child, [...path, index], ops, tags))
1685
+ el.appendChild(buildSkeleton(child, [...path, index], ops))
1328
1686
  })
1329
1687
 
1688
+ // after the children, because renderNode registers them there and for its
1689
+ // reason: :value on a <select> can only pick an <option> that already exists.
1690
+ // `ops` is flat and in registration order, and this is the recursive call's
1691
+ // tail, so a parent's form-state ops land after every op of every descendant -
1692
+ // which is exactly what renderNode's own recursion does
1693
+ const valueExpr = node.attrs[":value"]
1694
+ if (valueExpr !== undefined) ops.push({ kind: "value", path, expr: valueExpr })
1695
+ const checkedExpr = node.attrs[":checked"]
1696
+ if (checkedExpr !== undefined) ops.push({ kind: "checked", path, expr: checkedExpr })
1697
+ const selectedExpr = node.attrs[":selected"]
1698
+ if (selectedExpr !== undefined) ops.push({ kind: "selected", path, expr: selectedExpr })
1699
+
1330
1700
  return el
1331
1701
  }
1332
1702
 
@@ -1336,21 +1706,51 @@ const buildSkeleton = (node: TemplateNode, path: number[], ops: SkeletonOp[], ta
1336
1706
  // and a regression on a row whose only fragments are that small
1337
1707
  const MIN_SKELETON_ELEMENTS = 3
1338
1708
 
1709
+ // children under a :text or an :html are not built by either path, so they are
1710
+ // not elements this threshold should be counting - a <p :text="v"> with two
1711
+ // <span>s written inside it is one element's worth of cloning, not three
1339
1712
  const countElements = (node: TemplateNode): number =>
1340
- 1 + node.children.reduce((total, child) => total + (typeof child === "string" ? 0 : countElements(child)), 0)
1713
+ node.attrs[":text"] !== undefined || node.attrs[":html"] !== undefined
1714
+ ? 1
1715
+ : 1 + node.children.reduce((total, child) => total + (typeof child === "string" ? 0 : countElements(child)), 0)
1341
1716
 
1342
1717
  const skeletonPlans = new WeakMap<TemplateNode, SkeletonPlan | null>()
1343
1718
 
1719
+ // A definition rendered ONCE pays for a plan it never reuses: +23% at
1720
+ // MIN_SKELETON_ELEMENTS, +11.5% at six elements, measured in
1721
+ // RECORD/2026-08-24.one-shot-render-measured.md. So the plan is built on the
1722
+ // SECOND render, not the first - a one-shot definition never builds one at all,
1723
+ // and a :each of 1,000 rows interprets row 1 and clones the other 999.
1724
+ //
1725
+ // The element count could not answer this. It is a proxy for "will this be
1726
+ // rendered again", and raising it to protect the one-shot case would take a
1727
+ // 9-element list row - which amortizes beautifully - off the clone path. This
1728
+ // keys on the thing itself.
1729
+ //
1730
+ // renderEach calls renderNode per row and renderNode calls planOf, so the list
1731
+ // case needs no special handling; it falls out.
1732
+ //
1733
+ // The third state is a WeakSet rather than a sentinel in the map, so the map's
1734
+ // type keeps saying what it means: absent is "never seen", null is "examined,
1735
+ // not plannable"
1736
+ const seenOnce = new WeakSet<TemplateNode>()
1737
+
1344
1738
  const planOf = (node: TemplateNode): SkeletonPlan | null => {
1345
1739
  const cached = skeletonPlans.get(node)
1346
1740
  if (cached !== undefined) return cached
1347
1741
 
1742
+ // first sighting: interpret it, and decide nothing. Examining it here is the
1743
+ // cost the one-shot case was paying
1744
+ if (!seenOnce.has(node)) {
1745
+ seenOnce.add(node)
1746
+ return null
1747
+ }
1748
+
1348
1749
  let plan: SkeletonPlan | null = null
1349
1750
  if (plannableNode(node) && countElements(node) >= MIN_SKELETON_ELEMENTS) {
1350
1751
  const ops: SkeletonOp[] = []
1351
- const tags = new Set<string>()
1352
- const skeleton = buildSkeleton(node, [], ops, tags)
1353
- plan = { skeleton, ops, tags: Array.from(tags) }
1752
+ const skeleton = buildSkeleton(node, [], ops)
1753
+ plan = { skeleton, ops }
1354
1754
  }
1355
1755
  skeletonPlans.set(node, plan)
1356
1756
  return plan
@@ -1365,6 +1765,9 @@ const atPath = (root: Node, path: number[]): Node => {
1365
1765
  const renderFromSkeleton = (plan: SkeletonPlan, scope: Record<string, any>, fx: EffectScope): Node => {
1366
1766
  const root = plan.skeleton.cloneNode(true) as Element
1367
1767
 
1768
+ // ordered by how often a row actually carries the kind, not by when it was
1769
+ // added: this chain runs once per op per instance, so the four holes a
1770
+ // benchmark row is made of are matched before the five a form is
1368
1771
  for (const op of plan.ops) {
1369
1772
  const target = op.path.length === 0 ? root : atPath(root, op.path)
1370
1773
 
@@ -1381,7 +1784,7 @@ const renderFromSkeleton = (plan: SkeletonPlan, scope: Record<string, any>, fx:
1381
1784
  const el = target as Element
1382
1785
  const { name, expr } = op
1383
1786
  fx.effect(() => applyAttr(el, name, evalExpr(expr, scope)))
1384
- } else {
1787
+ } else if (op.kind === "class") {
1385
1788
  const el = target as Element
1386
1789
  const { classExpr, toggles, staticClasses } = op
1387
1790
  let bound: string[] = []
@@ -1396,6 +1799,54 @@ const renderFromSkeleton = (plan: SkeletonPlan, scope: Record<string, any>, fx:
1396
1799
  el.classList.add(...next)
1397
1800
  bound = next
1398
1801
  })
1802
+ } else if (op.kind === "textContent") {
1803
+ const el = target as Element
1804
+ const { expr } = op
1805
+ // compared before it lands, like the text node above: an unchanged write
1806
+ // still replaces the element's child text node, so a re-run that changed
1807
+ // nothing would hand every observer a new node
1808
+ fx.effect(() => {
1809
+ const text = String(evalExpr(expr, scope) ?? "")
1810
+ if (el.textContent !== text) el.textContent = text
1811
+ })
1812
+ } else if (op.kind === "html") {
1813
+ // renderNode's effect with its `allowUrl` arm removed, because an element
1814
+ // carrying :html.allowed is never planned - the attribute is rejected by
1815
+ // plannableAttr, which is what keeps that directive's warning in exactly
1816
+ // one place. RECORD/2026-08-25.html-in-the-cloner.md
1817
+ const el = target as Element
1818
+ const { expr } = op
1819
+ fx.effect(() => { el.innerHTML = sanitizeHTML(String(evalExpr(expr, scope) ?? "")) })
1820
+ } else if (op.kind === "value") {
1821
+ // the property, not the attribute, and skipping a write that would not
1822
+ // change it - renderNode's reasons apply here unchanged
1823
+ const el = target as HTMLInputElement
1824
+ const { expr } = op
1825
+ fx.effect(() => {
1826
+ const value = String(evalExpr(expr, scope) ?? "")
1827
+ if (el.value !== value) el.value = value
1828
+ })
1829
+ } else if (op.kind === "checked") {
1830
+ const el = target as HTMLInputElement
1831
+ const { expr } = op
1832
+ fx.effect(() => {
1833
+ const checked = !!evalExpr(expr, scope)
1834
+ if (el.checked !== checked) el.checked = checked
1835
+ })
1836
+ } else if (op.kind === "selected") {
1837
+ const el = target as HTMLOptionElement
1838
+ const { expr } = op
1839
+ fx.effect(() => {
1840
+ const selected = !!evalExpr(expr, scope)
1841
+ if (el.selected !== selected) el.selected = selected
1842
+ })
1843
+ } else {
1844
+ // every kind is named above, so this is unreachable - and the assignment
1845
+ // is what makes the compiler say so. A kind added to SkeletonOp and
1846
+ // forgotten here is the exact failure this whole file is arranged to
1847
+ // prevent, and it is cheaper to catch it in tsc than in the corpus
1848
+ const unhandled: never = op
1849
+ void unhandled
1399
1850
  }
1400
1851
  }
1401
1852
 
@@ -1403,7 +1854,7 @@ const renderFromSkeleton = (plan: SkeletonPlan, scope: Record<string, any>, fx:
1403
1854
  }
1404
1855
 
1405
1856
  const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
1406
- // :with applies to the element's own bindings (@events, :attrs) and its
1857
+ // :with applies to the element's own bindings (@events, :name) and its
1407
1858
  // whole subtree. On a :each element the item scope is already in place, so
1408
1859
  // :with="item" works
1409
1860
  const withExpr = node.attrs[":with"]
@@ -1415,63 +1866,78 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1415
1866
  if (isSlotTag(node.tag)) return renderSlot(node, scope, fx, shadow)
1416
1867
  if (node.tag === "template" && slotAttrOf(node) !== undefined) return misplacedSlotContent(node)
1417
1868
 
1418
- const componentKey = findComponentKey(scope, node.tag)
1869
+ const componentKey = componentKeyOf(node, scope)
1419
1870
  if (componentKey) return renderNestedComponent(componentKey, node, scope, fx, shadow)
1420
1871
 
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
1872
+ // A planned subtree is cloned, and no scope key can take one of its tags away
1873
+ // any more: a plannable tag is undashed, unstamped and a name the HTML parser
1874
+ // knows, which is exactly the set componentKeyOf answers "no" to. This used
1875
+ // to walk plan.tags calling findComponentKey for each, because a variable
1876
+ // named `Td` made every <td> under it a component and `Map`, `Data`, `Table`,
1877
+ // `Form` and `Label` are all HTML tags somebody might name a component after.
1878
+ // The rename is what retires that check - see RECORD/2026-08-25.component-tag-prefix.md
1428
1879
  if (debugFlags.cloneSkeletons) {
1429
1880
  const plan = planOf(node)
1430
- if (plan && !plan.tags.some(tag => findComponentKey(scope, tag))) return renderFromSkeleton(plan, scope, fx)
1881
+ if (plan) return renderFromSkeleton(plan, scope, fx)
1431
1882
  }
1432
1883
 
1433
- const el = document.createElement(node.tag)
1884
+ const el = createFor(node)
1434
1885
 
1435
- // <UserCrad /> - written as a component (node.component), resolving to no
1436
- // component, and not an element either. Nothing else on the page can supply
1437
- // the name once every script has settled, so this renders no markup, no
1438
- // styles, no children and no script, forever, and says so by throwing rather
1439
- // than leaving a hole where a region of the page was meant to be.
1886
+ // <UserCrad /> - written as a component (node.component) and resolving to
1887
+ // none. Nothing else on the page can supply the name once every script has
1888
+ // settled, so this renders no markup, no styles, no children and no script,
1889
+ // forever, and says so by throwing rather than leaving a hole where a region
1890
+ // of the page was meant to be.
1440
1891
  //
1441
- // All three conditions carry weight. Without the capitalization <lable> and
1442
- // <svg> would be fatal (createElement builds SVG names in the HTML namespace,
1443
- // so an <svg> is an HTMLUnknownElement too); without the element check <DIV>
1444
- // would be, though it renders a perfectly good div; and without the pending
1445
- // count a factory that awaits $mounted() before returning its components
1446
- // could never render one, which is exactly what the watcher below is for.
1892
+ // Two conditions now, where there were three. The capitalization still
1893
+ // carries the claim - <lable>, <svg> and <my-widget> are not judged - but the
1894
+ // element check is gone with the rename: a stamped tag is a c79-* one, so it
1895
+ // is never the element it was named after, and <DIV> is judged like any other
1896
+ // capitalized tag. It is a component claim that resolves to nothing, and it
1897
+ // says so instead of quietly rendering a div.
1447
1898
  //
1448
- // An *absent* count is a fourth case, and it is not zero: renderComponent()
1449
- // renders a template against a store somebody else owns and assembles, so
1450
- // nothing there has finished and nothing says a key can't still be written
1451
- // in. The claim being tested is a component's claim about its own scripts,
1452
- // and where none was made the tag waits for the upgrade, as it always has.
1899
+ // The pending count still carries the rest: without it a factory that awaits
1900
+ // $mounted() before returning its components could never render one, which is
1901
+ // exactly what the watcher below is for. An *absent* count is its own case,
1902
+ // and it is not zero: renderComponent() renders a template against a store
1903
+ // somebody else owns and assembles, so nothing there has finished and nothing
1904
+ // says a key can't still be written in. The claim being tested is a
1905
+ // component's claim about its own scripts, and where none was made the tag
1906
+ // waits for the upgrade, as it always has.
1453
1907
  //
1454
1908
  // Written without a local for the count because renderNode is on the stack
1455
1909
  // for the whole of the subtree below it, so a slot here is a slot per level
1456
1910
  // of a component nested inside itself - see renderWith
1457
- if (node.component && el instanceof HTMLUnknownElement && ((scope as any)[PENDING_SCRIPTS] as PendingScripts | undefined)?.count === 0) {
1911
+ if (node.component !== undefined && ((scope as any)[PENDING_SCRIPTS] as PendingScripts | undefined)?.count === 0) {
1458
1912
  throw unresolvedComponent(node.component, scope)
1459
1913
  }
1460
1914
 
1461
- // a tag that isn't standard HTML but has no matching scope key *yet* may be
1462
- // a component that arrives later (e.g. an async factory script exposing an
1463
- // imported component after `await`). Watch for the key: the effect tracks
1464
- // no deps, so it only re-runs on the store's new-key sweep, and swaps the
1465
- // placeholder element for the component exactly once
1466
- // dashes included, because findComponentKey matches them case-insensitively
1467
- // with dashes stripped: <drop-area> resolves DropArea, so a dashed tag is a
1468
- // possible component too, not only a custom element
1469
- const mayUpgrade = el instanceof HTMLUnknownElement || node.tag.includes("-")
1915
+ // a tag that may still name a component whose key has not arrived yet (an
1916
+ // async factory script exposing an imported component after `await`). Watch
1917
+ // for the key: the effect tracks no deps, so it only re-runs on the store's
1918
+ // new-key sweep, and swaps the placeholder element for the component exactly
1919
+ // once.
1920
+ //
1921
+ // The two spellings componentKeyOf accepts, and no others - a capitalized tag
1922
+ // (which is a c79-* element here, since it resolved to nothing) and a dashed
1923
+ // one, because <drop-area> resolves DropArea. An unknown *undashed* lowercase
1924
+ // tag no longer waits: <mychip> is a typo or a custom element that never
1925
+ // registered, not a component that has yet to arrive.
1926
+ //
1927
+ // Never for a foreign element, and the dash clause is why that has to be said
1928
+ // out loud: <annotation-xml> is the one MathML or SVG tag with a hyphen in it,
1929
+ // and without this it is a real element treated as a custom one - its
1930
+ // : bindings held verbatim as parameters for a component, and the element
1931
+ // itself replaced the moment something named AnnotationXml enters scope. The
1932
+ // namespace is the parser's answer to "where was this written", so ns !== undefined
1933
+ // means inside a <math> or an <svg>, where no component can live - the same
1934
+ // argument plannableNode makes. See RECORD/2026-08-24.mathml.md
1935
+ const mayUpgrade = node.ns === undefined && (node.component !== undefined || node.tag.includes("-"))
1470
1936
  if (mayUpgrade) {
1471
1937
  let upgraded = false
1472
1938
  fx.effect(() => {
1473
1939
  if (upgraded) return
1474
- const key = findComponentKey(scope, node.tag)
1940
+ const key = componentKeyOf(node, scope)
1475
1941
  if (!key) return
1476
1942
  upgraded = true
1477
1943
  const replacement = renderNestedComponent(key, node, scope, fx, shadow)
@@ -1488,23 +1954,23 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1488
1954
  // entries form allocates one array of pairs plus one two-element array per
1489
1955
  // attribute *per instance*. Nothing here reads the pairs as pairs, so the
1490
1956
  // allocation buys nothing and the garbage it makes is measurable - see
1491
- // TODOS/2026-08-23.where-the-create-time-goes.md
1957
+ // RECORD/2026-08-23.where-the-create-time-goes.md
1492
1958
  for (const key in node.attrs) {
1493
1959
  const value = node.attrs[key]
1494
1960
  if (key.startsWith("@")) bindEvent(el, key, value, scope)
1495
1961
  else if (key === ":model" || key.startsWith(":model.")) {
1496
- // :model binds component tags only (see TODOS/2026-07-15.model-directive.md;
1962
+ // :model binds component tags only (see RECORD/2026-07-15.model-directive.md;
1497
1963
  // the native-element form is parked there). Warn on a real element, but
1498
1964
  // not on a tag that may still upgrade into a component - the upgrade
1499
1965
  // re-renders through renderNestedComponent, models and all
1500
1966
  if (!mayUpgrade) {
1501
- console.warn(`jq79: ${key} on <${node.tag}> does nothing - :model binds component tags only (for now)`)
1967
+ console.warn(`jq79: ${key} on <${tagLabel(node)}> does nothing - :model binds component tags only (for now)`)
1502
1968
  }
1503
1969
  } else if (isControlAttr(key)) {
1504
1970
  // a directive of its own, bound further down (or by renderNodes)
1505
1971
  } else if (key.startsWith(":")) {
1506
1972
  // :name="expr" binds that one attribute, reactively - the single-key
1507
- // case :attrs="{ name: expr }" was carrying. `:name` alone is shorthand
1973
+ // case :attrs="{ name: expr }" used to carry. `:name` alone is shorthand
1508
1974
  // for `:name="name"`, like props and :model.<name>, and the shorthand
1509
1975
  // reads the camelCase variable while the attribute keeps its written
1510
1976
  // (kebab) name: `:aria-expanded` binds `ariaExpanded`, because
@@ -1513,28 +1979,19 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1513
1979
  // On a tag that may still upgrade this is a *parameter*, not an
1514
1980
  // attribute: leave it written verbatim, as before, so the upgrade's
1515
1981
  // renderNestedComponent still finds it. A component tag has no single
1516
- // root for an attribute to land on anyway (TODOS/2026-07-15.class-directive.md)
1982
+ // root for an attribute to land on anyway (RECORD/2026-07-15.class-directive.md)
1517
1983
  if (mayUpgrade) el.setAttribute(key, value)
1518
1984
  else {
1519
- const name = key.slice(1)
1520
- const expr = value || kebabToCamel(name)
1985
+ const written = key.slice(1)
1986
+ const expr = value || kebabToCamel(written)
1987
+ // outside the effect: the name a binding writes is fixed by the source
1988
+ // and the element, and neither moves between runs
1989
+ const name = foreignAttrName(el, written)
1521
1990
  fx.effect(() => applyAttr(el, name, evalExpr(expr, scope)))
1522
1991
  }
1523
1992
  } else el.setAttribute(key, value)
1524
1993
  }
1525
1994
 
1526
- const bindExpr = node.attrs[":attrs"]
1527
- if (bindExpr !== undefined) {
1528
- let boundKeys: string[] = []
1529
-
1530
- fx.effect(() => {
1531
- boundKeys.forEach(key => el.removeAttribute(key))
1532
- const bound = evalExpr(bindExpr, scope)
1533
- boundKeys = bound && typeof bound === "object" ? Object.keys(bound) : []
1534
- boundKeys.forEach(key => applyAttr(el, key, bound[key]))
1535
- })
1536
- }
1537
-
1538
1995
  // :class="expr" adds classes on top of the static `class` attribute, and
1539
1996
  // :class.<name>="expr" is the single-flag shorthand for `{ <name>: expr }`
1540
1997
  // (the name routed through classNames, so an empty `:class.` can't reach
@@ -1584,7 +2041,10 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1584
2041
  console.warn("jq79: :html.allowed without :html on the same element does nothing")
1585
2042
  }
1586
2043
  if (textExpr !== undefined) {
1587
- fx.effect(() => { el.textContent = String(evalExpr(textExpr, scope) ?? "") })
2044
+ fx.effect(() => {
2045
+ const text = String(evalExpr(textExpr, scope) ?? "")
2046
+ if (el.textContent !== text) el.textContent = text
2047
+ })
1588
2048
  } else if (htmlExpr !== undefined) {
1589
2049
  fx.effect(() => {
1590
2050
  const options = allowedExpr !== undefined ? { allowUrl: normalizeAllowUrl(evalExpr(allowedExpr, scope)) } : undefined
@@ -1603,8 +2063,8 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1603
2063
 
1604
2064
  // :value / :checked / :selected write the DOM *property*, not the
1605
2065
  // attribute - the attribute is only a form control's default, and detaches
1606
- // the moment the user interacts (which is why :attrs="{ value }" stops
1607
- // driving a typed-in input). One-way, store -> DOM: the way back stays an
2066
+ // the moment the user interacts (which is why writing the value ATTRIBUTE
2067
+ // stops driving a typed-in input). One-way, store -> DOM: the way back stays an
1608
2068
  // explicit @input/@change. :value skips the write when the property
1609
2069
  // already holds the string, so an unrelated re-run can't move the caret of
1610
2070
  // the input the user is typing into. Registered after the children render:
@@ -1619,14 +2079,20 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1619
2079
  // written out rather than looped over a literal array: the loop allocated the
1620
2080
  // array *and* its closure for every element rendered - 8,000 of each per
1621
2081
  // 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)
2082
+ // walk above is a `for...in` (RECORD/2026-08-23.where-the-create-time-goes.md)
1623
2083
  const checkedExpr = node.attrs[":checked"]
1624
2084
  if (checkedExpr !== undefined) {
1625
- fx.effect(() => { (el as HTMLInputElement).checked = !!evalExpr(checkedExpr, scope) })
2085
+ fx.effect(() => {
2086
+ const checked = !!evalExpr(checkedExpr, scope)
2087
+ if ((el as HTMLInputElement).checked !== checked) (el as HTMLInputElement).checked = checked
2088
+ })
1626
2089
  }
1627
2090
  const selectedExpr = node.attrs[":selected"]
1628
2091
  if (selectedExpr !== undefined) {
1629
- fx.effect(() => { (el as HTMLOptionElement).selected = !!evalExpr(selectedExpr, scope) })
2092
+ fx.effect(() => {
2093
+ const selected = !!evalExpr(selectedExpr, scope)
2094
+ if ((el as HTMLOptionElement).selected !== selected) (el as HTMLOptionElement).selected = selected
2095
+ })
1630
2096
  }
1631
2097
 
1632
2098
  return el
@@ -1823,7 +2289,7 @@ const eachPlanOf = (node: TemplateNode): EachPlan | null => {
1823
2289
  // one key per row, so a 1,000-row list paid 1,000 `with`-scoped calls through
1824
2290
  // the store proxy to discover that nothing had changed: most of the 37% of a
1825
2291
  // pass that goes on evaluating expressions
1826
- // (TODOS/2026-08-23.where-the-list-operations-go.md). Anything else - a call,
2292
+ // (RECORD/2026-08-23.where-the-list-operations-go.md). Anything else - a call,
1827
2293
  // an index, a deeper path, a name from the outer scope - still goes through
1828
2294
  // evalExpr, and so does a non-object item, which keeps every diagnostic a
1829
2295
  // property read of a null row would have raised
@@ -1842,7 +2308,7 @@ const eachPlanOf = (node: TemplateNode): EachPlan | null => {
1842
2308
  // it was 9ms of removeRow's 25ms. Decided once, from the template, rather
1843
2309
  // than per row per render. The item name is deliberately not in this list:
1844
2310
  // nearly every binding reads it, and it is not what goes stale.
1845
- // See TODOS/2026-08-23.positional-refresh.md
2311
+ // See RECORD/2026-08-23.positional-refresh.md
1846
2312
  const positionalNames = ["$index", ...(atName ? [atName] : [])]
1847
2313
  const readsPosition = mentionsAny(itemNode, positionalNames)
1848
2314
 
@@ -1864,7 +2330,7 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
1864
2330
  // one key per row, so a 1,000-row list paid 1,000 `with`-scoped calls through
1865
2331
  // the store proxy to discover that nothing had changed: most of the 37% of a
1866
2332
  // pass that goes on evaluating expressions
1867
- // (TODOS/2026-08-23.where-the-list-operations-go.md). Anything else - a call,
2333
+ // (RECORD/2026-08-23.where-the-list-operations-go.md). Anything else - a call,
1868
2334
  // an index, a deeper path, a name from the outer scope - still goes through
1869
2335
  // evalExpr, and so does a non-object item, which keeps every diagnostic a
1870
2336
  // property read of a null row would have raised
@@ -2063,7 +2529,7 @@ const warnChainAttrs = (node: TemplateNode) => {
2063
2529
  // allocated only on the way to a warning, never on the path that finds none
2064
2530
  const present = [hasIf ? ":if" : null, hasElseif ? ":elseif" : null, hasElse ? ":else" : null].filter(Boolean)
2065
2531
  console.warn(
2066
- `jq79: ${present.join(" and ")} on the same <${node.tag}> - only ${present[0]} applies; ` +
2532
+ `jq79: ${present.join(" and ")} on the same <${tagLabel(node)}> - only ${present[0]} applies; ` +
2067
2533
  "the branches of a chain are sibling elements, one directive each"
2068
2534
  )
2069
2535
  }
@@ -2076,13 +2542,107 @@ const warnOrphanBranch = (node: TemplateNode, afterClosedChain: boolean) => {
2076
2542
  const attr = ":elseif" in node.attrs ? ":elseif" : ":else"
2077
2543
  console.warn(
2078
2544
  afterClosedChain
2079
- ? `jq79: a second ${attr} on <${node.tag}> - the chain before it already ended with :else, ` +
2545
+ ? `jq79: a second ${attr} on <${tagLabel(node)}> - the chain before it already ended with :else, ` +
2080
2546
  "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. ` +
2547
+ : `jq79: ${attr} on <${tagLabel(node)}> continues no :if - it renders unconditionally. ` +
2082
2548
  "A chain is :if, then :elseif, then :else, on adjacent siblings: anything but whitespace between them breaks it"
2083
2549
  )
2084
2550
  }
2085
2551
 
2552
+ // A directive on a *nested* <template> says something it does not mean, in two
2553
+ // different ways, and neither is a rendering bug to fix.
2554
+ //
2555
+ // Bare, the element is native and inert: its children live in .content and
2556
+ // never reach the page, so `<template :if="show"><li>uno</li></template>`
2557
+ // decides whether an empty <template> is inserted and shows nothing in either
2558
+ // state. With a :slot attribute the directive is not merely invisible but
2559
+ // *dropped* - partitionSlots finds the node by slotAttrOf and takes its
2560
+ // children as the slot's content, and nothing on that path looks at :if, so a
2561
+ // :slot filler renders whatever its condition says.
2562
+ //
2563
+ // The directive is the discriminant, and nothing else is: a bare nested
2564
+ // <template> is the legitimate native use and must stay silent. A top-level one
2565
+ // never arrives here at all - parseComponentString lifts declarations out
2566
+ // before componentPartsFrom runs - so the position needs no exclusion.
2567
+ // See RECORD/2026-08-24.template-directive-warning.md
2568
+ const TEMPLATE_DIRECTIVES = [":if", ":elseif", ":else", ":each"]
2569
+
2570
+ const warnTemplateDirective = (node: TemplateNode, parent: TemplateNode | undefined) => {
2571
+ // an HTML <template> only: inside an <svg> the tag is a plain namespaced
2572
+ // element with ordinary children, so they render and the message below - that
2573
+ // they live in a .content nobody reads - would be false
2574
+ if (node.tag !== "template" || node.ns !== undefined) return
2575
+ const directive = TEMPLATE_DIRECTIVES.find(attr => attr in node.attrs)
2576
+ if (directive === undefined) return
2577
+
2578
+ if (slotAttrOf(node) !== undefined) {
2579
+ // A <template :slot> fills a slot only as a direct child of a component
2580
+ // tag - anywhere else it is misplaced, and misplacedSlotContent says so in
2581
+ // its own words. There the directive is NOT dropped: renderNodes groups the
2582
+ // :if into a chain like any other element's, so a false branch renders
2583
+ // nothing and the message below would be wrong twice over. Say nothing and
2584
+ // leave the position to the diagnostic that is about the position
2585
+ if (parent?.component === undefined) return
2586
+ console.warn(
2587
+ `jq79: ${directive} on <template ${slotAttrOf(node)}> is ignored - a slot is filled with its children as written. ` +
2588
+ `Put ${directive} on the elements inside it, or on the component's tag`
2589
+ )
2590
+ return
2591
+ }
2592
+ console.warn(
2593
+ `jq79: ${directive} on a nested <template> shows nothing - a <template>'s children live in its .content ` +
2594
+ `and never reach the page. Put ${directive} on the elements themselves`
2595
+ )
2596
+ }
2597
+
2598
+ // The directive names a `:` attribute can be a misspelling OF. Derived from
2599
+ // CONTROL_ATTRS rather than written out, plus the two families that are not in
2600
+ // it (`:model`, `:slot`) - so there is no second list to keep in step.
2601
+ const DIRECTIVE_NAMES = [...CONTROL_ATTRS, ":model", ":slot"].map(attr => attr.slice(1))
2602
+
2603
+ // `:iff="ready"` renders the element unconditionally and writes iff="true", and
2604
+ // until now said nothing - because since RECORD/2026-08-07.attribute-directive.md
2605
+ // an unrecognized `:name` is not an error at all: it BINDS that attribute, which
2606
+ // is what makes `:src`, `:disabled` and `:aria-expanded` work. So there is no
2607
+ // "unknown directive" to report in general, and the only typo worth a word is
2608
+ // one that starts with a directive's own name: `:iff`, `:eachh`, `:classs`.
2609
+ //
2610
+ // A prefix, deliberately, and not an edit distance: the rule is derived from the
2611
+ // directive list itself, so nothing here is a table of near-misses to maintain
2612
+ // A name that WAS a directive and is not one any more. Bare removal would be
2613
+ // the silent kind: `:attrs` is now an ordinary binding, so it would write
2614
+ // attrs="[object Object]" on an element and pass a prop nobody declared to a
2615
+ // component. One entry, and the message carries the migration
2616
+ const RETIRED_DIRECTIVES: Record<string, string> = {
2617
+ ":attrs": `:attrs was removed in 0.7 - it now binds an attribute called "attrs". ` +
2618
+ `Bind them one at a time (:disabled="x", :title="y"), or :class for classes`,
2619
+ }
2620
+
2621
+ const warnRetiredDirective = (node: TemplateNode) => {
2622
+ for (const attr in node.attrs) {
2623
+ const message = RETIRED_DIRECTIVES[attr]
2624
+ if (message !== undefined) console.warn(`jq79: ${message}`)
2625
+ }
2626
+ }
2627
+
2628
+ const warnDirectiveTypo = (node: TemplateNode) => {
2629
+ // on a component tag every `:name` is a prop by design, and on a tag that may
2630
+ // still become one it is a parameter waiting for a definition
2631
+ if (node.component !== undefined || node.tag.includes("-")) return
2632
+
2633
+ for (const attr in node.attrs) {
2634
+ if (!attr.startsWith(":") || isControlAttr(attr) || attr === ":model" || attr.startsWith(":model.")) continue
2635
+ const name = attr.slice(1)
2636
+ const directive = DIRECTIVE_NAMES.find(known => name !== known && name.startsWith(known))
2637
+ if (directive === undefined) continue
2638
+ console.warn(
2639
+ `jq79: ${attr} is not a directive - it bound an attribute named "${name}". ` +
2640
+ `A ":name" jq79 does not recognize binds that attribute (which is what :src and :disabled are). ` +
2641
+ `If you meant :${directive}, that is the spelling`
2642
+ )
2643
+ }
2644
+ }
2645
+
2086
2646
  // Checks one node list's chains, and every list below it, against that grammar.
2087
2647
  // Run once per definition from componentPartsFrom, not per render: a template
2088
2648
  // says what it says before any data exists, so a stray :else is reported when
@@ -2094,9 +2654,17 @@ const warnOrphanBranch = (node: TemplateNode, afterClosedChain: boolean) => {
2094
2654
  // The dispatch mirrors renderNodes' loop, because that is what decides which
2095
2655
  // node ends up a branch of what: a :each node is claimed before the chain
2096
2656
  // grouping ever sees it, which is exactly why it breaks a chain
2097
- const validateChains = (nodes: (TemplateNode | string)[]) => {
2657
+ const validateChains = (nodes: (TemplateNode | string)[], parent?: TemplateNode) => {
2098
2658
  nodes.forEach(node => {
2099
- if (typeof node !== "string") validateChains(node.children)
2659
+ if (typeof node === "string") return
2660
+ // before the loop below, which skips a :each node early - and a
2661
+ // <template :each> is one of the two shapes this reports. The parent comes
2662
+ // with it because a <template :slot> means one thing under a component tag
2663
+ // and something else anywhere else
2664
+ warnTemplateDirective(node, parent)
2665
+ warnRetiredDirective(node)
2666
+ warnDirectiveTypo(node)
2667
+ validateChains(node.children, node)
2100
2668
  })
2101
2669
 
2102
2670
  // the chain that ended immediately before this point closed itself with an
@@ -2365,12 +2933,10 @@ const SLOT_TAG_RE = /^slot\./i
2365
2933
  // <script>/<style> bodies split out, only start-tag interiors scanned, quoted
2366
2934
  // values consumed whole.
2367
2935
  //
2368
- // Two name positions, not one: attribute names (`:model.firstName`) and the
2369
- // dotted tag names (`<slot.firstName>`), whose closing halves are rewritten
2370
- // too or the parser sees a mismatched pair. Component tags are deliberately
2371
- // left alone - findComponentKey already matches them case-insensitively with
2372
- // dashes stripped, so <UserCard> needs no help and rewriting it would only
2373
- // obscure what the author wrote
2936
+ // Three name positions, not one: attribute names (`:model.firstName`), the
2937
+ // dotted tag names (`<slot.firstName>`) and component tags (`<UserCard>`,
2938
+ // renamed by componentTagName below). The last two have closing halves that are
2939
+ // rewritten too, or the parser sees a mismatched pair
2374
2940
  const kebabTagName = (tag: string): string =>
2375
2941
  SLOT_TAG_RE.test(tag) ? `slot.${camelToKebab(tag.slice("slot.".length))}` : tag
2376
2942
 
@@ -2391,6 +2957,41 @@ const kebabTagName = (tag: string): string =>
2391
2957
  const COMPONENT_TAG_ATTR = ":jq79-component"
2392
2958
  const COMPONENT_TAG_RE = /^[A-Z]/
2393
2959
 
2960
+ // A component tag is renamed to a name the HTML parser cannot resolve to an
2961
+ // element, because a PascalCase tag is lowercased by the parser and what comes
2962
+ // out is *the native element of that name*: <Circle /> inside an <svg> is a
2963
+ // circle, <Tr /> is a row placed inside its <tbody>, and 70 of 90 ordinary
2964
+ // one-word component names collide the same way. The claim the author made -
2965
+ // this is a component - survives in the stamp, and the tag stops being a name
2966
+ // anything downstream can mistake for an element's.
2967
+ //
2968
+ // <Circle /> -> <c79-circle :jq79-component="Circle" />
2969
+ // </UserCard> -> </c79-user-card>
2970
+ //
2971
+ // Hyphenated, and that is not cosmetic: `c79-circle` is a valid custom element
2972
+ // name, so the parser builds an HTMLElement for it, where `c79circle` would be
2973
+ // an HTMLUnknownElement. The hyphen is the shape the platform reserves for what
2974
+ // is not native, which is the principle this rests on applied to our own tags -
2975
+ // and it reads for itself in the inspector, where a component that resolves to
2976
+ // nothing leaves <c79-circle> rather than a plausible-looking <circle>.
2977
+ //
2978
+ // Every capitalized tag, not only the colliding ones: today's safe name is
2979
+ // tomorrow's element. See RECORD/2026-08-25.component-tag-prefix.md
2980
+ const COMPONENT_TAG_PREFIX = "c79-"
2981
+
2982
+ const componentTagName = (tag: string): string =>
2983
+ `${COMPONENT_TAG_PREFIX}${camelToKebab(tag[0].toLowerCase() + tag.slice(1))}`
2984
+
2985
+ const rewriteTagName = (tag: string): string =>
2986
+ COMPONENT_TAG_RE.test(tag) ? componentTagName(tag) : kebabTagName(tag)
2987
+
2988
+ // the closing half of the rename. OPEN_TAG_RE matches open tags only, which was
2989
+ // fine while both ends lowercased to the same name; rename one end and not the
2990
+ // other and `<c79-circle>` gets closed by `</circle>`, nesting everything that
2991
+ // follows inside it. </slot.x> keeps its own pass - it is lowercase and
2992
+ // unaffected by this one
2993
+ const CLOSE_COMPONENT_RE = /<\/([A-Z][\w.-]*)(\s*)>/g
2994
+
2394
2995
  // appends the stamp inside the tag, *before* a self-closing slash: this pass
2395
2996
  // runs first and expandSelfClosingTags still has to recognize the `/>` that
2396
2997
  // OPEN_TAG_RE swept into the attributes. A slash inside a quoted value can't be
@@ -2415,9 +3016,10 @@ const expandNameCase = (src: string): string =>
2415
3016
  const rewritten = attrs.replace(ATTR_NAME_RE, (whole, space: string | undefined, name: string | undefined) =>
2416
3017
  name === undefined ? whole : `${space}${camelToKebab(name)}`
2417
3018
  )
2418
- return `<${kebabTagName(tag)}${stampComponentTag(tag, rewritten)}>`
3019
+ return `<${rewriteTagName(tag)}${stampComponentTag(tag, rewritten)}>`
2419
3020
  })
2420
3021
  .replace(CLOSE_SLOT_RE, (_match, suffix: string, space: string) => `</slot.${camelToKebab(suffix)}${space}>`)
3022
+ .replace(CLOSE_COMPONENT_RE, (_match, tag: string, space: string) => `</${componentTagName(tag)}${space}>`)
2421
3023
  )
2422
3024
  .join("")
2423
3025
 
@@ -2469,6 +3071,100 @@ const scopeRules = (rules: CSSRuleList, scope: string) => {
2469
3071
  })
2470
3072
  }
2471
3073
 
3074
+ // A component's name in a selector - `Circle { color: red }` - names the box
3075
+ // the component renders in (see renderNestedComponent), so it is rewritten to
3076
+ // the tag that box actually has. Same rename componentTagName does for markup,
3077
+ // so the author types the prefix in neither place.
3078
+ //
3079
+ // It runs on the SOURCE, before any CSS parser sees it, and that is not a
3080
+ // stylistic choice: in an HTML document a type selector matches
3081
+ // case-insensitively, and an engine is free to lowercase it when it serializes
3082
+ // `selectorText`. jsdom hands `Circle` back as written; an engine that hands
3083
+ // back `circle` would leave a rewrite made there matching nothing - green in
3084
+ // this repo's tests and inert in a browser. See
3085
+ // RECORD/2026-08-25.the-wrapper-and-the-css-rename.md
3086
+ //
3087
+ // Only a capitalized name with a lowercase letter in it is a component name
3088
+ // here: `DIV`, `A` and `SPAN` are shouty type selectors, which CSS has always
3089
+ // matched case-insensitively, and stay elements. That differs from the markup
3090
+ // rule on purpose - there, capitalization is the whole claim - and it leaves
3091
+ // one corner: a component named with no lowercase letter at all cannot be
3092
+ // styled by name
3093
+ const COMPONENT_SELECTOR_RE = /(^|[\s>+~,(])([A-Z][A-Za-z0-9]*)(?![\w-])/g
3094
+ const HAS_LOWER_RE = /[a-z]/
3095
+
3096
+ const renameSelectorNames = (selectors: string): string =>
3097
+ selectors.replace(COMPONENT_SELECTOR_RE, (whole, before: string, name: string) =>
3098
+ HAS_LOWER_RE.test(name) ? `${before}${componentTagName(name)}` : whole
3099
+ )
3100
+
3101
+ // at-rules whose block holds rules rather than declarations, so what is inside
3102
+ // their braces is selector position again
3103
+ const NESTED_AT_RULE_RE = /^\s*@(media|supports|container|layer|scope|document)\b/i
3104
+ const AT_RULE_RE = /^\s*@/
3105
+
3106
+ // the scan: strings, comments and nesting tracked so a rename only ever lands
3107
+ // in selector position. A declaration block is skipped whole (`content: "A"`,
3108
+ // `font-family: Georgia` are not selectors), and so is an at-rule's own prelude
3109
+ // (`@import url(Foo.css)` names a file, not a component).
3110
+ //
3111
+ // A string or a comment inside a selector is emitted verbatim and *ends* the
3112
+ // chunk being renamed, so `[title="a > Boo"]` keeps its Boo. The at-rule test
3113
+ // reads the whole prelude rather than the last chunk, or a `@supports
3114
+ // (font-family: "X")` would stop looking like one the moment its string was
3115
+ // split off
3116
+ const renameComponentSelectors = (css: string): string => {
3117
+ let out = ""
3118
+ let pending = "" // renamable text: selector source since the last verbatim run
3119
+ let prelude = "" // everything since the last delimiter, for the at-rule test
3120
+ const stack: boolean[] = [] // true = the block holds rules, not declarations
3121
+ const inSelectorPosition = () => stack.length === 0 || stack[stack.length - 1]
3122
+
3123
+ const flush = () => {
3124
+ out += inSelectorPosition() && !AT_RULE_RE.test(prelude) ? renameSelectorNames(pending) : pending
3125
+ pending = ""
3126
+ }
3127
+ const verbatim = (text: string) => {
3128
+ flush()
3129
+ out += text
3130
+ prelude += text
3131
+ }
3132
+ const delimiter = (char: string, holdsRules?: boolean) => {
3133
+ flush()
3134
+ out += char
3135
+ prelude = ""
3136
+ if (holdsRules !== undefined) stack.push(holdsRules)
3137
+ else if (char === "}") stack.pop()
3138
+ }
3139
+
3140
+ for (let i = 0; i < css.length; ) {
3141
+ const char = css[i]
3142
+ if (char === "/" && css[i + 1] === "*") {
3143
+ const end = css.indexOf("*/", i + 2)
3144
+ const stop = end === -1 ? css.length : end + 2
3145
+ verbatim(css.slice(i, stop))
3146
+ i = stop
3147
+ } else if (char === '"' || char === "'") {
3148
+ let j = i + 1
3149
+ while (j < css.length && css[j] !== char) j += css[j] === "\\" ? 2 : 1
3150
+ verbatim(css.slice(i, Math.min(j + 1, css.length)))
3151
+ i = j + 1
3152
+ } else if (char === "{") {
3153
+ delimiter("{", NESTED_AT_RULE_RE.test(prelude))
3154
+ i++
3155
+ } else if (char === "}" || char === ";") {
3156
+ delimiter(char)
3157
+ i++
3158
+ } else {
3159
+ pending += char
3160
+ prelude += char
3161
+ i++
3162
+ }
3163
+ }
3164
+ flush()
3165
+ return out
3166
+ }
3167
+
2472
3168
  // the CSS parser is the browser's own (no dependency, no hand-rolled parser).
2473
3169
  // Note browsers *silently drop* rules whose selector they can't parse, which
2474
3170
  // is what Vue's :deep()/::v-deep/>>> escape hatches are - unsupported here,
@@ -2499,7 +3195,7 @@ const parseComponentString = (component: string): ComponentParts => {
2499
3195
  // const fullName = `${fname} ${lname}`
2500
3196
  // </script>
2501
3197
  //
2502
- // <div :attrs="{ fullName }"></div>
3198
+ // <div :title="fullName"></div>
2503
3199
  // <div class="full-name">
2504
3200
  // {{ fullName }}
2505
3201
  // </div>
@@ -2599,6 +3295,15 @@ const componentPartsFrom = (elements: Element[], hashSource: string): ComponentP
2599
3295
  }
2600
3296
  })
2601
3297
 
3298
+ // a component's name in a selector becomes the tag its box actually has,
3299
+ // once per definition and in `content` itself, so every path downstream gets
3300
+ // it: document.head, the scoped rewrite below, and a shadow root (which uses
3301
+ // `content` directly). A `lang` block is skipped for the same reason scoping
3302
+ // skips it - it is not CSS yet
3303
+ styles.forEach(style => {
3304
+ if (!("lang" in style.attrs)) style.content = renameComponentSelectors(style.content)
3305
+ })
3306
+
2602
3307
  // scoping is resolved once, here: the stamped template and the scoped CSS
2603
3308
  // are what every instance of this definition renders and injects. An
2604
3309
  // uncompiled `lang` block is left as it was written - rewriting selectors
@@ -2703,6 +3408,30 @@ const sourceUrlComment = (filename: string | undefined, index: number): string =
2703
3408
  // nothing: the host element is outside the template, so it carries no stamp)
2704
3409
  const headStyle = (style: TagBlock): string => style.scoped ?? style.content
2705
3410
 
3411
+ // the one rule every component box needs: an element where there was none is a
3412
+ // box where there was none, and a component inside a flex or grid parent would
3413
+ // otherwise become an inline wrapper holding the real child. `display: contents`
3414
+ // removes the box and leaves the children in the parent's layout.
3415
+ //
3416
+ // `:where()` is load-bearing: it has zero specificity, so any author rule wins
3417
+ // without !important and without depending on which stylesheet the browser saw
3418
+ // first. `c79-panel { display: flex }` opts a component's box back into being a
3419
+ // box, which is the point of naming it.
3420
+ //
3421
+ // Not refcounted like a component's own styles: it is one constant rule for the
3422
+ // whole document, so it is injected on the first box and stays. The
3423
+ // isConnected check is what makes it survive a head somebody emptied
3424
+ const WRAPPER_STYLE = `:where([${COMPONENT_BOX_ATTR}]) { display: contents }`
3425
+
3426
+ let wrapperStyleEl: HTMLStyleElement | null = null
3427
+
3428
+ const ensureWrapperStyle = () => {
3429
+ if (wrapperStyleEl?.isConnected) return
3430
+ wrapperStyleEl = document.createElement("style")
3431
+ wrapperStyleEl.textContent = WRAPPER_STYLE
3432
+ document.head.appendChild(wrapperStyleEl)
3433
+ }
3434
+
2706
3435
  // document.head styles are shared by content and refcounted, so N instances
2707
3436
  // of the same component (e.g. one per :each item) inject a single <style> tag
2708
3437
  // that goes away when the last instance is destroyed
@@ -3317,11 +4046,25 @@ export class Component79 {
3317
4046
  // nobody could debug
3318
4047
  static debug(options?: Partial<DebugFlags>): DebugFlags {
3319
4048
  if (options) {
4049
+ // an expression is compiled once and cached for the life of the page, so
4050
+ // flipping the form it compiles to has to drop what was compiled under
4051
+ // the old one - otherwise "off" leaves every expression already rendered
4052
+ // still running the prologue
4053
+ const scopedBefore = debugFlags.scopedNames
3320
4054
  for (const key in options) {
3321
4055
  const value = options[key as keyof DebugFlags]
3322
- if (typeof value === "boolean") debugFlags[key as keyof DebugFlags] = value
4056
+ // the key before the value: a typo carrying a boolean - `cloneSkeleton`
4057
+ // for `cloneSkeletons` - used to pass this guard, land in the flags and
4058
+ // come back in the return value, so a caller read "cloning is off" while
4059
+ // it was still on. RECORD/2026-08-25.two-defects-a-review-found.md
4060
+ // hasOwnProperty, not `in`: `in` walks the prototype chain, so
4061
+ // `debug({ toString: false })` passed this guard and landed on the flags
4062
+ if (!Object.prototype.hasOwnProperty.call(debugFlags, key)) {
4063
+ console.warn(`jq79: Component79.debug does not know "${key}" - the flags it has are: ${Object.keys(debugFlags).join(", ")}`)
4064
+ } else if (typeof value === "boolean") debugFlags[key as keyof DebugFlags] = value
3323
4065
  else console.warn(`jq79: Component79.debug ignored "${key}" - the flags are booleans, and the ones it knows are: ${Object.keys(debugFlags).join(", ")}`)
3324
4066
  }
4067
+ if (debugFlags.scopedNames !== scopedBefore) compiled.clear()
3325
4068
  }
3326
4069
  return { ...debugFlags }
3327
4070
  }
@@ -3640,11 +4383,18 @@ export class Component79 {
3640
4383
  }
3641
4384
 
3642
4385
  if (shadow) {
3643
- this.styleEls = this.styles.map(style => {
4386
+ // document.head cannot reach into a shadow root, so every component box
4387
+ // rendered inside this one needs the wrapper rule here. It goes last, and
4388
+ // the position carries nothing: :where() has no specificity, so an author
4389
+ // rule wins wherever it sits. What it does buy is that "the shadow root's
4390
+ // style" still means the component's own
4391
+ const wrapperEl = document.createElement("style")
4392
+ wrapperEl.textContent = WRAPPER_STYLE
4393
+ this.styleEls = [...this.styles.map(style => {
3644
4394
  const el = document.createElement("style")
3645
4395
  el.textContent = style.content // the source: a shadow root scopes it already
3646
4396
  return el
3647
- })
4397
+ }), wrapperEl]
3648
4398
  } else {
3649
4399
  this.styles.forEach(style => acquireStyle(headStyle(style)))
3650
4400
  this.ownsSharedStyles = true