jq79 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/jq79.ts CHANGED
@@ -1,11 +1,19 @@
1
1
 
2
2
  import { $, $$, $create, sanitizeHTML, allowedHosts } from "./dom"
3
- import type { AllowUrl } from "./dom"
4
- import { $reactive, $toRaw, untracked, createEffectScope, ALSO_WAKEN_BY } from "./reactive"
3
+ import type { AllowUrl, QueryAll, QueryOne } from "./dom"
4
+ import { $reactive, $toRaw, untracked, createEffectScope, ALSO_WAKEN_BY, OWNER } from "./reactive"
5
5
  import type { ReactiveDeepData, EffectScope } from "./reactive"
6
- import { transformSetupScript, transformFactoryScript, parsePropsPattern, parseFactoryProps, freeIdentifiers, type PropDecl } from "./transform"
6
+ import { transformSetupScript, transformFactoryScript, parsePropsPattern, parseFactoryProps, type PropDecl } from "./transform"
7
+ import {
8
+ COMPONENT_NAME_RE, COMPONENT_TAG_ATTR, CONTROL_ATTRS, EACH_PATTERN, EXPR_PARAMS, PRECOMPILED_PARAM, PRECOMPILED_QUEUE,
9
+ componentTagName, functionText,
10
+ assignment, declaredPropNames, defer, factoryBody, factoryParams, functionKey, isControlAttr, isSlotTag,
11
+ kebabToCamel, prepareSource, readSetupSignature, scopedBody, setupBody, setupParams, slotName, splitText, withBody,
12
+ type TagBlock, type TemplateNode, type TextPart,
13
+ } from "./source"
7
14
 
8
15
  export { $, $$, $create } from "./dom"
16
+ export type { QueryOne, QueryAll } from "./dom"
9
17
  export { $reactive, $toRaw } from "./reactive"
10
18
 
11
19
  // the package version, substituted at build time (tsup/vitest `define`, read
@@ -15,34 +23,6 @@ export { $reactive, $toRaw } from "./reactive"
15
23
  declare const __JQ79_VERSION__: string
16
24
  const VERSION = typeof __JQ79_VERSION__ === "string" ? __JQ79_VERSION__ : "0.0.0-dev"
17
25
 
18
- type TemplateNode = {
19
- tag: string
20
- attrs: Record<string, string>
21
- children: (TemplateNode | string)[]
22
- // the tag as the author capitalized it, present only when they wrote it
23
- // uppercase-initial - i.e. when they meant a component. `tag` cannot answer
24
- // this: the HTML parser lowercases it, so the claim is captured before the
25
- // parse (see stampComponentTag) and lifted off attrs here, where it stops
26
- // looking like an attribute to every loop downstream
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
35
- }
36
-
37
- type TagBlock = {
38
- attrs: Record<string, string>
39
- content: string
40
- // <style scoped> only: `content` rewritten to require the component's scope
41
- // attribute. Kept beside the original rather than replacing it, because a
42
- // shadow root doesn't want it - see headStyle()
43
- scoped?: string
44
- }
45
-
46
26
  const elementAttrs = (el: Element): Record<string, string> =>
47
27
  Object.fromEntries(Array.from(el.attributes).map(attr => [attr.name, attr.value]))
48
28
 
@@ -111,6 +91,164 @@ type CompiledExpr = { fn: Function | null; scoped: boolean }
111
91
 
112
92
  const compiled = new Map<string, CompiledExpr>()
113
93
 
94
+ // ---------------------------------------------------------------------------
95
+ // safe eval
96
+ //
97
+ // Every function the runtime builds out of a component's text - an expression,
98
+ // a setup script, a factory script - is built by makeFunction. By default that
99
+ // is `new Function`, exactly as it always was. Two things stand in front of it:
100
+ //
101
+ // - precompiled functions, which scripts the browser loaded *as code* push onto
102
+ // a global queue, keyed by the very (params, body) the runtime would have
103
+ // handed `new Function` - so a hit is the same function by construction. A
104
+ // global rather than a call, so such a script can load before the library or
105
+ // after it, and serve either build of it (the module, or the CDN global)
106
+ // - safe mode (Component79.safeEval()), in which the runtime never calls
107
+ // `new Function`. That is what a CSP without 'unsafe-eval' demands, and
108
+ // where one throws EvalError that compileWith would swallow as a syntax
109
+ // error, safe mode says what is missing instead of rendering empty in silence.
110
+ // With `{ nonce: true }` a miss is built anyway - as a <script> carrying the
111
+ // page's nonce, which the CSP admits where it refuses eval (buildWithNonce)
112
+ //
113
+ // See RECORD/2026-09-23.no-unsafe-eval.md
114
+ // ---------------------------------------------------------------------------
115
+
116
+ // `fn: null` is a body that did not compile, cached as the syntax error it is
117
+ type PrecompiledEntry = [params: string[], body: string, fn: Function | null]
118
+
119
+
120
+ const precompiled = new Map<string, Function | null>()
121
+ let safeEvalOn = false
122
+ // set by safeEval({ nonce: true }), and then never unset: safe mode is one-way
123
+ let safeEvalNonce: string | undefined
124
+
125
+ // what safe mode could not provide. `reason` completes "<the code> ..." and
126
+ // `hint` says why, so an expression and a script report it the same way
127
+ class SafeEvalMiss extends Error {
128
+ constructor(readonly reason: string, readonly hint: string) {
129
+ super(`${reason}. ${hint}`)
130
+ }
131
+ }
132
+
133
+ const notPrecompiled = () => new SafeEvalMiss(
134
+ "was not precompiled",
135
+ "Safe mode never builds code at runtime: the component's precompiled functions did not reach the page."
136
+ )
137
+
138
+ // drains the queue first: a precompiled script may have loaded since the last
139
+ // lookup. With nothing registered - every page that never precompiled anything
140
+ // - the answer is known without building a key
141
+ const lookupPrecompiled = (params: string[], body: string): Function | null | undefined => {
142
+ const queue = (globalThis as any)[PRECOMPILED_QUEUE]
143
+ if (Array.isArray(queue) && queue.length > 0) {
144
+ for (const [entryParams, entryBody, fn] of queue.splice(0) as PrecompiledEntry[]) {
145
+ precompiled.set(functionKey(entryParams, entryBody), fn)
146
+ }
147
+ }
148
+ return precompiled.size === 0 ? undefined : precompiled.get(functionKey(params, body))
149
+ }
150
+
151
+ // `suffix` goes to `new Function` only and is not part of the key: it is the
152
+ // //# sourceURL that names a script for devtools, which a precompiled script
153
+ // has no use for (it has a URL of its own) and a generator couldn't spell the
154
+ // way the runtime does - it depends on how the component was loaded
155
+ const makeFunction = (params: string[], body: string, suffix = ""): Function => {
156
+ const hit = lookupPrecompiled(params, body)
157
+ if (hit === null) throw new SyntaxError("jq79: this code did not compile when it was precompiled")
158
+ if (hit !== undefined) return hit
159
+ if (safeEvalNonce !== undefined) return buildWithNonce(params, body, suffix, safeEvalNonce)
160
+ if (safeEvalOn) throw notPrecompiled()
161
+ return new Function(...params, body + suffix)
162
+ }
163
+
164
+ // the page's nonce, read off a script that carries one. The `.nonce` property
165
+ // and not the attribute: once the element is in a document under a CSP header,
166
+ // the browser blanks the attribute (so a stylesheet's attribute selector can't
167
+ // leak it) and keeps the value on the property - checked in Chromium, where
168
+ // getAttribute("nonce") read "" and .nonce the real value. The attribute is
169
+ // the fallback for an environment with no such property (jsdom).
170
+ //
171
+ // Searched for, because the library may have no script of its own to read: a
172
+ // module has no document.currentScript. A CSP that works by nonce always has
173
+ // one - the script that loaded the page
174
+ const pageNonce = (): string | undefined => {
175
+ if (typeof document === "undefined") return undefined
176
+ for (const script of Array.from(document.querySelectorAll<HTMLScriptElement>("script[nonce]"))) {
177
+ const nonce = script.nonce || script.getAttribute("nonce")
178
+ if (nonce) return nonce
179
+ }
180
+ return undefined
181
+ }
182
+
183
+ // builds what `new Function` would, as a classic <script> the CSP admits
184
+ // because it carries the page's nonce. Classic scripts are sloppy, so `with`
185
+ // compiles exactly as it does under `new Function`; the text is laid out as
186
+ // `new Function` lays it out ("function anonymous(params\n) {\nbody\n}"), so
187
+ // the function is named the same and devtools reports the same line numbers,
188
+ // and the //# sourceURL suffix names the script as it names the function today.
189
+ //
190
+ // An inline script runs synchronously when it is inserted, so this is a
191
+ // drop-in for the synchronous `new Function`. The function comes back on the
192
+ // element itself (document.currentScript) rather than through a global: nothing
193
+ // for the page to collide with, and nothing to clean up.
194
+ //
195
+ // Two ways it can fail, told apart by what the insertion left behind:
196
+ // - a syntax error is reported to window's `error` event rather than thrown to
197
+ // the inserter. The listener takes it and cancels the report, and it is
198
+ // rethrown here - where `new Function` threw it, and where compileWith
199
+ // caches it as the syntax error it always has been;
200
+ // - a nonce the CSP refuses leaves nothing at all: no function, no error
201
+ // event, only a violation the browser logs. That one says so.
202
+ //
203
+ // A body that closes the function's brace early (`a) } alert(1); { (`) is one
204
+ // place this differs: `new Function` refuses it, and a script runs what comes
205
+ // after the brace. The text is the component's own, which can hold a <script>
206
+ // anyway, so this admits nothing a component couldn't already do.
207
+ //
208
+ // Built functions go into the precompiled map: they hold no state, and a
209
+ // setup script - built once per *instance* - would otherwise insert a script
210
+ // per instance
211
+ const NONCE_BUILT = "__jq79built"
212
+
213
+ const buildWithNonce = (params: string[], body: string, suffix: string, nonce: string): Function => {
214
+ const script = document.createElement("script")
215
+ script.setAttribute("nonce", nonce)
216
+ script.textContent = `document.currentScript.${NONCE_BUILT} = ${functionText(params, body)}${suffix}`
217
+ let syntaxError: unknown
218
+ const onError = (event: ErrorEvent) => {
219
+ syntaxError = event.error ?? new SyntaxError(event.message)
220
+ event.preventDefault()
221
+ }
222
+ window.addEventListener("error", onError)
223
+ try {
224
+ (document.head ?? document.documentElement).append(script)
225
+ } finally {
226
+ window.removeEventListener("error", onError)
227
+ script.remove()
228
+ }
229
+ const fn = (script as any)[NONCE_BUILT]
230
+ if (typeof fn === "function") {
231
+ precompiled.set(functionKey(params, body), fn)
232
+ return fn
233
+ }
234
+ if (syntaxError !== undefined) throw syntaxError
235
+ throw new SafeEvalMiss(
236
+ "could not be built",
237
+ // the value stays out of the message: a nonce has no business in a log
238
+ "The page's CSP refused a <script> carrying the nonce jq79 read from the page."
239
+ )
240
+ }
241
+
242
+ // once per expression, like reportFailedExpr: a :each over 1000 rows misses
243
+ // 1000 times per render
244
+ const reportedSafeEvalMisses = new Set<string>()
245
+
246
+ const reportSafeEvalMiss = (expr: string, miss: SafeEvalMiss) => {
247
+ if (reportedSafeEvalMisses.has(expr)) return
248
+ reportedSafeEvalMisses.add(expr)
249
+ console.error(`jq79: safeEval() is on, and "${expr}" ${miss.reason}, so it rendered as nothing. ${miss.hint}`)
250
+ }
251
+
114
252
  // Resolves a name the `const` prologue could not: its fast read came back
