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/README.md +1 -1
- package/dist/jq79.cjs +14 -13
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.d.ts +2 -0
- package/dist/jq79.global.js +14 -13
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +14 -13
- package/dist/jq79.js.map +1 -1
- package/dist/reactive.d.ts +22 -0
- package/dist/transform.d.ts +1 -0
- package/package.json +4 -1
- package/src/jq79.ts +901 -151
- package/src/reactive.ts +105 -31
- package/src/transform.ts +147 -0
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
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
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([":
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
1181
|
-
//
|
|
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,
|
|
1204
|
-
//
|
|
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
|
|
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
|
-
//
|
|
1230
|
-
//
|
|
1231
|
-
//
|
|
1232
|
-
//
|
|
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
|
|
1239
|
-
// what makes rule 1 enforceable
|
|
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
|
-
//
|
|
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
|
|
1276
|
-
//
|
|
1277
|
-
|
|
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[]
|
|
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[]
|
|
1298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1352
|
-
|
|
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, :
|
|
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 =
|
|
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
|
|
1422
|
-
//
|
|
1423
|
-
//
|
|
1424
|
-
//
|
|
1425
|
-
//
|
|
1426
|
-
//
|
|
1427
|
-
//
|
|
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
|
|
1881
|
+
if (plan) return renderFromSkeleton(plan, scope, fx)
|
|
1431
1882
|
}
|
|
1432
1883
|
|
|
1433
|
-
const el =
|
|
1884
|
+
const el = createFor(node)
|
|
1434
1885
|
|
|
1435
|
-
// <UserCrad /> - written as a component (node.component)
|
|
1436
|
-
//
|
|
1437
|
-
//
|
|
1438
|
-
//
|
|
1439
|
-
//
|
|
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
|
-
//
|
|
1442
|
-
// <svg>
|
|
1443
|
-
//
|
|
1444
|
-
//
|
|
1445
|
-
//
|
|
1446
|
-
//
|
|
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
|
-
//
|
|
1449
|
-
//
|
|
1450
|
-
//
|
|
1451
|
-
//
|
|
1452
|
-
//
|
|
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
|
|
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
|
|
1462
|
-
//
|
|
1463
|
-
//
|
|
1464
|
-
//
|
|
1465
|
-
//
|
|
1466
|
-
//
|
|
1467
|
-
//
|
|
1468
|
-
//
|
|
1469
|
-
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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 }"
|
|
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 (
|
|
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
|
|
1520
|
-
const expr = value || kebabToCamel(
|
|
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(() => {
|
|
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
|
|
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` (
|
|
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(() => {
|
|
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(() => {
|
|
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
|
-
// (
|
|
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
|
|
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
|
-
// (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
2369
|
-
// dotted tag names (`<slot.firstName>`)
|
|
2370
|
-
//
|
|
2371
|
-
//
|
|
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 `<${
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
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
|