jq79 0.6.4 → 0.6.6

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"
@@ -104,24 +104,107 @@ const elementToAST = (el: Element): TemplateNode => {
104
104
  // expression compiled with and without $event is two different functions. A
105
105
  // syntactically invalid expression caches its failure (null) so it isn't
106
106
  // recompiled, and rethrown as undefined, exactly as before
107
- 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 }
108
111
 
109
- const compileExpr = (expr: string, params: string[]): Function | null => {
110
- const key = `${params.join(",")}|${expr}`
111
- let fn = compiled.get(key)
112
- if (fn === undefined) {
113
- try {
114
- // the newline before `)` ends a trailing line comment in the
115
- // expression ({{ msg // greeting }}); ASI doesn't apply inside parens,
116
- // so everything else is untouched. Without it the comment eats the
117
- // rest of this single-line body and the expression never compiles
118
- fn = new Function("$scope", ...params, `with ($scope) { return (${expr}\n); }`)
119
- } catch {
120
- fn = null // a syntax error: it will never compile, so don't try again
121
- }
122
- 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
123
176
  }
124
- return fn
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)
185
+ }
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
125
208
  }
126
209
 
127
210
  // a template expression is re-evaluated constantly - once per effect run, once
@@ -240,9 +323,20 @@ const reportExprError = (expr: string, scope: Record<string, any>, error: unknow
240
323
  }
241
324
 
242
325
  const runExpr = (expr: string, scope: Record<string, any>, extras?: Record<string, any>): any => {
243
- const fn = compileExpr(expr, extras ? Object.keys(extras) : [])
244
- if (!fn) return undefined // a syntax error: compileExpr cached the failure, and it stays undefined
245
- 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
+ }
246
340
  }
247
341
 