115
253
  // undefined, which means one of three different things. `with` told them apart
116
254
  // by consulting [[HasProperty]] on every read of every name; this consults it
@@ -128,14 +266,13 @@ const resolveName = (scope: Record<string, any>, name: string): any => {
128
266
  throw new ReferenceError(`${name} is not defined`)
129
267
  }
130
268
 
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
269
  const compileWith = (expr: string, params: string[]): Function | null => {
136
270
  try {
137
- return new Function("$scope", "$r", ...params, `with ($scope) { return (${expr}\n); }`)
138
- } catch {
271
+ return makeFunction([...EXPR_PARAMS, ...params], withBody(expr))
272
+ } catch (error) {
273
+ // nothing precompiled, in a mode that won't compile: not a syntax error,
274
+ // though it is cached like one - it will not start compiling later either
275
+ if (error instanceof SafeEvalMiss) reportSafeEvalMiss(expr, error)
139
276
  return null // a syntax error: it will never compile, so don't try again
140
277
  }
141
278
  }
@@ -162,15 +299,13 @@ const compileWith = (expr: string, params: string[]): Function | null => {
162
299
  // differential in tests/expressions.test.ts is what stands behind it
163
300
  const compileScoped = (expr: string, params: string[]): Function | null => {
164
301
  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(" ")}`
302
+ const body = scopedBody(expr, params)
303
+ if (body === null) return null
304
+ // a safe-mode miss lands here too, and stays quiet: the caller falls back to
305
+ // the `with` form, which may well be the one that was precompiled - and
306
+ // reports if it wasn't
172
307
  try {
173
- return new Function("$scope", "$r", ...params, `${prologue} return (${expr}\n);`)
308
+ return makeFunction([...EXPR_PARAMS, ...params], body)
174
309
  } catch {
175
310
  return null
176
311
  }
@@ -374,39 +509,6 @@ const evalHandler = (expr: string, scope: Record<string, any>, extras: Record<st
374
509
  }
375
510
  }
376
511
 
377
- // [\s\S] rather than `.` so an expression can span lines, like the ones in
378
- // directive attributes (which reach evalExpr wrapped in parens either way)
379
- const INTERPOLATION_RE = /{{\s*([\s\S]+?)\s*}}/g
380
-
381
- // A text template split once into its literal and expression parts. The split
382
- // used to happen on every run of every instance - `String.replace` over the
383
- // whole text, a fresh match object and a callback per expression - and a
384
- // :each over 1,000 rows runs it 1,000 times per text node to reach the same
385
- // answer about the same string. Keyed by the template text, like compileExpr's
386
- // cache and bounded the same way: by how many distinct texts the source holds
387
- //
388
- // An expression part is boxed so a literal `"x"` and an expression `x` stay
389
- // distinguishable without a second array
390
- type TextPart = string | { expr: string }
391
-
392
- const textParts = new Map<string, TextPart[]>()
393
-
394
- const splitText = (template: string): TextPart[] => {
395
- const cached = textParts.get(template)
396
- if (cached) return cached
397
- const parts: TextPart[] = []
398
- let at = 0
399
- INTERPOLATION_RE.lastIndex = 0
400
- for (let match = INTERPOLATION_RE.exec(template); match; match = INTERPOLATION_RE.exec(template)) {
401
- if (match.index > at) parts.push(template.slice(at, match.index))
402
- parts.push({ expr: match[1] })
403
- at = match.index + match[0].length
404
- }
405
- if (at < template.length) parts.push(template.slice(at))
406
- textParts.set(template, parts)
407
- return parts
408
- }
409
-
410
512
  // what an interpolated text node renders to, from the parts. `?? ""` on each
411
513
  // expression, and String() over the join, is what template.replace did: a
412
514
  // nullish value contributes nothing and everything else is coerced
@@ -421,20 +523,6 @@ const renderText = (parts: TextPart[], scope: Record<string, any>): string => {
421
523
  }
422
524
 
423
525
 
424
- const CONTROL_ATTRS = new Set([":class", ":value", ":checked", ":selected", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html", ":html.allowed", ":props"])
425
-
426
- // a control attribute is one the static-attr loop and nested-component prop
427
- // collection must skip. The set holds the fixed names; `:class.<name>` (the
428
- // single-flag shorthand) and `:props.<n>` (one spread among several) are
429
- // open-ended, so they're matched by prefix - they can't be enumerated into the set
430
- const isControlAttr = (attr: string): boolean =>
431
- CONTROL_ATTRS.has(attr) || attr.startsWith(":class.") || attr.startsWith(":props.") ||
432
- attr === ":slot" || attr.startsWith(":slot.")
433
- // `item in items`, `item, i in items`, `(value, key) in props` - the second
434
- // binding is the array index or the object key, parens optional (Vue-style).
435
- // The list expression can span lines, so it matches [\s\S] rather than `.`
436
- const EACH_PATTERN = /^\s*\(?\s*(\w+)\s*(?:,\s*(\w+))?\s*\)?\s+in\s+([\s\S]+)$/
437
-
438
526
  type ConditionalBranch = { expr?: string; node: TemplateNode }
439
527
 
440
528
 
@@ -491,14 +579,6 @@ const wireTagEvent = (instance: Component79, attr: string, expr: string, scope:
491
579
  instance.on(name, listener)
492
580
  }
493
581
 
494
- const kebabToCamel = (name: string) => name.replace(/-(\w)/g, (_, c: string) => c.toUpperCase())
495
-
496
- // the inverse, used only by the pre-parse name rewrite (see expandNameCase):
497
- // uppercase ASCII letters only, never digits - `:props.0` is a generated
498
- // attribute name and splitting on digits would mangle it. Round-trips through
499
- // kebabToCamel, acronyms included: userID -> user-i-d -> userID
500
- const camelToKebab = (name: string) => name.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)
501
-
502
582
  // the stable boundaries of a rendered chunk. An element is its own handle, but
503
583
  // a fragment (a nested component: two anchors with the instance's DOM between
504
584
  // them) empties itself into the parent on insertion - after that its identity
@@ -626,9 +706,9 @@ const plainScopes = new WeakSet<object>()
626
706
  // opened and closed by hand rather than by a wrapper taking a callback: a
627
707
  // component that renders itself through :each stacks one renderEach per level,
628
708
  // and a callback would add a frame to each of them. The cyclic-component test
629
- // cuts off at 200 levels, and on a CI runner that extra frame per level was the
630
- // difference between cutting off and a RangeError - the same reason $effect
631
- // keeps its own shape (see reactive.ts)
709
+ // cuts off at MAX_NESTING_DEPTH levels, and on a CI runner that frame per level
710
+ // was the difference between cutting off and a RangeError - the same reason
711
+ // $effect keeps its own shape (see reactive.ts)
632
712
  type RenderPass = { memo: Map<string, string | null> | null; base: object | null }
633
713
 
634
714
  const openRenderPass = (base: Record<string, any>): RenderPass => {
@@ -714,8 +794,15 @@ const unresolvedComponent = (tag: string, scope: Record<string, any>): Error =>
714
794
 
715
795
  // how deep a component may nest inside itself before the runtime calls it a
716
796
  // cycle. Deeper than any real tree, shallower than the JS stack: a truncated
717
- // render with an error on the console beats a stack overflow with none
718
- const MAX_NESTING_DEPTH = 200
797
+ // render with an error on the console beats a stack overflow with none.
798
+ //
799
+ // It was 200, which is roughly where the stack actually gives out - measured at
800
+ // ~196 levels for the cyclic-data test on macOS arm64 - so the guard lost to the
801
+ // stack whenever a frame on the render path grew, and it did, three times. The
802
+ // ceiling is a property of the host (arm64 vs x64, worker vs main thread), so no
803
+ // number near it can be right everywhere; this one is half of the one ceiling
804
+ // that was measured. See RECORD/2026-09-06.the-depth-guard-lost-its-margin.md
805
+ const MAX_NESTING_DEPTH = 100
719
806
  let nestingDepth = 0
720
807
 
721
808
  // ---------------------------------------------------------------------------
@@ -770,15 +857,6 @@ type SlotMap = Record<string, SlotRenderer>
770
857
  // nested component is handed
771
858
  const SLOTS = Symbol("jq79.slots")
772
859
 
773
- // <slot>, <slot.header-bar>: the hole and its name. Names arrive kebab-case
774
- // whichever way they were authored (the HTML parser lowercases tag names and
775
- // attribute modifiers alike, so expandNameCase normalizes camelCase to kebab
776
- // before parsing) and are camelCase where read - <slot.header-bar> and
777
- // <slot.headerBar> are :slot.header-bar is $slots.headerBar
778
- const isSlotTag = (tag: string): boolean => tag === "slot" || tag.startsWith("slot.")
779
-
780
- const slotName = (suffix: string): string => (suffix ? kebabToCamel(suffix) : "default")
781
-
782
860
  // the content of one slot, as written at the usage site
783
861
  type SlotContent = { nodes: (TemplateNode | string)[]; binder?: string }
784
862
 
@@ -896,6 +974,9 @@ const makeSlotRenderer = (content: SlotContent, parentScope: Record<string, any>
896
974
  Object.defineProperty(scope, ALSO_WAKEN_BY, { value: [...inherited, slotScope] })
897
975
 
898
976
  const contentFx = createEffectScope(scope)
977
+ // and the row the <slot> sits in, when it is a keyed one: the slot props
978
+ // read that row's names, so a new item there has to reach this content
979
+ contentFx.ownedBy(slotScope)
899
980
  // rule 3: the <slot> is the content's lifetime. When the child's subtree at
900
981
  // this position goes - an :if turning false, the instance being replaced,
901
982
  // the whole child being destroyed - the content's effects go with it
@@ -1094,11 +1175,6 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
1094
1175
  // initial value would never reach the child
1095
1176
  const modelAttr = (name: string) => (name === "default" ? ":model" : `:model.${name}`)
1096
1177
  const modelProp = (name: string) => (name === "default" ? "model" : name)
1097
- // the newline keeps `= $value` out of a trailing line comment in the
1098
- // expression (:model="uname // the username") - glued on the same line,
1099
- // the assignment would vanish into the comment and compile as a bare read,
1100
- // dropping every update without a word
1101
- const assignment = (expr: string) => `${expr}\n= $value`
1102
1178
  // the models whose expression will never take an update, decided here rather
1103
1179
  // than at update time: an assignment that landed and one that was dropped
1104
1180
  // both evaluate to the value assigned, so the result can't tell them apart -
@@ -2128,6 +2204,12 @@ const renderConditional = (branches: ConditionalBranch[], scope: Record<string,
2128
2204
  current = boundsOf(rendered)
2129
2205
  anchor.parentNode!.insertBefore(rendered, anchor.nextSibling)
2130
2206
  })
2207
+ // the branch lives inside whatever holds this chain: when that goes - an
2208
+ // outer :if turning false, a :each row removed - the branch's effects go
2209
+ // with it. Left to this chain's own effect, they outlived it, and a write
2210
+ // that tore the outer branch down still woke them against the value that
2211
+ // had just emptied it (`toast = null` evaluating `toast.action.label`)
2212
+ fx.onDispose(() => branchFx?.dispose())
2131
2213
 
2132
2214
  return wrapper
2133
2215
  }
@@ -2209,10 +2291,14 @@ const isPlainObject = (value: any): value is Record<string, any> => {
2209
2291
  // :each="item in items" (or "item, i in items" / "(value, key) in props"),
2210
2292
  // optionally keyed with :key="expr". Only depends on what the list expression
2211
2293
  // reads, and on each run diffs by key: unchanged items (same key, same item
2212
- // reference) keep their DOM/effects, changed/added ones are (re)rendered,
2213
- // removed ones are disposed. Without :key, an array uses position - fine for
2294
+ // reference) keep their DOM/effects, added ones are rendered, removed ones are
2295
+ // disposed. A changed item - a different object under the same key - keeps its
2296
+ // row when the key was given with :key, which is what :key says: this is the
2297
+ // same row. Its DOM stays, and its bindings re-run against the new item
2298
+ // (see EffectScope.rerun). Without :key, an array uses position - fine for
2214
2299
  // append-only lists, wasteful for reordering - and an object uses the
2215
- // property key, which is already the stable identity. Each item gets its own
2300
+ // property key, which is already the stable identity; there a changed item is
2301
+ // rendered again. Each item gets its own
2216
2302
  // scope via Object.create(scope), so the bindings and `$index` shadow
2217
2303
  // same-named outer names without copying the parent scope's keys
2218
2304
  // does anything in this subtree name one of `names` as an identifier? Attribute
@@ -2230,6 +2316,17 @@ const mentionsAny = (node: TemplateNode | string, names: string[]): boolean => {
2230
2316
  node.children.some(child => mentionsAny(child, names))
2231
2317
  }
2232
2318
 
2319
+ // what renders into an effect scope of its own: a conditional chain, a list, a
2320
+ // component (a capitalized tag, or a dashed one - nothing else can resolve to
2321
+ // one, see componentKeyOf) and a <slot>, whose content does. Over-approximating
2322
+ // costs a defineProperty per row; missing one leaves its bindings on the old item
2323
+ const rendersScopes = (node: TemplateNode | string): boolean =>
2324
+ typeof node !== "string" && (
2325
+ ":if" in node.attrs || ":elseif" in node.attrs || ":else" in node.attrs || ":each" in node.attrs ||
2326
+ node.component !== undefined || node.tag.includes("-") || isSlotTag(node.tag) ||
2327
+ node.children.some(rendersScopes)
2328
+ )
2329
+
2233
2330
  // `name` as a whole word: `$index` must not match inside `$indexes`, and `i`
2234
2331
  // must not match inside `items`. `$` counts as a word character here, which is
2235
2332
  // why the boundaries are checked by hand rather than with \b - \b treats `$`
@@ -2267,6 +2364,9 @@ type EachPlan = {
2267
2364
  // can either loop name be mistaken for a component? Decided from the
2268
2365
  // template, so the item scope can be marked plain without being scanned
2269
2366
  namesComponent: boolean
2367
+ // can a row render anything into an effect scope of its own? Only then does
2368
+ // a keyed row need the OWNER mark that lets its re-run reach them
2369
+ nestsScopes: boolean
2270
2370
  }
2271
2371
 
2272
2372
  const eachPlans = new WeakMap<TemplateNode, EachPlan | null>()
@@ -2313,7 +2413,8 @@ const eachPlanOf = (node: TemplateNode): EachPlan | null => {
2313
2413
  const readsPosition = mentionsAny(itemNode, positionalNames)
2314
2414
 
2315
2415
  const namesComponent = /^[A-Z]/.test(itemName) || (atName !== undefined && /^[A-Z]/.test(atName))
2316
- const plan: EachPlan = { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent }
2416
+ const nestsScopes = keyExpr !== undefined && rendersScopes(itemNode)
2417
+ const plan: EachPlan = { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent, nestsScopes }
2317
2418
  eachPlans.set(node, plan)
2318
2419
  return plan
2319
2420
  }
@@ -2322,7 +2423,7 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
2322
2423
  const plan = eachPlanOf(node)
2323
2424
  if (!plan) return document.createComment(`invalid :each expression "${node.attrs[":each"]}"`)
2324
2425
 
2325
- const { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent } = plan
2426
+ const { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent, nestsScopes } = plan
2326
2427
 
2327
2428
  // `:key="row.id"`, or the loop variable itself, is what a key almost always
2328
2429
  // is - and reading one needs neither a scope to resolve names against nor a
@@ -2378,6 +2479,8 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
2378
2479
 
2379
2480
  const seen = new Set<any>()
2380
2481
  const moved: EachEntry[] = []
2482
+ // keyed rows handed a new item: kept, and re-run once they are in place
2483
+ const swapped: EachEntry[] = []
2381
2484
  const nextEntries: EachEntry[] = []
2382
2485
  // each entry's position in the previous pass, in the new order, for the
2383
2486
  // positioning walk below - -1 for one rendered here, which has none
@@ -2440,6 +2543,25 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
2440
2543
  continue
2441
2544
  }
2442
2545
 
2546
+ // the same row with a new item in it: `rows = items.map(i => ({ ...i }))`
2547
+ // hands every row a new object on every pass, and rendering them all
2548
+ // again threw away the DOM - and with it focus, scroll and anything a
2549
+ // user had typed - of a list whose content had not changed. Writing the
2550
+ // loop name is a plain assignment, for the reason the reuse path above
2551
+ // gives; the re-run that brings the bindings over to the new item waits
2552
+ // until the rows are in place
2553
+ if (existing && keyExpr !== undefined) {
2554
+ const entryScope = existing.scope
2555
+ entryScope[itemName] = item
2556
+ if (entryScope.$index !== index) entryScope.$index = index
2557
+ if (atName && entryScope[atName] !== at) entryScope[atName] = at
2558
+ existing.item = item
2559
+ swapped.push(existing)
2560
+ nextEntries.push(existing)
2561
+ positions.push(existing.pos)
2562
+ continue
2563
+ }
2564
+
2443
2565
  if (existing) {
2444
2566
  existing.fx.dispose()
2445
2567
  removeRange(existing.range)
@@ -2452,6 +2574,12 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
2452
2574
  // one WeakSet write per row against one Object.keys per element in it
2453
2575
  if (!namesComponent) plainScopes.add(itemScope)
2454
2576
  const itemFx = createEffectScope(scope)
2577
+ // a keyed row can be handed a new item later, and then everything
2578
+ // rendered inside it re-runs - including what lives in scopes of its
2579
+ // own, which register with the row through this (see OWNER). A row
2580
+ // with none has nothing to register: a defineProperty per row was
2581
+ // +3.9% on replacing 1,000 of them
2582
+ if (nestsScopes) Object.defineProperty(itemScope, OWNER, { value: itemFx })
2455
2583
  // bounds captured before the positioning pass inserts the entry: a
2456
2584
  // component entry is a fragment, which empties on insertion (see boundsOf)
2457
2585
  const range = boundsOf(renderNode(itemNode, itemScope, itemFx, shadow))
@@ -2487,12 +2615,19 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
2487
2615
  // the named key tracked nothing - refresh them so the move reaches those
2488
2616
  // too. Untracked, so these runs don't feed this list effect's own deps
2489
2617
  moved.forEach(entry => untracked(() => entry.fx.refresh()))
2618
+ // after the moves, so a binding that measures its row finds it where it
2619
+ // ends up. A row both moved and swapped is re-run here alone: rerun
2620
+ // covers everything refresh does
2621
+ swapped.forEach(entry => untracked(() => entry.fx.rerun()))
2490
2622
 
2491
2623
  entries = nextEntries
2492
2624
  } finally {
2493
2625
  closeRenderPass(pass)
2494
2626
  }
2495
2627
  })
2628
+ // the rows live inside whatever holds this list, as a :if branch does (see
2629
+ // renderConditional): torn down with it, not left subscribed to the store
2630
+ fx.onDispose(() => entries.forEach(entry => entry.fx.dispose()))
2496
2631
 
2497
2632
  return wrapper
2498
2633
  }
@@ -2862,181 +2997,6 @@ type ComponentParts = {
2862
2997
  name?: string
2863
2998
  }
2864
2999
 
2865
- const VOID_ELEMENTS = new Set([
2866
- "area", "base", "br", "col", "embed", "hr", "img", "input",
2867
- "link", "meta", "param", "source", "track", "wbr",
2868
- ])
2869
-
2870
- // a self-closing tag with its attributes; quoted attribute values are matched
2871
- // as whole chunks so a "/>" inside one doesn't end the tag early. The tag name
2872
- // admits a dot for the named forms of a tag - <slot.header /> - which is a
2873
- // legal HTML tag name (the tokenizer reads to the first space, "/" or ">")
2874
- const SELF_CLOSING_RE = /<([A-Za-z][\w.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)\/>/g
2875
- const RAW_BLOCK_RE = /(<script[\s\S]*?<\/script\s*>|<style[\s\S]*?<\/style\s*>)/gi
2876
-
2877
- // expands self-closing tags (<MyComponent />, <div />) into explicit
2878
- // open+close pairs BEFORE DOM parsing. The HTML parser ignores the slash and
2879
- // would treat them as unclosed, swallowing the following siblings. Void
2880
- // elements keep their native behavior, and <script>/<style> contents are
2881
- // passed through untouched so code inside them is never rewritten
2882
- const expandSelfClosingTags = (src: string): string =>
2883
- src
2884
- .split(RAW_BLOCK_RE)
2885
- .map((chunk, i) =>
2886
- i % 2 === 1 // odd chunks are the captured script/style blocks
2887
- ? chunk
2888
- : chunk.replace(SELF_CLOSING_RE, (match, tag: string, attrs: string) =>
2889
- VOID_ELEMENTS.has(tag.toLowerCase()) ? match : `<${tag}${attrs}></${tag}>`
2890
- )
2891
- )
2892
- .join("")
2893
-
2894
- // a start tag with its attributes, quote-aware so a ">" inside a value doesn't
2895
- // end it early; and a single spread attribute in name position (preceded by
2896
- // start-or-whitespace), its expression an identifier or member path
2897
- const OPEN_TAG_RE = /<([A-Za-z][\w.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g
2898
- const ATTR_SPREAD_RE = /"[^"]*"|'[^']*'|(^|\s)\.\.\.([A-Za-z_$][\w$.]*)/g
2899
-
2900
- // `...expr` as an attribute is sugar for :props="expr" (spread an object's
2901
- // properties as props - see renderNestedComponent). Rewritten BEFORE DOM
2902
- // parsing, into a value-based :props.<n>, because the HTML parser lowercases
2903
- // attribute *names*: with the expression in the name, `...userData` would arrive
2904
- // as `...userdata` and resolve to nothing. Moving it into a value - which the
2905
- // parser leaves untouched - keeps camelCase intact. Same pre-parse string move
2906
- // as expandSelfClosingTags, with the same defenses against rewriting code that
2907
- // only looks like a spread: <script>/<style> bodies are split out (a JS `...rest`
2908
- // there is not an attribute), only a start tag's interior is scanned (text
2909
- // between tags is safe), and quoted values are consumed whole so a genuine JS
2910
- // spread in a value (@click="f(...args)", :x="{ ...a }") is skipped. The <n>
2911
- // suffix (per tag) only keeps several spreads' attribute names distinct. A call
2912
- // (`...getProps()`) stops at the paren and is left alone - use :props="expr()"
2913
- const expandPropsSpread = (src: string): string =>
2914
- src
2915
- .split(RAW_BLOCK_RE)
2916
- .map((chunk, i) =>
2917
- i % 2 === 1
2918
- ? chunk
2919
- : chunk.replace(OPEN_TAG_RE, (_match, tag: string, attrs: string) => {
2920
- let n = 0
2921
- const rewritten = attrs.replace(ATTR_SPREAD_RE, (whole, space: string | undefined, expr: string | undefined) =>
2922
- expr === undefined ? whole : `${space}:props.${n++}="${expr}"`
2923
- )
2924
- return `<${tag}${rewritten}>`
2925
- })
2926
- )
2927
- .join("")
2928
-
2929
- // a `:`-prefixed attribute name in name position, and a </slot.name> closing
2930
- // tag. Both quote-aware for the same reason ATTR_SPREAD_RE is: a colon inside
2931
- // a value (@click="a ? b : c", style="color: red") is not an attribute name
2932
- const ATTR_NAME_RE = /"[^"]*"|'[^']*'|(^|\s)(:[\w.$-]+)/g
2933
- const CLOSE_SLOT_RE = /<\/slot\.([\w.$-]+)(\s*)>/gi
2934
- const SLOT_TAG_RE = /^slot\./i
2935
-
2936
- // camelCase -> kebab-case for every name the HTML parser would lowercase,
2937
- // BEFORE it gets the chance: `:firstName` would arrive as `:firstname` and
2938
- // kebabToCamel (which is what reads these names back out) would have nothing
2939
- // to un-kebab, so the prop, model or slot would silently land under the wrong
2940
- // key. Rewriting to `:first-name` here means both spellings converge on the
2941
- // same camelCase name downstream - the author picks, the runtime doesn't care.
2942
- //
2943
- // Runs FIRST among the pre-parse passes, which is what keeps it simple: it
2944
- // never sees the `:props.<n>` that expandPropsSpread generates, and a
2945
- // <slot.firstName /> is still one occurrence rather than the open+close pair
2946
- // expandSelfClosingTags turns it into. Same defenses as the passes after it -
2947
- // <script>/<style> bodies split out, only start-tag interiors scanned, quoted
2948
- // values consumed whole.
2949
- //
2950
- // Three name positions, not one: attribute names (`:model.firstName`), the
2951
- // dotted tag names (`<slot.firstName>`) and component tags (`<UserCard>`,
2952
- // renamed by componentTagName below). The last two have closing halves that are
2953
- // rewritten too, or the parser sees a mismatched pair
2954
- const kebabTagName = (tag: string): string =>
2955
- SLOT_TAG_RE.test(tag) ? `slot.${camelToKebab(tag.slice("slot.".length))}` : tag
2956
-
2957
- // the same pass records what it declined to rewrite. An uppercase-initial tag
2958
- // is a claim about a component: HTML's own elements are matched
2959
- // case-insensitively but nobody writes <DIV> by accident, and a custom element
2960
- // may not be spelled that way at all. So <UserCard> is a name the author
2961
- // expected to resolve - which is what lets renderNode throw when it doesn't
2962
- // (see unresolvedComponent).
2963
- //
2964
- // Carried in a *value* rather than left in the tag name, because the value is
2965
- // the one place the HTML parser preserves case - the same move expandPropsSpread
2966
- // makes for `...userData`, and for the same reason. elementToAST lifts it
2967
- // straight off attrs into a field, so no attribute loop downstream ever sees
2968
- // it - and since that lift is unconditional, the name has to be one no author
2969
- // would write: a plain `:component` would eat the prop of that name off
2970
- // <Card :component="Widget" />
2971
- const COMPONENT_TAG_ATTR = ":jq79-component"
2972
- const COMPONENT_TAG_RE = /^[A-Z]/
2973
-
2974
- // A component tag is renamed to a name the HTML parser cannot resolve to an
2975
- // element, because a PascalCase tag is lowercased by the parser and what comes
2976
- // out is *the native element of that name*: <Circle /> inside an <svg> is a
2977
- // circle, <Tr /> is a row placed inside its <tbody>, and 70 of 90 ordinary
2978
- // one-word component names collide the same way. The claim the author made -
2979
- // this is a component - survives in the stamp, and the tag stops being a name
2980
- // anything downstream can mistake for an element's.
2981
- //
2982
- // <Circle /> -> <c79-circle :jq79-component="Circle" />
2983
- // </UserCard> -> </c79-user-card>
2984
- //
2985
- // Hyphenated, and that is not cosmetic: `c79-circle` is a valid custom element
2986
- // name, so the parser builds an HTMLElement for it, where `c79circle` would be
2987
- // an HTMLUnknownElement. The hyphen is the shape the platform reserves for what
2988
- // is not native, which is the principle this rests on applied to our own tags -
2989
- // and it reads for itself in the inspector, where a component that resolves to
2990
- // nothing leaves <c79-circle> rather than a plausible-looking <circle>.
2991
- //
2992
- // Every capitalized tag, not only the colliding ones: today's safe name is
2993
- // tomorrow's element. See RECORD/2026-08-25.component-tag-prefix.md
2994
- const COMPONENT_TAG_PREFIX = "c79-"
2995
-
2996
- const componentTagName = (tag: string): string =>
2997
- `${COMPONENT_TAG_PREFIX}${camelToKebab(tag[0].toLowerCase() + tag.slice(1))}`
2998
-
2999
- const rewriteTagName = (tag: string): string =>
3000
- COMPONENT_TAG_RE.test(tag) ? componentTagName(tag) : kebabTagName(tag)
3001
-
3002
- // the closing half of the rename. OPEN_TAG_RE matches open tags only, which was
3003
- // fine while both ends lowercased to the same name; rename one end and not the
3004
- // other and `<c79-circle>` gets closed by `</circle>`, nesting everything that
3005
- // follows inside it. </slot.x> keeps its own pass - it is lowercase and
3006
- // unaffected by this one
3007
- const CLOSE_COMPONENT_RE = /<\/([A-Z][\w.-]*)(\s*)>/g
3008
-
3009
- // appends the stamp inside the tag, *before* a self-closing slash: this pass
3010
- // runs first and expandSelfClosingTags still has to recognize the `/>` that
3011
- // OPEN_TAG_RE swept into the attributes. A slash inside a quoted value can't be
3012
- // mistaken for it - only a trailing one is matched
3013
- const TRAILING_SLASH_RE = /\/\s*$/
3014
-
3015
- const stampComponentTag = (tag: string, attrs: string): string => {
3016
- if (!COMPONENT_TAG_RE.test(tag)) return attrs
3017
- const stamp = ` ${COMPONENT_TAG_ATTR}="${tag}"`
3018
- const slash = TRAILING_SLASH_RE.exec(attrs)
3019
- return slash ? `${attrs.slice(0, slash.index)}${stamp}${slash[0]}` : `${attrs}${stamp}`
3020
- }
3021
-
3022
- const expandNameCase = (src: string): string =>
3023
- src
3024
- .split(RAW_BLOCK_RE)
3025
- .map((chunk, i) =>
3026
- i % 2 === 1
3027
- ? chunk
3028
- : chunk
3029
- .replace(OPEN_TAG_RE, (_match, tag: string, attrs: string) => {
3030
- const rewritten = attrs.replace(ATTR_NAME_RE, (whole, space: string | undefined, name: string | undefined) =>
3031
- name === undefined ? whole : `${space}${camelToKebab(name)}`
3032
- )
3033
- return `<${rewriteTagName(tag)}${stampComponentTag(tag, rewritten)}>`
3034
- })
3035
- .replace(CLOSE_SLOT_RE, (_match, suffix: string, space: string) => `</slot.${camelToKebab(suffix)}${space}>`)
3036
- .replace(CLOSE_COMPONENT_RE, (_match, tag: string, space: string) => `</${componentTagName(tag)}${space}>`)
3037
- )
3038
- .join("")
3039
-
3040
3000
  // <style scoped> support. Every element of the component's own template is
3041
3001
  // stamped with data-jq79="<hash>" and the style's selectors are rewritten to
3042
3002
  // require that attribute, so its rules can't reach anything the component
@@ -3193,12 +3153,6 @@ const scopeCss = (css: string, scope: string): string => {
3193
3153
  return Array.from(sheet.cssRules).map(rule => rule.cssText).join("\n")
3194
3154
  }
3195
3155
 
3196
- // a component name has to be PascalCase to be usable: findComponentKey only
3197
- // ever considers capitalized scope keys, so a lowercase name would declare a
3198
- // component no tag could reference. It is also what keeps the named exports
3199
- // from colliding with a definition's own fields, which are all lowercase
3200
- const COMPONENT_NAME_RE = /^[A-Z][A-Za-z0-9]*$/
3201
-
3202
3156
  // converts a string of HTML into an AST representation of the component:
3203
3157
  // - template: the non-script/style top-level elements, as TemplateNodes
3204
3158
  // - scripts/styles: { attrs, content } blocks in source order
@@ -3227,7 +3181,7 @@ const parseComponentString = (component: string): ComponentParts => {
3227
3181
  // self-closing tag is still one occurrence), then `...expr` -> :props.<n>
3228
3182
  // (which reads the raw camelCase before the parser can lowercase names),
3229
3183
  // then self-closing tags
3230
- const prepared = expandSelfClosingTags(expandPropsSpread(expandNameCase(component)))
3184
+ const prepared = prepareSource(component)
3231
3185
  const parsedDOM = new DOMParser().parseFromString(`<template>${prepared}</template>`, "text/html")
3232
3186
  const root = parsedDOM.querySelector("template") as HTMLTemplateElement
3233
3187
 
@@ -3279,6 +3233,19 @@ const parseComponentString = (component: string): ComponentParts => {
3279
3233
  return parts
3280
3234
  }
3281
3235
 
3236
+ // the media types that mark a script as TypeScript, the other spelling of
3237
+ // `lang="ts"` (dev/vite.ts, TS_TYPE_RE, says why there are two). The plugin
3238
+ // compiles a block marked either way, so either mark still here means the same
3239
+ // thing
3240
+ const TS_SCRIPT_TYPE_RE = /^(?:text|application)\/(?:x-)?typescript$/i
3241
+
3242
+ // the mark a script is still carrying that says the plugin never compiled it,
3243
+ // spelled as the author spelled it
3244
+ const uncompiledScriptMark = (attrs: Record<string, string>): string | null =>
3245
+ "lang" in attrs ? `lang="${attrs.lang}"`
3246
+ : TS_SCRIPT_TYPE_RE.test(attrs.type?.trim() ?? "") ? `type="${attrs.type}"`
3247
+ : null
3248
+
3282
3249
  // the script/style/markup split of one component's top-level elements, with
3283
3250
  // <style scoped> resolved against the source those elements came from - the
3284
3251
  // whole file for its own component, a <template>'s contents for a named one,
@@ -3296,16 +3263,17 @@ const componentPartsFrom = (elements: Element[], hashSource: string): ComponentP
3296
3263
  else template.push(elementToAST(el))
3297
3264
  })
3298
3265
 
3299
- // <script lang="ts"> is compiled by the jq79/vite plugin, like <style lang>
3300
- // below, and a `lang` still here means the same thing: this component never
3266
+ // a TypeScript script is compiled by the jq79/vite plugin, like <style lang>
3267
+ // below, and a mark still here means the same thing: this component never
3301
3268
  // went through the bundler. It matters more on a script, because the failure
3302
3269
  // is not always loud - `interface`/`as`/generics throw at compile time, but
3303
3270
  // `let x: T = v` is a valid labeled statement, so it runs and leaves x
3304
3271
  // undeclared and non-reactive with nothing in the console
3305
3272
  scripts.forEach(script => {
3306
- if ("lang" in script.attrs) {
3273
+ const mark = uncompiledScriptMark(script.attrs)
3274
+ if (mark) {
3307
3275
  console.warn(
3308
- `jq79: <script lang="${script.attrs.lang}"> needs the jq79/vite plugin to compile it. ` +
3276
+ `jq79: <script ${mark}> needs the jq79/vite plugin to compile it. ` +
3309
3277
  "This component didn't go through the bundler, so its types were never stripped: the script " +
3310
3278
  "will throw, or - for a plain `let x: T = ...` - silently fail to declare x."
3311
3279
  )
@@ -3431,6 +3399,20 @@ type ScriptRun = { settled: Promise<unknown>; sync: boolean }
3431
3399
  const sourceUrlComment = (filename: string | undefined, index: number): string =>
3432
3400
  filename ? `\n//# sourceURL=${filename}?jq79-script=${index}` : ""
3433
3401
 
3402
+ // a script's function, named for devtools. A safe-mode miss throws, as a
3403
+ // script that doesn't compile always has - but saying which script, and why
3404
+ const compileScript = (params: string[], body: string, at: ScriptLocation): Function => {
3405
+ try {
3406
+ return makeFunction(params, body, sourceUrlComment(at.filename, at.index ?? 0))
3407
+ } catch (error) {
3408
+ if (!(error instanceof SafeEvalMiss)) throw error
3409
+ throw new Error(
3410
+ `jq79: safeEval() is on, and script ${at.index ?? 0} of ${at.filename ?? "a component built from a string"} ` +
3411
+ `${error.reason}. ${error.hint}`
3412
+ )
3413
+ }
3414
+ }
3415
+
3434
3416
  // what a <style> block injects into document.head: the scoped rewrite when it
3435
3417
  // has one, the source otherwise. A shadow root uses `content` directly instead
3436
3418
  // - scoping is what a shadow root already does, and doing both would break the
@@ -3453,9 +3435,51 @@ const headStyle = (style: TagBlock): string => style.scoped ?? style.content
3453
3435
  // isConnected check is what makes it survive a head somebody emptied
3454
3436
  const WRAPPER_STYLE = `:where([${COMPONENT_BOX_ATTR}]) { display: contents }`
3455
3437
 
3438
+ // ---------------------------------------------------------------------------
3439
+ // styles under safe mode
3440
+ //
3441
+ // A <style> element is inline style to a CSP, and a `style-src` without
3442
+ // 'unsafe-inline' refuses it - checked in Chromium, for a component's <style>
3443
+ // and <style scoped> alike, and for the wrapper rule every component box
3444
+ // needs. A constructed stylesheet adopted by the document or a shadow root is
3445
+ // CSSOM, which no CSP directive governs: in the same browser, under
3446
+ // `style-src 'self'`, it applied - in the document, in a shadow root, and
3447
+ // after an insertRule - with no violation. So under safe mode, component
3448
+ // styles are adopted sheets.
3449
+ //
3450
+ // Only under safe mode: an adopted sheet cascades after every stylesheet of
3451
+ // the document, <link> and <style> alike, which is a different place from the
3452
+ // end of <head> - a page that didn't ask keeps the order it has. And only
3453
+ // where the browser adopts sheets at all; elsewhere it is <style> elements, as
3454
+ // before (RECORD/2026-09-23.no-unsafe-eval.md)
3455
+ // ---------------------------------------------------------------------------
3456
+
3457
+ const adoptsStyles = (): boolean =>
3458
+ safeEvalOn && typeof Document !== "undefined" && "adoptedStyleSheets" in Document.prototype
3459
+
3460
+ const constructedSheet = (css: string): CSSStyleSheet => {
3461
+ const sheet = new CSSStyleSheet()
3462
+ sheet.replaceSync(css)
3463
+ return sheet
3464
+ }
3465
+
3466
+ const adoptSheet = (target: Document | ShadowRoot, sheet: CSSStyleSheet) => {
3467
+ if (!target.adoptedStyleSheets.includes(sheet)) target.adoptedStyleSheets = [...target.adoptedStyleSheets, sheet]
3468
+ }
3469
+
3470
+ const unadoptSheet = (target: Document | ShadowRoot, sheet: CSSStyleSheet) => {
3471
+ target.adoptedStyleSheets = target.adoptedStyleSheets.filter(adopted => adopted !== sheet)
3472
+ }
3473
+
3456
3474
  let wrapperStyleEl: HTMLStyleElement | null = null
3475
+ // one sheet serves every root that adopts it: the document, and each shadow root
3476
+ let wrapperSheet: CSSStyleSheet | null = null
3457
3477
 
3458
3478
  const ensureWrapperStyle = () => {
3479
+ if (adoptsStyles()) {
3480
+ adoptSheet(document, wrapperSheet ??= constructedSheet(WRAPPER_STYLE))
3481
+ return
3482
+ }
3459
3483
  if (wrapperStyleEl?.isConnected) return
3460
3484
  wrapperStyleEl = document.createElement("style")
3461
3485
  wrapperStyleEl.textContent = WRAPPER_STYLE
@@ -3464,16 +3488,23 @@ const ensureWrapperStyle = () => {
3464
3488
 
3465
3489
  // document.head styles are shared by content and refcounted, so N instances
3466
3490
  // of the same component (e.g. one per :each item) inject a single <style> tag
3467
- // that goes away when the last instance is destroyed
3468
- const styleRegistry = new Map<string, { el: HTMLStyleElement; count: number }>()
3491
+ // that goes away when the last instance is destroyed - or, under safe mode, a
3492
+ // single adopted sheet
3493
+ const styleRegistry = new Map<string, { el?: HTMLStyleElement; sheet?: CSSStyleSheet; count: number }>()
3469
3494
 
3470
3495
  const acquireStyle = (content: string) => {
3471
3496
  let entry = styleRegistry.get(content)
3472
3497
  if (!entry) {
3473
- const el = document.createElement("style")
3474
- el.textContent = content
3475
- document.head.appendChild(el)
3476
- entry = { el, count: 0 }
3498
+ if (adoptsStyles()) {
3499
+ const sheet = constructedSheet(content)
3500
+ adoptSheet(document, sheet)
3501
+ entry = { sheet, count: 0 }
3502
+ } else {
3503
+ const el = document.createElement("style")
3504
+ el.textContent = content
3505
+ document.head.appendChild(el)
3506
+ entry = { el, count: 0 }
3507
+ }
3477
3508
  styleRegistry.set(content, entry)
3478
3509
  }
3479
3510
  entry.count++
@@ -3482,7 +3513,8 @@ const acquireStyle = (content: string) => {
3482
3513
  const releaseStyle = (content: string) => {
3483
3514
  const entry = styleRegistry.get(content)
3484
3515
  if (entry && --entry.count <= 0) {
3485
- entry.el.remove()
3516
+ if (entry.sheet) unadoptSheet(document, entry.sheet)
3517
+ else entry.el?.remove()
3486
3518
  styleRegistry.delete(content)
3487
3519
  }
3488
3520
  }
@@ -3519,10 +3551,9 @@ const runSetupScript = (code: string, scope: Record<string, any>, effect: (run:
3519
3551
  (Reflect.has(target, key) || !(key in globalThis) && !(key in helpers)),
3520
3552
  })
3521
3553
  const state: { done?: boolean } = {}
3522
- const result: Promise<void> = new Function(
3523
- "$scope", "$__effect", "$__import", "$__state", ...Object.keys(helpers),
3524
- `return (async () => { with ($scope) { ${code} }\n;$__state.done = true })()${sourceUrlComment(at.filename, at.index ?? 0)}`
3525
- )(scriptScope, effect, importer, state, ...Object.values(helpers))
3554
+ const result: Promise<void> = compileScript(setupParams(Object.keys(helpers)), setupBody(code), at)(
3555
+ scriptScope, effect, importer, state, ...Object.values(helpers)
3556
+ )
3526
3557
  result.catch(error => console.error("jq79: error in :setup script", error))
3527
3558
  trackScript(result)
3528
3559
  return { settled: result, sync: state.done === true }
@@ -3558,11 +3589,8 @@ const declareProps = (store: Record<string, any>, props: PropDecl[] | null) => {
3558
3589
  // with no :setup at all) stays `null`, so its signature is still read from the
3559
3590
  // factory's first parameter
3560
3591
  const setupSignature = (script: TagBlock): PropDecl[] | null => {
3561
- const pattern = script.attrs[":setup"]
3562
- if (pattern === undefined) return null
3563
- if (pattern.trim() === "") return []
3564
- const props = parsePropsPattern(pattern)
3565
- if (!props) warnUnreadableSignature(script, pattern)
3592
+ const props = readSetupSignature(script)
3593
+ if (!props && script.attrs[":setup"] !== undefined) warnUnreadableSignature(script, script.attrs[":setup"])
3566
3594
  return props
3567
3595
  }
3568
3596
 
@@ -3591,20 +3619,6 @@ const warnUnreadableSignature = (script: TagBlock, pattern: string) => {
3591
3619
  )
3592
3620
  }
3593
3621
 
3594
- // every prop name a component's scripts declare, across both script modes.
3595
- // Read before the store exists, because what a component declares decides
3596
- // which of its file's sibling components it can still see: declaring a name
3597
- // says it comes from the parent, so the file's own definition of that name is
3598
- // deliberately not in this component's scope
3599
- const declaredPropNames = (scripts: TagBlock[]): Set<string> => {
3600
- const names = new Set<string>()
3601
- scripts.forEach(script => {
3602
- const declarations = parseFactoryProps(script.content) ?? setupSignature(script)
3603
- declarations?.forEach(({ name }) => names.add(name))
3604
- })
3605
- return names
3606
- }
3607
-
3608
3622
  // the same names, but null when NO script declared a signature at all - the
3609
3623
  // distinction declareProps already keeps, and the only one that can decide
3610
3624
  // whether to filter what a parent passes. `<script :setup>` and
@@ -3718,10 +3732,9 @@ const interopDefault = (mod: any) => (mod && mod.default !== undefined ? mod.def
3718
3732
  const runFactoryScript = (code: string, scope: Record<string, any>, effect: (run: () => void) => void, instanceHelpers: Record<string, any> = {}, importer: (url: string) => Promise<any> = importResource, at: ScriptLocation = {}): ScriptRun => {
3719
3733
  const helpers = { ...SETUP_HELPERS, ...instanceHelpers }
3720
3734
  const $__exports: { default?: (props: Record<string, any>, ctx: Record<string, any>) => any; done?: boolean } = {}
3721
- const result: Promise<void> = new Function(
3722
- "$__exports", "$__default", "$__import", ...Object.keys(helpers),
3723
- `return (async () => { "use strict";\n${code}\n;$__exports.done = true })()${sourceUrlComment(at.filename, at.index ?? 0)}`
3724
- )($__exports, interopDefault, importer, ...Object.values(helpers))
3735
+ const result: Promise<void> = compileScript(factoryParams(Object.keys(helpers)), factoryBody(code), at)(
3736
+ $__exports, interopDefault, importer, ...Object.values(helpers)
3737
+ )
3725
3738
 
3726
3739
  const logError = (error: any) => console.error("jq79: error in factory script", error)
3727
3740
  let invoked = false
@@ -3889,11 +3902,84 @@ const warnIfStuck = (component: Component79, gates: Promise<void>[]) => {
3889
3902
  }
3890
3903
 
3891
3904
  const fetchComponent = async (url: string): Promise<Component79> => {
3892
- const response = await fetch(url)
3893
- if (!response.ok) throw new Error(`failed to fetch component from ${url}: ${response.status}`)
3905
+ // under safeEval()'s worker, the component's functions arrive beside it,
3906
+ // and both have to be in before anyone can render it
3907
+ const [text] = await Promise.all([
3908
+ fetch(url).then(response => {
3909
+ if (!response.ok) throw new Error(`failed to fetch component from ${url}: ${response.status}`)
3910
+ return response.text()
3911
+ }),
3912
+ safeEvalWorker?.then(() => loadPrecompiled(url)),
3913
+ ])
3894
3914
  // the URL names the component's scripts in devtools, and is where the
3895
3915
  // browser will look for the source when a breakpoint lands in one
3896
- return new Component79(await response.text(), { filename: url })
3916
+ return new Component79(text, { filename: url })
3917
+ }
3918
+
3919
+ // ---------------------------------------------------------------------------
3920
+ // safe eval's worker
3921
+ //
3922
+ // Without a bundler, a component's functions come from jq79-sw.js (src/sw.ts):
3923
+ // asked for `<url>?jq79-precompiled`, it fetches the component from the site
3924
+ // itself and answers with the script that registers its functions. Asked for
3925
+ // with a <script src>, which the CSP judges by URL - the site's own - and
3926
+ // carrying the page's nonce where there is one, for a CSP that works by nonce.
3927
+ // ---------------------------------------------------------------------------
3928
+
3929
+ // set by safeEval() when it registers the worker: settles once the worker
3930
+ // controls the page, which is when a `?jq79-precompiled` request reaches it
3931
+ let safeEvalWorker: Promise<void> | undefined
3932
+
3933
+ const DEFAULT_WORKER_URL = "/jq79-sw.js"
3934
+
3935
+ // registers the worker and waits until it controls this page. On a first visit
3936
+ // it claims the page as it activates; a page loaded past it (a hard reload)
3937
+ // isn't controlled until it asks, so it asks
3938
+ const startWorker = async (url: string): Promise<void> => {
3939
+ const container = typeof navigator === "undefined" ? undefined : navigator.serviceWorker
3940
+ if (!container) {
3941
+ throw new Error(
3942
+ "jq79: safeEval() compiles components in a service worker, and this page can't have one - service workers " +
3943
+ "need https (or localhost). Precompile with the jq79/vite plugin instead, or use safeEval({ nonce: true }) " +
3944
+ "on a page whose server issues a nonce."
3945
+ )
3946
+ }
3947
+ let registration: ServiceWorkerRegistration
3948
+ try {
3949
+ registration = await container.register(url)
3950
+ } catch (error) {
3951
+ throw new Error(
3952
+ `jq79: safeEval() couldn't register its service worker at ${url} (${(error as Error).message}). ` +
3953
+ "Serve jq79-sw.js from the jq79 package at your site's root, or pass its URL: safeEval({ worker: \"/path/jq79-sw.js\" })."
3954
+ )
3955
+ }
3956
+ await container.ready
3957
+ if (container.controller) return
3958
+ await new Promise<void>(resolve => {
3959
+ container.addEventListener("controllerchange", () => resolve(), { once: true })
3960
+ if (container.controller) resolve()
3961
+ else registration.active?.postMessage("jq79:claim")
3962
+ })
3963
+ }
3964
+
3965
+ // a component's precompiled script: the component's own URL (no fragment)
3966
+ // with the worker's parameter on it, loaded as a classic <script>
3967
+ const loadPrecompiled = (url: string): Promise<void> => {
3968
+ const at = new URL(url, document.baseURI)
3969
+ at.hash = ""
3970
+ at.searchParams.set(PRECOMPILED_PARAM, "")
3971
+ return new Promise((resolve, reject) => {
3972
+ const script = document.createElement("script")
3973
+ const nonce = pageNonce()
3974
+ if (nonce) script.setAttribute("nonce", nonce)
3975
+ script.src = at.href
3976
+ script.onload = () => { script.remove(); resolve() }
3977
+ script.onerror = () => {
3978
+ script.remove()
3979
+ reject(new Error(`jq79: the precompiled functions of ${url} did not load, and safeEval() can't render it without them`))
3980
+ }
3981
+ document.head.append(script)
3982
+ })
3897
3983
  }
3898
3984
 
3899
3985
  // a parsed single-file component. Typical lifecycle:
@@ -3953,6 +4039,11 @@ export class Component79 {
3953
4039
  // shadow rendering keeps per-instance <style> elements; head rendering goes
3954
4040
  // through the shared refcounted styleRegistry instead
3955
4041
  private styleEls: HTMLStyleElement[] = []
4042
+ // under safe mode, a shadow root's styles: the CSS, and the sheets built from
4043
+ // it once there is a shadow root to adopt them (see placeShadowStyles)
4044
+ private shadowCss: string[] | null = null
4045
+ private shadowSheets: CSSStyleSheet[] = []
4046
+ private sheetRoot: ShadowRoot | null = null
3956
4047
  private ownsSharedStyles = false
3957
4048
  private useShadow = false
3958
4049
  private mountRoot: Element | ShadowRoot | DocumentFragment | null = null
@@ -4052,7 +4143,7 @@ export class Component79 {
4052
4143
 
4053
4144
  // shadow styles live inline, right before the DOM they style (attach()
4054
4145
  // appends them ahead of the content), so they go back the same way
4055
- if (shadow) this.styleEls.forEach(el => parent.insertBefore(el, before))
4146
+ if (shadow) this.placeShadowStyles(parent, before)
4056
4147
  parent.insertBefore(this.content!, before)
4057
4148
  this.mountRoot = parent
4058
4149
  this.settleMounted()
@@ -4099,6 +4190,48 @@ export class Component79 {
4099
4190
  return { ...debugFlags }
4100
4191
  }
4101
4192
 
4193
+ // for a page whose CSP has no 'unsafe-eval': from here on the runtime never
4194
+ // calls `new Function`. Opt-in, so a page that doesn't call it behaves
4195
+ // exactly as before; global and one-way, like the CSP it exists for.
4196
+ //
4197
+ // await Component79.safeEval() // precompiled by the worker: a miss is reported
4198
+ // await Component79.safeEval({ nonce: true }) // no worker: every function built with the page's nonce
4199
+ //
4200
+ // Precompiled functions come first either way (RECORD/2026-09-23.no-unsafe-eval.md).
4201
+ //
4202
+ // The plain form registers jq79-sw.js - served from the site's root, or
4203
+ // wherever `worker` says - and resolves once it controls the page. From
4204
+ // then on a component fetched by URL (Component79.fetch, an import() of an
4205
+ // .html from a script) arrives with its functions, compiled by the worker
4206
+ // from the same file. A component built from a string has no file to
4207
+ // compile, and its misses are reported. `worker: false` registers nothing,
4208
+ // for a page whose functions reach it another way - the jq79/vite plugin's.
4209
+ //
4210
+ // `{ nonce: true }` is for a page whose server issues a fresh nonce per
4211
+ // response: it still turns the component's text into code in the browser, as
4212
+ // eval would, only through a door just jq79 holds the key to. A static host's
4213
+ // nonce never changes, and a nonce everyone knows protects nothing. It
4214
+ // registers no worker unless `worker` asks for one too.
4215
+ //
4216
+ // With no nonce on the page, or no worker to be had, the promise rejects -
4217
+ // and safe mode stays on: a page that asked for no eval never falls back to it
4218
+ static safeEval(options: { nonce?: boolean; worker?: string | false } = {}): Promise<void> {
4219
+ safeEvalOn = true
4220
+ const steps: Promise<void>[] = []
4221
+ if (options.nonce) {
4222
+ const nonce = pageNonce()
4223
+ if (nonce === undefined) {
4224
+ steps.push(Promise.reject(new Error(
4225
+ "jq79: safeEval({ nonce: true }) found no nonce on this page - no <script> carries one. " +
4226
+ "The nonce belongs on the script that loads the page, the one the CSP names."
4227
+ )))
4228
+ } else safeEvalNonce = nonce
4229
+ }
4230
+ const worker = options.worker ?? (options.nonce ? false : DEFAULT_WORKER_URL)
4231
+ if (worker !== false) steps.push(safeEvalWorker ??= startWorker(worker))
4232
+ return Promise.all(steps).then(() => undefined)
4233
+ }
4234
+
4102
4235
  static fetch(url: string): PendingComponent79 {
4103
4236
  if (Array.isArray(url)) throw new TypeError("Component79.fetch takes one URL; use fetchAll for an array")
4104
4237
  return new PendingComponent79(fetchComponent(url))
@@ -4143,7 +4276,7 @@ export class Component79 {
4143
4276
  // what this component can see of its file's other components, and which of
4144
4277
  // its declared props arrived empty - both decided by the signature, before
4145
4278
  // the store exists (see siblingsInScope / UNFILLED_PROPS)
4146
- const declared = declaredPropNames(this.scripts)
4279
+ const declared = declaredPropNames(this.scripts, setupSignature)
4147
4280
  const siblingScope = siblingsInScope(this.siblings, declared)
4148
4281
  const raw: Record<string, any> = siblingScope
4149
4282
  ? Object.assign(Object.create(siblingScope), data)
@@ -4224,7 +4357,9 @@ export class Component79 {
4224
4357
  // though the template renders after the scripts run, so they only find
4225
4358
  // something from post-await code or callbacks
4226
4359
  const endMarker = this.endMarker
4227
- const $$self = (selector: string): Element[] => {
4360
+ // typed as $ / $$ are (QueryOne / QueryAll in dom.ts): a tag name gives
4361
+ // its element, anything else an HTMLElement unless told
4362
+ const $$self = ((selector: string): Element[] => {
4228
4363
  const found: Element[] = []
4229
4364
  for (let node: Node | null = marker.nextSibling; node && node !== endMarker; node = node.nextSibling) {
4230
4365
  if (node instanceof Element) {
@@ -4233,8 +4368,8 @@ export class Component79 {
4233
4368
  }
4234
4369
  }
4235
4370
  return found
4236
- }
4237
- const $self = (selector: string): Element | null => $$self(selector)[0] ?? null
4371
+ }) as QueryAll
4372
+ const $self = ((selector: string): Element | null => $$self(selector)[0] ?? null) as QueryOne
4238
4373
 
4239
4374
  // import() calls whose specifier was pre-resolved by a bundler (the
4240
4375
  // modules map) get the bundled module; everything else falls back to the
@@ -4281,11 +4416,9 @@ export class Component79 {
4281
4416
  })
4282
4417
 
4283
4418
  // scripts run before the template renders so `$:` values are initialized;
4284
- // a `:mounted` script defers entirely until mount() instead. A top-level
4285
- // `export default` switches the script to factory mode (plain lexical JS)
4286
- // a `:mounted` script is deferred by prepending the await on the code's own
4287
- // first line, so deferring doesn't shift the lines devtools reports for it
4288
- const defer = (code: string) => `await $mounted();${code}`
4419
+ // a `:mounted` script defers entirely until mount() instead (see defer). A
4420
+ // top-level `export default` switches the script to factory mode (plain
4421
+ // lexical JS)
4289
4422
 
4290
4423
  // what the first render is still waiting for. A script holds the template
4291
4424
  // back until it returns or calls $mounted() - whichever comes first - so
@@ -4418,13 +4551,18 @@ export class Component79 {
4418
4551
  // the position carries nothing: :where() has no specificity, so an author
4419
4552
  // rule wins wherever it sits. What it does buy is that "the shadow root's
4420
4553
  // style" still means the component's own
4421
- const wrapperEl = document.createElement("style")
4422
- wrapperEl.textContent = WRAPPER_STYLE
4423
- this.styleEls = [...this.styles.map(style => {
4424
- const el = document.createElement("style")
4425
- el.textContent = style.content // the source: a shadow root scopes it already
4426
- return el
4427
- }), wrapperEl]
4554
+ if (adoptsStyles()) {
4555
+ // sheets, and only once there is a shadow root to adopt them
4556
+ this.shadowCss = [...this.styles.map(style => style.content), WRAPPER_STYLE]
4557
+ } else {
4558
+ const wrapperEl = document.createElement("style")
4559
+ wrapperEl.textContent = WRAPPER_STYLE
4560
+ this.styleEls = [...this.styles.map(style => {
4561
+ const el = document.createElement("style")
4562
+ el.textContent = style.content // the source: a shadow root scopes it already
4563
+ return el
4564
+ }), wrapperEl]
4565
+ }
4428
4566
  } else {
4429
4567
  this.styles.forEach(style => acquireStyle(headStyle(style)))
4430
4568
  this.ownsSharedStyles = true
@@ -4461,13 +4599,35 @@ export class Component79 {
4461
4599
  const root = this.useShadow && target instanceof Element
4462
4600
  ? target.shadowRoot ?? target.attachShadow({ mode: "open" })
4463
4601
  : target
4464
- if (this.useShadow) this.styleEls.forEach(el => root.appendChild(el))
4602
+ if (this.useShadow) this.placeShadowStyles(root, null)
4465
4603
  root.appendChild(this.content!)
4466
4604
  this.mountRoot = root
4467
4605
  this.settleMounted()
4468
4606
  return this
4469
4607
  }
4470
4608
 
4609
+ // where a shadow-rendered component's styles go: <style> elements ahead of
4610
+ // its content, as always - or, under safe mode, sheets adopted by the shadow
4611
+ // root. Built on first placement, since only then is there a root; one that
4612
+ // isn't a ShadowRoot (a fragment) can't adopt, and gets elements after all
4613
+ private placeShadowStyles(root: Node, before: Node | null) {
4614
+ if (this.shadowCss && typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot) {
4615
+ if (this.sheetRoot && this.sheetRoot !== root) this.shadowSheets.forEach(sheet => unadoptSheet(this.sheetRoot!, sheet))
4616
+ if (this.shadowSheets.length === 0) this.shadowSheets = this.shadowCss.map(css => css === WRAPPER_STYLE ? (wrapperSheet ??= constructedSheet(css)) : constructedSheet(css))
4617
+ this.shadowSheets.forEach(sheet => adoptSheet(root, sheet))
4618
+ this.sheetRoot = root
4619
+ return
4620
+ }
4621
+ if (this.shadowCss && this.styleEls.length === 0) {
4622
+ this.styleEls = this.shadowCss.map(css => {
4623
+ const el = document.createElement("style")
4624
+ el.textContent = css
4625
+ return el
4626
+ })
4627
+ }
4628
+ this.styleEls.forEach(el => root.insertBefore(el, before))
4629
+ }
4630
+
4471
4631
  // `await $mounted()` means "rendered and on the page", so it waits for both -
4472
4632
  // whichever lands last calls this. In the ordinary synchronous flow the render
4473
4633
  // is already done and this is the attach; for a component whose first render
@@ -4504,6 +4664,12 @@ export class Component79 {
4504
4664
  this.data?.$dispose()
4505
4665
  this.styleEls.forEach(el => el.parentNode?.removeChild(el))
4506
4666
  this.styleEls = []
4667
+ // the wrapper sheet stays: other boxes in that root may need it, as the
4668
+ // wrapper <style> stays in the document
4669
+ if (this.sheetRoot) this.shadowSheets.forEach(sheet => { if (sheet !== wrapperSheet) unadoptSheet(this.sheetRoot!, sheet) })
4670
+ this.shadowCss = null
4671
+ this.shadowSheets = []
4672
+ this.sheetRoot = null
4507
4673
  if (this.ownsSharedStyles) {
4508
4674
  this.styles.forEach(style => releaseStyle(headStyle(style)))
4509
4675
  this.ownsSharedStyles = false
@@ -4604,11 +4770,30 @@ export class PendingComponent79 {
4604
4770
 
4605
4771
  export { Component79 as C79 }
4606
4772
 
4773
+ // a component that takes the props P: what a signature writes for a component
4774
+ // it takes as a prop - `:setup="{ Button }: { Button: Component<{ label: string }> }"` -
4775
+ // so a type-checker can hold the tags that use it, and the parents that pass
4776
+ // one, to P (RECORD/2026-10-01.component-as-prop.md). "~props" is a phantom:
4777
+ // never set, only in the types. A function of P, because props are passed *to*
4778
+ // a component: one that takes more than P (optionally) is a Component<P>, one
4779
+ // that requires something P doesn't have is not
4780
+ // E and S name the events a signature listens for and the slots it fills
4781
+ // (RECORD/2026-10-01.component-prop-events-and-slots.md): functions of them
4782
+ // too, so a component that emits or renders more is one, one that misses a
4783
+ // name is not. Their default, never, asks nothing
4784
+ export type Component<P = any, E extends string = never, S extends string = never> = Component79 & {
4785
+ readonly "~props"?: (props: P) => void
4786
+ readonly "~emits"?: (event: E) => void
4787
+ readonly "~slots"?: (slot: S) => void
4788
+ }
4789
+
4607
4790
  export const parseComponent = (component: string): Component79 => new Component79(component)
4608
4791
 
4609
4792
  // library helpers injected into setup scripts. They behave like extra
4610
4793
  // globals: a same-named scope property (render data or a top-level
4611
- // declaration) shadows them
4794
+ // declaration) shadows them. Their names, in this order, are also
4795
+ // SETUP_HELPER_NAMES (source.ts) - what precompile compiles scripts with, so
4796
+ // the two change together
4612
4797
  const SETUP_HELPERS: Record<string, any> = { $, $$, $create, $reactive, $toRaw, Component79 }
4613
4798
 
4614
4799
  // the hot-reload handshake. jq79/dev serves a classic script that sets the flag