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