248
342
  const evalExpr = (expr: string, scope: Record<string, any>, extras?: Record<string, any>): any => {
@@ -327,7 +421,7 @@ const renderText = (parts: TextPart[], scope: Record<string, any>): string => {
327
421
  }
328
422
 
329
423
 
330
- 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"])
331
425
 
332
426
  // a control attribute is one the static-attr loop and nested-component prop
333
427
  // collection must skip. The set holds the fixed names; `:class.<name>` (the
@@ -432,7 +526,7 @@ const removeRange = ({ first, last }: NodeRange) => {
432
526
  // removes several ranges that sit next to each other, in one DOM call each run
433
527
  // rather than one per node. A list dropping all its rows hands them over as a
434
528
  // single span: unlinking 10,000 rows one at a time is 40% of that operation,
435
- // 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
436
530
  // caller, which is the only place that knows what else is going
437
531
  const removeRuns = (runs: NodeRange[]) => {
438
532
  runs.forEach(run => {
@@ -506,7 +600,7 @@ const scanComponentKey = (scope: Record<string, any>, tag: string): string | nul
506
600
  // a template renders before its setup script settles, so `const Row = await
507
601
  // $import(...)` arrives as a new store key *after* elements are on the page -
508
602
  // and a cached "no component called Row" that outlived the pass would never be
509
- // revisited. See TODOS/2026-08-23.component-key-scan.md
603
+ // revisited. See RECORD/2026-08-23.component-key-scan.md
510
604
  // The memo answers for one *base* scope - the one the pass was opened with -
511
605
  // and nothing below it. A lookup walks from wherever it starts up to that base,
512
606
  // checking own keys as it goes (an :each item scope has two or three, a :with
@@ -549,6 +643,31 @@ const closeRenderPass = (outer: RenderPass) => {
549
643
  memoBase = outer.base
550
644
  }
551
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
+
552
671
  const findComponentKey = (scope: Record<string, any>, tag: string): string | null => {
553
672
  if (!tagMemo) return scanComponentKey(scope, tag)
554
673
  const normalized = tag.replace(/-/g, "").toLowerCase()
@@ -694,7 +813,7 @@ const partitionSlots = (node: TemplateNode): Record<string, SlotContent> => {
694
813
  // first wins, like two <template name="X"> in one file: a duplicate is a
695
814
  // typo, and the fix is to delete one - not to guess which
696
815
  if (name in contents) {
697
- 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`)
698
817
  return
699
818
  }
700
819
  contents[name] = { nodes: child.children, binder: child.attrs[attr] || undefined }
@@ -703,7 +822,7 @@ const partitionSlots = (node: TemplateNode): Record<string, SlotContent> => {
703
822
  const hasLoose = loose.some(isMeaningful)
704
823
  if (hasLoose && "default" in contents) {
705
824
  console.warn(
706
- `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 - ` +
707
826
  "the <template> is the default slot's content, and the rest was ignored"
708
827
  )
709
828
  } else if (hasLoose) {
@@ -824,6 +943,44 @@ const renderSlot = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
824
943
  return wrapper
825
944
  }
826
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
+
827
984
  // <MyComponent :user :title="'str'"></MyComponent> - renders a child
828
985
  // component instance at this position. Props: `:name="expr"` evaluates expr
829
986
  // in the parent scope (`:name` alone is shorthand for `:name="name"`), plain
@@ -837,15 +994,51 @@ const renderSlot = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
837
994
  // <style> has to go in there with it - document.head can't reach into a shadow
838
995
  // tree, and a style that never applies to its own component would still be
839
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
+
840
1025
  const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
841
- // two anchors bracketing everything this usage site ever renders: the
842
- // instance's DOM is dynamic (the definition can resolve late or be swapped),
843
- // so a caller that needs to move or remove this chunk later can't hold any
844
- // of it - it holds the anchors, which never move on their own (see boundsOf)
845
- const anchor = document.createComment(key)
846
- const endAnchor = document.createComment(`/${key}`)
847
- const wrapper = document.createDocumentFragment()
848
- 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!)
849
1042
 
850
1043
  // the tag's children, as content for the child's <slot>s. Built once per
851
1044
  // usage site (the AST doesn't change) and closed over the parent's scope
@@ -914,14 +1107,14 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
914
1107
  Object.entries(models).forEach(([name, expr]) => {
915
1108
  const prop = modelProp(name)
916
1109
  if (props[prop] !== undefined) {
917
- 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`)
918
1111
  }
919
1112
  props[prop] = expr
920
1113
  // an expression that can't be an assignment target is a wiring mistake -
921
1114
  // say so now, not on the first update that silently goes nowhere
922
1115
  if (compileExpr(assignment(expr), ["$value"]) === null) {
923
1116
  unassignable.add(name)
924
- 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`)
925
1118
  }
926
1119
  })
927
1120
 
@@ -962,14 +1155,14 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
962
1155
  if (!unfilled?.has(key) || reported.has("unfilled")) return
963
1156
  reported.add("unfilled")
964
1157
  console.error(
965
- `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. ` +
966
1159
  `Pass it (:${key}="…"), or drop it from the signature to use the one declared in this file.`
967
1160
  )
968
1161
  return
969
1162
  }
970
1163
  if (reported.has("type")) return
971
1164
  reported.add("type")
972
- 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`)
973
1166
  }
974
1167
 
975
1168
  fx.effect(() => {
@@ -985,6 +1178,8 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
985
1178
  currentDef = nextDef
986
1179
  if (!nextDef) return
987
1180
 
1181
+ warnForeignRoot(node, nextDef)
1182
+
988
1183
  // a fresh instance per usage site: the definition's parsed parts (and
989
1184
  // pre-resolved modules) are shared, but store/effects/DOM are per instance
990
1185
  const instance = new Component79({
@@ -1020,7 +1215,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
1020
1215
  if (expr === undefined) {
1021
1216
  if (!warned.has(name)) {
1022
1217
  warned.add(name)
1023
- 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(", ")}`)
1024
1219
  }
1025
1220
  return false
1026
1221
  }
@@ -1058,7 +1253,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
1058
1253
  // cuts an effect that wakes itself
1059
1254
  if (nestingDepth >= MAX_NESTING_DEPTH) {
1060
1255
  console.error(
1061
- `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. ` +
1062
1257
  "A component that renders itself stops when its data stops - is there a cycle in it?"
1063
1258
  )
1064
1259
  return
@@ -1069,7 +1264,12 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
1069
1264
  } finally {
1070
1265
  nestingDepth--
1071
1266
  }
1072
- 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)
1073
1273
 
1074
1274
  // deep: a prop sync forwards whatever the expression evaluates to, whole,
1075
1275
  // into the child's store - it reads `user`, never `user.name`, so it can't
@@ -1192,8 +1392,9 @@ const BOOLEAN_ATTRS = new Set([
1192
1392
  "playsinline", "readonly", "required", "reversed", "selected",
1193
1393
  ])
1194
1394
 
1195
- // the one value rule, shared by `:attr="expr"` and `:attrs` so the two forms
1196
- // 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:
1197
1398
  //
1198
1399
  // - a boolean attribute is removed by ANY falsy value and set to "" when
1199
1400
  // truthy, so `:disabled="items.length"` enables the button on an empty list
@@ -1217,14 +1418,83 @@ const BOOLEAN_ATTRS = new Set([
1217
1418
  const createFor = (node: TemplateNode): Element =>
1218
1419
  node.ns === undefined ? document.createElement(node.tag) : document.createElementNS(node.ns, node.tag)
1219
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
+
1220
1490
  const applyAttr = (el: Element, name: string, value: any) => {
1221
1491
  const boolean = BOOLEAN_ATTRS.has(name)
1222
1492
  if (boolean ? !value : value == null) el.removeAttribute(name)
1223
1493
  else el.setAttribute(name, boolean ? "" : String(value))
1224
1494
  }
1225
1495
 
1226
- // renders a single element node: static attrs, @event listeners, a reactive
1227
- // :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
1228
1498
  // children with a reactive textContent/innerHTML, otherwise children render
1229
1499
  // normally. :if/:elseif/:else/:each are handled by renderNodes, which decides
1230
1500
  // *whether*/*how many times* a node is rendered before calling this. Tags
@@ -1244,7 +1514,7 @@ const applyAttr = (el: Element, name: string, value: any) => {
1244
1514
  //
1245
1515
  // Worth -20 to -49% of create1k depending on how much fixed structure a row
1246
1516
  // has, and nothing at all on a row that has none. Measured, with the method and
1247
- // the caveats, in TODOS/2026-08-24.clone-skeletons-measured.md.
1517
+ // the caveats, in RECORD/2026-08-24.clone-skeletons-measured.md.
1248
1518
  //
1249
1519
  // Two rules keep this from becoming the bug it could be:
1250
1520
  //
@@ -1259,7 +1529,7 @@ const applyAttr = (el: Element, name: string, value: any) => {
1259
1529
  // single directive renderNode treats specially that CONTROL_ATTRS does not
1260
1530
  // name, so it slipped through the generic `:<name>` clause. Adding to this
1261
1531
  // list is the dangerous edit in this file - see
1262
- // TODOS/2026-08-24.more-holes-in-the-cloner.md.
1532
+ // RECORD/2026-08-24.more-holes-in-the-cloner.md.
1263
1533
  // 2. **The interpreted path stays the fallback for everything else**, including
1264
1534
  // every tag that could still turn into a component. The upgrade watch and
1265
1535
  // the unresolved-component throw are not reimplemented here; they are never
@@ -1278,7 +1548,7 @@ const applyAttr = (el: Element, name: string, value: any) => {
1278
1548
  // rebuild: a page that renders wrong is a bug report either way, but one whose
1279
1549
  // reporter can say "it goes away with cloning off" is a bug report that names
1280
1550
  // the file
1281
- const debugFlags: DebugFlags = { cloneSkeletons: true }
1551
+ const debugFlags: DebugFlags = { cloneSkeletons: true, scopedNames: true }
1282
1552
 
1283
1553
  // What `Component79.debug()` can switch. One flag today; the shape is an object
1284
1554
  // so the next one does not change the call
@@ -1287,16 +1557,26 @@ export type DebugFlags = {
1287
1557
  // instead of walking the AST for every instance of it. Off means every
1288
1558
  // element goes through renderNode, exactly as before this existed
1289
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
1290
1566
  }
1291
1567
 
1292
1568
  // the control attributes a skeleton knows how to fill. The rest of
1293
1569
  // CONTROL_ATTRS stays rejected on purpose: :if/:elseif/:else/:each/:key change
1294
1570
  // the shape rather than filling a hole, :with changes the scope its subtree
1295
- // evaluates in, :props belongs to a component tag, and :html carries a
1296
- // sanitizer plus a second attribute (:html.allowed) whose "without :html"
1297
- // warning fires once per render interpreted and would fire once per definition
1298
- // here - see TODOS/2026-08-24.more-holes-in-the-cloner.md
1299
- const PLANNABLE_CONTROL_ATTRS = new Set([":text", ":attrs", ":value", ":checked", ":selected"])
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"])
1300
1580
 
1301
1581
  // What a hole can be, in the order renderNode registers them.
1302
1582
  // A `:` attribute with a dot in it is rejected wholesale except `:class.`:
@@ -1320,19 +1600,21 @@ const plannableAttr = (name: string): boolean => {
1320
1600
  const plannableNode = (node: TemplateNode): boolean => {
1321
1601
  if (node.component || node.tag.includes("-")) return false
1322
1602
  if (isSlotTag(node.tag) || node.tag === "template") return false
1323
- // an unknown tag may still become a component, so it stays interpreted - the
1324
- // upgrade watch lives there and a clone cannot carry it. A *foreign* element
1325
- // skips the test rather than failing it: <circle> is an HTMLUnknownElement
1326
- // when built with createElement, which is exactly the mistake this used to
1327
- // make, and nothing in an <svg> subtree can ever become a component (no SVG
1328
- // tag is uppercase-initial, so none is ever stamped as one)
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
1329
1611
  if (node.ns === undefined && document.createElement(node.tag) instanceof HTMLUnknownElement) return false
1330
1612
  for (const key in node.attrs) if (!plannableAttr(key)) return false
1331
- // an element with :text has no children on either path (see buildSkeleton), so
1332
- // what the source wrote inside it cannot make the subtree unplannable - a
1333
- // component tag under a :text is markup nobody renders, not markup the clone
1334
- // path would get wrong
1335
- if (node.attrs[":text"] !== undefined) return true
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
1336
1618
  return node.children.every(child => typeof child === "string" || plannableNode(child))
1337
1619
  }
1338
1620
 
@@ -1344,20 +1626,19 @@ type SkeletonOp =
1344
1626
  | { kind: "text"; path: number[]; parts: TextPart[] }
1345
1627
  | { kind: "event"; path: number[]; attr: string; expr: string }
1346
1628
  | { kind: "attr"; path: number[]; name: string; expr: string }
1347
- | { kind: "attrs"; path: number[]; expr: string }
1348
1629
  | { kind: "class"; path: number[]; classExpr?: string; toggles: [string, string][] | null; staticClasses: Set<string> }
1349
1630
  | { kind: "textContent"; path: number[]; expr: string }
1631
+ | { kind: "html"; path: number[]; expr: string }
1350
1632
  | { kind: "value"; path: number[]; expr: string }
1351
1633
  | { kind: "checked"; path: number[]; expr: string }
1352
1634
  | { kind: "selected"; path: number[]; expr: string }
1353
1635
 
1354
- type SkeletonPlan = { skeleton: Element; ops: SkeletonOp[]; tags: string[] }
1636
+ type SkeletonPlan = { skeleton: Element; ops: SkeletonOp[] }
1355
1637
 
1356
1638
  // mirrors renderNode's own order: the attribute walk (events and attribute
1357
1639
  // bindings as they appear), then :class, then the children. Effects run in
1358
1640
  // registration order, so this is not cosmetic
1359
- const buildSkeleton = (node: TemplateNode, path: number[], ops: SkeletonOp[], tags: Set<string>): Element => {
1360
- tags.add(node.tag)
1641
+ const buildSkeleton = (node: TemplateNode, path: number[], ops: SkeletonOp[]): Element => {
1361
1642
  const el = createFor(node)
1362
1643
 
1363
1644
  let classExpr: string | undefined
@@ -1374,21 +1655,23 @@ const buildSkeleton = (node: TemplateNode, path: number[], ops: SkeletonOp[], ta
1374
1655
  else if (isControlAttr(key)) { /* handled after the walk */ }
1375
1656
  else if (key.startsWith(":")) {
1376
1657
  const name = key.slice(1)
1377
- 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) })
1378
1661
  } else el.setAttribute(key, value)
1379
1662
  }
1380
- const attrsExpr = node.attrs[":attrs"]
1381
- if (attrsExpr !== undefined) ops.push({ kind: "attrs", path, expr: attrsExpr })
1382
-
1383
1663
  if (classExpr !== undefined || toggles) {
1384
1664
  ops.push({ kind: "class", path, classExpr, toggles, staticClasses: new Set(classNames(node.attrs.class ?? "")) })
1385
1665
  }
1386
1666
 
1387
- // :text replaces the element's content, and renderNode never renders the
1388
- // children of an element carrying one. So the skeleton gives it none either:
1389
- // a :text node is a leaf on both paths, whatever the source wrote inside it
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
1390
1671
  const textExpr = node.attrs[":text"]
1672
+ const htmlExpr = node.attrs[":html"]
1391
1673
  if (textExpr !== undefined) ops.push({ kind: "textContent", path, expr: textExpr })
1674
+ else if (htmlExpr !== undefined) ops.push({ kind: "html", path, expr: htmlExpr })
1392
1675
  else node.children.forEach((child, index) => {
1393
1676
  if (typeof child === "string") {
1394
1677
  // an interpolated text node is a hole; the skeleton holds the empty node
@@ -1399,7 +1682,7 @@ const buildSkeleton = (node: TemplateNode, path: number[], ops: SkeletonOp[], ta
1399
1682
  } else el.appendChild(document.createTextNode(child))
1400
1683
  return
1401
1684
  }
1402
- el.appendChild(buildSkeleton(child, [...path, index], ops, tags))
1685
+ el.appendChild(buildSkeleton(child, [...path, index], ops))
1403
1686
  })
1404
1687
 
1405
1688
  // after the children, because renderNode registers them there and for its
@@ -1423,11 +1706,11 @@ const buildSkeleton = (node: TemplateNode, path: number[], ops: SkeletonOp[], ta
1423
1706
  // and a regression on a row whose only fragments are that small
1424
1707
  const MIN_SKELETON_ELEMENTS = 3
1425
1708
 
1426
- // children under a :text are not built by either path, so they are not elements
1427
- // this threshold should be counting - a <p :text="v"> with two <span>s written
1428
- // inside it is one element's worth of cloning, not three
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
1429
1712
  const countElements = (node: TemplateNode): number =>
1430
- node.attrs[":text"] !== undefined
1713
+ node.attrs[":text"] !== undefined || node.attrs[":html"] !== undefined
1431
1714
  ? 1
1432
1715
  : 1 + node.children.reduce((total, child) => total + (typeof child === "string" ? 0 : countElements(child)), 0)
1433
1716
 
@@ -1435,7 +1718,7 @@ const skeletonPlans = new WeakMap<TemplateNode, SkeletonPlan | null>()
1435
1718
 
1436
1719
  // A definition rendered ONCE pays for a plan it never reuses: +23% at
1437
1720
  // MIN_SKELETON_ELEMENTS, +11.5% at six elements, measured in
1438
- // TODOS/2026-08-24.one-shot-render-measured.md. So the plan is built on the
1721
+ // RECORD/2026-08-24.one-shot-render-measured.md. So the plan is built on the
1439
1722
  // SECOND render, not the first - a one-shot definition never builds one at all,
1440
1723
  // and a :each of 1,000 rows interprets row 1 and clones the other 999.
1441
1724
  //
@@ -1466,9 +1749,8 @@ const planOf = (node: TemplateNode): SkeletonPlan | null => {
1466
1749
  let plan: SkeletonPlan | null = null
1467
1750
  if (plannableNode(node) && countElements(node) >= MIN_SKELETON_ELEMENTS) {
1468
1751
  const ops: SkeletonOp[] = []
1469
- const tags = new Set<string>()
1470
- const skeleton = buildSkeleton(node, [], ops, tags)
1471
- plan = { skeleton, ops, tags: Array.from(tags) }
1752
+ const skeleton = buildSkeleton(node, [], ops)
1753
+ plan = { skeleton, ops }
1472
1754
  }
1473
1755
  skeletonPlans.set(node, plan)
1474
1756
  return plan
@@ -1517,20 +1799,24 @@ const renderFromSkeleton = (plan: SkeletonPlan, scope: Record<string, any>, fx:
1517
1799
  el.classList.add(...next)
1518
1800
  bound = next
1519
1801
  })
1520
- } else if (op.kind === "attrs") {
1802
+ } else if (op.kind === "textContent") {
1521
1803
  const el = target as Element
1522
1804
  const { expr } = op
1523
- let boundKeys: string[] = []
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
1524
1808
  fx.effect(() => {
1525
- boundKeys.forEach(key => el.removeAttribute(key))
1526
- const bound = evalExpr(expr, scope)
1527
- boundKeys = bound && typeof bound === "object" ? Object.keys(bound) : []
1528
- boundKeys.forEach(key => applyAttr(el, key, bound[key]))
1809
+ const text = String(evalExpr(expr, scope) ?? "")
1810
+ if (el.textContent !== text) el.textContent = text
1529
1811
  })
1530
- } else if (op.kind === "textContent") {
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
1531
1817
  const el = target as Element
1532
1818
  const { expr } = op
1533
- fx.effect(() => { el.textContent = String(evalExpr(expr, scope) ?? "") })
1819
+ fx.effect(() => { el.innerHTML = sanitizeHTML(String(evalExpr(expr, scope) ?? "")) })
1534
1820
  } else if (op.kind === "value") {
1535
1821
  // the property, not the attribute, and skipping a write that would not
1536
1822
  // change it - renderNode's reasons apply here unchanged
@@ -1543,11 +1829,17 @@ const renderFromSkeleton = (plan: SkeletonPlan, scope: Record<string, any>, fx:
1543
1829
  } else if (op.kind === "checked") {
1544
1830
  const el = target as HTMLInputElement
1545
1831
  const { expr } = op
1546
- fx.effect(() => { el.checked = !!evalExpr(expr, scope) })
1832
+ fx.effect(() => {
1833
+ const checked = !!evalExpr(expr, scope)
1834
+ if (el.checked !== checked) el.checked = checked
1835
+ })
1547
1836
  } else if (op.kind === "selected") {
1548
1837
  const el = target as HTMLOptionElement
1549
1838
  const { expr } = op
1550
- fx.effect(() => { el.selected = !!evalExpr(expr, scope) })
1839
+ fx.effect(() => {
1840
+ const selected = !!evalExpr(expr, scope)
1841
+ if (el.selected !== selected) el.selected = selected
1842
+ })
1551
1843
  } else {
1552
1844
  // every kind is named above, so this is unreachable - and the assignment
1553
1845
  // is what makes the compiler say so. A kind added to SkeletonOp and
@@ -1562,7 +1854,7 @@ const renderFromSkeleton = (plan: SkeletonPlan, scope: Record<string, any>, fx:
1562
1854
  }
1563
1855
 
1564
1856
  const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
1565
- // :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
1566
1858
  // whole subtree. On a :each element the item scope is already in place, so
1567
1859
  // :with="item" works
1568
1860
  const withExpr = node.attrs[":with"]
@@ -1574,63 +1866,78 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1574
1866
  if (isSlotTag(node.tag)) return renderSlot(node, scope, fx, shadow)
1575
1867
  if (node.tag === "template" && slotAttrOf(node) !== undefined) return misplacedSlotContent(node)
1576
1868
 
1577
- const componentKey = findComponentKey(scope, node.tag)
1869
+ const componentKey = componentKeyOf(node, scope)
1578
1870
  if (componentKey) return renderNestedComponent(componentKey, node, scope, fx, shadow)
1579
1871
 
1580
- // A planned subtree is cloned - unless a scope key captures one of its tags.
1581
- // findComponentKey strips dashes and lowercases, and every PascalCase scope
1582
- // key participates, so a variable named `Td` makes every <td> under it a
1583
- // component and `Map`, `Data`, `Table`, `Form` and `Label` are all HTML tags
1584
- // somebody might name a component after. "It is a known HTML tag" is not on
1585
- // its own an answer; this is. It costs what the interpreted path already
1586
- // pays - one findComponentKey per distinct tag, memoized per render pass
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
1587
1879
  if (debugFlags.cloneSkeletons) {
1588
1880
  const plan = planOf(node)
1589
- if (plan && !plan.tags.some(tag => findComponentKey(scope, tag))) return renderFromSkeleton(plan, scope, fx)
1881
+ if (plan) return renderFromSkeleton(plan, scope, fx)
1590
1882
  }
1591
1883
 
1592
1884
  const el = createFor(node)
1593
1885
 
1594
- // <UserCrad /> - written as a component (node.component), resolving to no
1595
- // component, and not an element either. Nothing else on the page can supply
1596
- // the name once every script has settled, so this renders no markup, no
1597
- // styles, no children and no script, forever, and says so by throwing rather
1598
- // 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.
1599
1891
  //
1600
- // All three conditions carry weight. Without the capitalization <lable> and
1601
- // <svg> would be fatal (createElement builds SVG names in the HTML namespace,
1602
- // so an <svg> is an HTMLUnknownElement too); without the element check <DIV>
1603
- // would be, though it renders a perfectly good div; and without the pending
1604
- // count a factory that awaits $mounted() before returning its components
1605
- // 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.
1606
1898
  //
1607
- // An *absent* count is a fourth case, and it is not zero: renderComponent()
1608
- // renders a template against a store somebody else owns and assembles, so
1609
- // nothing there has finished and nothing says a key can't still be written
1610
- // in. The claim being tested is a component's claim about its own scripts,
1611
- // 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.
1612
1907
  //
1613
1908
  // Written without a local for the count because renderNode is on the stack
1614
1909
  // for the whole of the subtree below it, so a slot here is a slot per level
1615
1910
  // of a component nested inside itself - see renderWith
1616
- 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) {
1617
1912
  throw unresolvedComponent(node.component, scope)
1618
1913
  }
1619
1914
 
1620
- // a tag that isn't standard HTML but has no matching scope key *yet* may be
1621
- // a component that arrives later (e.g. an async factory script exposing an
1622
- // imported component after `await`). Watch for the key: the effect tracks
1623
- // no deps, so it only re-runs on the store's new-key sweep, and swaps the
1624
- // placeholder element for the component exactly once
1625
- // dashes included, because findComponentKey matches them case-insensitively
1626
- // with dashes stripped: <drop-area> resolves DropArea, so a dashed tag is a
1627
- // possible component too, not only a custom element
1628
- 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("-"))
1629
1936
  if (mayUpgrade) {
1630
1937
  let upgraded = false
1631
1938
  fx.effect(() => {
1632
1939
  if (upgraded) return
1633
- const key = findComponentKey(scope, node.tag)
1940
+ const key = componentKeyOf(node, scope)
1634
1941
  if (!key) return
1635
1942
  upgraded = true
1636
1943
  const replacement = renderNestedComponent(key, node, scope, fx, shadow)
@@ -1647,23 +1954,23 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1647
1954
  // entries form allocates one array of pairs plus one two-element array per
1648
1955
  // attribute *per instance*. Nothing here reads the pairs as pairs, so the
1649
1956
  // allocation buys nothing and the garbage it makes is measurable - see
1650
- // TODOS/2026-08-23.where-the-create-time-goes.md
1957
+ // RECORD/2026-08-23.where-the-create-time-goes.md
1651
1958
  for (const key in node.attrs) {
1652
1959
  const value = node.attrs[key]
1653
1960
  if (key.startsWith("@")) bindEvent(el, key, value, scope)
1654
1961
  else if (key === ":model" || key.startsWith(":model.")) {
1655
- // :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;
1656
1963
  // the native-element form is parked there). Warn on a real element, but
1657
1964
  // not on a tag that may still upgrade into a component - the upgrade
1658
1965
  // re-renders through renderNestedComponent, models and all
1659
1966
  if (!mayUpgrade) {
1660
- 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)`)
1661
1968
  }
1662
1969
  } else if (isControlAttr(key)) {
1663
1970
  // a directive of its own, bound further down (or by renderNodes)
1664
1971
  } else if (key.startsWith(":")) {
1665
1972
  // :name="expr" binds that one attribute, reactively - the single-key
1666
- // case :attrs="{ name: expr }" was carrying. `:name` alone is shorthand
1973
+ // case :attrs="{ name: expr }" used to carry. `:name` alone is shorthand
1667
1974
  // for `:name="name"`, like props and :model.<name>, and the shorthand
1668
1975
  // reads the camelCase variable while the attribute keeps its written
1669
1976
  // (kebab) name: `:aria-expanded` binds `ariaExpanded`, because
@@ -1672,28 +1979,19 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1672
1979
  // On a tag that may still upgrade this is a *parameter*, not an
1673
1980
  // attribute: leave it written verbatim, as before, so the upgrade's
1674
1981
  // renderNestedComponent still finds it. A component tag has no single
1675
- // 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)
1676
1983
  if (mayUpgrade) el.setAttribute(key, value)
1677
1984
  else {
1678
- const name = key.slice(1)
1679
- 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)
1680
1990
  fx.effect(() => applyAttr(el, name, evalExpr(expr, scope)))
1681
1991
  }
1682
1992
  } else el.setAttribute(key, value)
1683
1993
  }
1684
1994
 
1685
- const bindExpr = node.attrs[":attrs"]
1686
- if (bindExpr !== undefined) {
1687
- let boundKeys: string[] = []
1688
-
1689
- fx.effect(() => {
1690
- boundKeys.forEach(key => el.removeAttribute(key))
1691
- const bound = evalExpr(bindExpr, scope)
1692
- boundKeys = bound && typeof bound === "object" ? Object.keys(bound) : []
1693
- boundKeys.forEach(key => applyAttr(el, key, bound[key]))
1694
- })
1695
- }
1696
-
1697
1995
  // :class="expr" adds classes on top of the static `class` attribute, and
1698
1996
  // :class.<name>="expr" is the single-flag shorthand for `{ <name>: expr }`
1699
1997
  // (the name routed through classNames, so an empty `:class.` can't reach
@@ -1743,7 +2041,10 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1743
2041
  console.warn("jq79: :html.allowed without :html on the same element does nothing")
1744
2042
  }
1745
2043
  if (textExpr !== undefined) {
1746
- 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
+ })
1747
2048
  } else if (htmlExpr !== undefined) {
1748
2049
  fx.effect(() => {
1749
2050
  const options = allowedExpr !== undefined ? { allowUrl: normalizeAllowUrl(evalExpr(allowedExpr, scope)) } : undefined
@@ -1762,8 +2063,8 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1762
2063
 
1763
2064
  // :value / :checked / :selected write the DOM *property*, not the
1764
2065
  // attribute - the attribute is only a form control's default, and detaches
1765
- // the moment the user interacts (which is why :attrs="{ value }" stops
1766
- // 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
1767
2068
  // explicit @input/@change. :value skips the write when the property
1768
2069
  // already holds the string, so an unrelated re-run can't move the caret of
1769
2070
  // the input the user is typing into. Registered after the children render:
@@ -1778,14 +2079,20 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1778
2079
  // written out rather than looped over a literal array: the loop allocated the
1779
2080
  // array *and* its closure for every element rendered - 8,000 of each per
1780
2081
  // create1k, almost all of them to find nothing. Same reason the attribute
1781
- // walk above is a `for...in` (TODOS/2026-08-23.where-the-create-time-goes.md)
2082
+ // walk above is a `for...in` (RECORD/2026-08-23.where-the-create-time-goes.md)
1782
2083
  const checkedExpr = node.attrs[":checked"]
1783
2084
  if (checkedExpr !== undefined) {
1784
- 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
+ })
1785
2089
  }
1786
2090
  const selectedExpr = node.attrs[":selected"]
1787
2091
  if (selectedExpr !== undefined) {
1788
- 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
+ })
1789
2096
  }
1790
2097
 
1791
2098
  return el
@@ -1982,7 +2289,7 @@ const eachPlanOf = (node: TemplateNode): EachPlan | null => {
1982
2289
  // one key per row, so a 1,000-row list paid 1,000 `with`-scoped calls through
1983
2290
  // the store proxy to discover that nothing had changed: most of the 37% of a
1984
2291
  // pass that goes on evaluating expressions
1985
- // (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,
1986
2293
  // an index, a deeper path, a name from the outer scope - still goes through
1987
2294
  // evalExpr, and so does a non-object item, which keeps every diagnostic a
1988
2295
  // property read of a null row would have raised
@@ -2001,7 +2308,7 @@ const eachPlanOf = (node: TemplateNode): EachPlan | null => {
2001
2308
  // it was 9ms of removeRow's 25ms. Decided once, from the template, rather
2002
2309
  // than per row per render. The item name is deliberately not in this list:
2003
2310
  // nearly every binding reads it, and it is not what goes stale.
2004
- // See TODOS/2026-08-23.positional-refresh.md
2311
+ // See RECORD/2026-08-23.positional-refresh.md
2005
2312
  const positionalNames = ["$index", ...(atName ? [atName] : [])]
2006
2313
  const readsPosition = mentionsAny(itemNode, positionalNames)
2007
2314
 
@@ -2023,7 +2330,7 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
2023
2330
  // one key per row, so a 1,000-row list paid 1,000 `with`-scoped calls through
2024
2331
  // the store proxy to discover that nothing had changed: most of the 37% of a
2025
2332
  // pass that goes on evaluating expressions
2026
- // (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,
2027
2334
  // an index, a deeper path, a name from the outer scope - still goes through
2028
2335
  // evalExpr, and so does a non-object item, which keeps every diagnostic a
2029
2336
  // property read of a null row would have raised
@@ -2222,7 +2529,7 @@ const warnChainAttrs = (node: TemplateNode) => {
2222
2529
  // allocated only on the way to a warning, never on the path that finds none
2223
2530
  const present = [hasIf ? ":if" : null, hasElseif ? ":elseif" : null, hasElse ? ":else" : null].filter(Boolean)
2224
2531
  console.warn(
2225
- `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; ` +
2226
2533
  "the branches of a chain are sibling elements, one directive each"
2227
2534
  )
2228
2535
  }
@@ -2235,13 +2542,107 @@ const warnOrphanBranch = (node: TemplateNode, afterClosedChain: boolean) => {
2235
2542
  const attr = ":elseif" in node.attrs ? ":elseif" : ":else"
2236
2543
  console.warn(
2237
2544
  afterClosedChain
2238
- ? `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, ` +
2239
2546
  "which closes it. One :if, any number of :elseif, at most one :else"
2240
- : `jq79: ${attr} on <${node.tag}> continues no :if - it renders unconditionally. ` +
2547
+ : `jq79: ${attr} on <${tagLabel(node)}> continues no :if - it renders unconditionally. ` +
2241
2548
  "A chain is :if, then :elseif, then :else, on adjacent siblings: anything but whitespace between them breaks it"
2242
2549
  )
2243
2550
  }
2244
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
+
2245
2646
  // Checks one node list's chains, and every list below it, against that grammar.
2246
2647
  // Run once per definition from componentPartsFrom, not per render: a template
2247
2648
  // says what it says before any data exists, so a stray :else is reported when
@@ -2253,9 +2654,17 @@ const warnOrphanBranch = (node: TemplateNode, afterClosedChain: boolean) => {
2253
2654
  // The dispatch mirrors renderNodes' loop, because that is what decides which
2254
2655
  // node ends up a branch of what: a :each node is claimed before the chain
2255
2656
  // grouping ever sees it, which is exactly why it breaks a chain
2256
- const validateChains = (nodes: (TemplateNode | string)[]) => {
2657
+ const validateChains = (nodes: (TemplateNode | string)[], parent?: TemplateNode) => {
2257
2658
  nodes.forEach(node => {
2258
- 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)
2259
2668
  })
2260
2669
 
2261
2670
  // the chain that ended immediately before this point closed itself with an
@@ -2524,12 +2933,10 @@ const SLOT_TAG_RE = /^slot\./i
2524
2933
  // <script>/<style> bodies split out, only start-tag interiors scanned, quoted
2525
2934
  // values consumed whole.
2526
2935
  //
2527
- // Two name positions, not one: attribute names (`:model.firstName`) and the
2528
- // dotted tag names (`<slot.firstName>`), whose closing halves are rewritten
2529
- // too or the parser sees a mismatched pair. Component tags are deliberately
2530
- // left alone - findComponentKey already matches them case-insensitively with
2531
- // dashes stripped, so <UserCard> needs no help and rewriting it would only
2532
- // 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
2533
2940
  const kebabTagName = (tag: string): string =>
2534
2941
  SLOT_TAG_RE.test(tag) ? `slot.${camelToKebab(tag.slice("slot.".length))}` : tag
2535
2942
 
@@ -2550,6 +2957,41 @@ const kebabTagName = (tag: string): string =>
2550
2957
  const COMPONENT_TAG_ATTR = ":jq79-component"
2551
2958
  const COMPONENT_TAG_RE = /^[A-Z]/
2552
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
+
2553
2995
  // appends the stamp inside the tag, *before* a self-closing slash: this pass
2554
2996
  // runs first and expandSelfClosingTags still has to recognize the `/>` that
2555
2997
  // OPEN_TAG_RE swept into the attributes. A slash inside a quoted value can't be
@@ -2574,9 +3016,10 @@ const expandNameCase = (src: string): string =>
2574
3016
  const rewritten = attrs.replace(ATTR_NAME_RE, (whole, space: string | undefined, name: string | undefined) =>
2575
3017
  name === undefined ? whole : `${space}${camelToKebab(name)}`
2576
3018
  )
2577
- return `<${kebabTagName(tag)}${stampComponentTag(tag, rewritten)}>`
3019
+ return `<${rewriteTagName(tag)}${stampComponentTag(tag, rewritten)}>`
2578
3020
  })
2579
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}>`)
2580
3023
  )
2581
3024
  .join("")
2582
3025
 
@@ -2628,6 +3071,100 @@ const scopeRules = (rules: CSSRuleList, scope: string) => {
2628
3071
  })
2629
3072
  }
2630
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
+
2631
3168
  // the CSS parser is the browser's own (no dependency, no hand-rolled parser).
2632
3169
  // Note browsers *silently drop* rules whose selector they can't parse, which
2633
3170
  // is what Vue's :deep()/::v-deep/>>> escape hatches are - unsupported here,
@@ -2658,7 +3195,7 @@ const parseComponentString = (component: string): ComponentParts => {
2658
3195
  // const fullName = `${fname} ${lname}`
2659
3196
  // </script>
2660
3197
  //
2661
- // <div :attrs="{ fullName }"></div>
3198
+ // <div :title="fullName"></div>
2662
3199
  // <div class="full-name">
2663
3200
  // {{ fullName }}
2664
3201
  // </div>
@@ -2758,6 +3295,15 @@ const componentPartsFrom = (elements: Element[], hashSource: string): ComponentP
2758
3295
  }
2759
3296
  })
2760
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
+
2761
3307
  // scoping is resolved once, here: the stamped template and the scoped CSS
2762
3308
  // are what every instance of this definition renders and injects. An
2763
3309
  // uncompiled `lang` block is left as it was written - rewriting selectors
@@ -2862,6 +3408,30 @@ const sourceUrlComment = (filename: string | undefined, index: number): string =
2862
3408
  // nothing: the host element is outside the template, so it carries no stamp)
2863
3409
  const headStyle = (style: TagBlock): string => style.scoped ?? style.content
2864
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
+
2865
3435
  // document.head styles are shared by content and refcounted, so N instances
2866
3436
  // of the same component (e.g. one per :each item) inject a single <style> tag
2867
3437
  // that goes away when the last instance is destroyed
@@ -3476,11 +4046,25 @@ export class Component79 {
3476
4046
  // nobody could debug
3477
4047
  static debug(options?: Partial<DebugFlags>): DebugFlags {
3478
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
3479
4054
  for (const key in options) {
3480
4055
  const value = options[key as keyof DebugFlags]
3481
- 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
3482
4065
  else console.warn(`jq79: Component79.debug ignored "${key}" - the flags are booleans, and the ones it knows are: ${Object.keys(debugFlags).join(", ")}`)
3483
4066
  }
4067
+ if (debugFlags.scopedNames !== scopedBefore) compiled.clear()
3484
4068
  }
3485
4069
  return { ...debugFlags }
3486
4070
  }
@@ -3799,11 +4383,18 @@ export class Component79 {
3799
4383
  }
3800
4384
 
3801
4385
  if (shadow) {
3802
- 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 => {
3803
4394
  const el = document.createElement("style")
3804
4395
  el.textContent = style.content // the source: a shadow root scopes it already
3805
4396
  return el
3806
- })
4397
+ }), wrapperEl]
3807
4398
  } else {
3808
4399
  this.styles.forEach(style => acquireStyle(headStyle(style)))
3809
4400
  this.ownsSharedStyles = true