jq79 0.7.2 → 0.7.4

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,12 +1,21 @@
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, $computed, 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"
9
- export { $reactive, $toRaw } from "./reactive"
16
+ export type { QueryOne, QueryAll } from "./dom"
17
+ export { $reactive, $toRaw, $computed } from "./reactive"
18
+ export type { Computed } from "./reactive"
10
19
 
11
20
  // the package version, substituted at build time (tsup/vitest `define`, read
12
21
  // from package.json - releases bump it there and nowhere else). The typeof
@@ -15,34 +24,6 @@ export { $reactive, $toRaw } from "./reactive"
15
24
  declare const __JQ79_VERSION__: string
16
25
  const VERSION = typeof __JQ79_VERSION__ === "string" ? __JQ79_VERSION__ : "0.0.0-dev"
17
26
 
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
27
  const elementAttrs = (el: Element): Record<string, string> =>
47
28
  Object.fromEntries(Array.from(el.attributes).map(attr => [attr.name, attr.value]))
48
29
 
@@ -111,6 +92,164 @@ type CompiledExpr = { fn: Function | null; scoped: boolean }
111
92
 
112
93
  const compiled = new Map<string, CompiledExpr>()
113
94
 
95
+ // ---------------------------------------------------------------------------
96
+ // safe eval
97
+ //
98
+ // Every function the runtime builds out of a component's text - an expression,
99
+ // a setup script, a factory script - is built by makeFunction. By default that
100
+ // is `new Function`, exactly as it always was. Two things stand in front of it:
101
+ //
102
+ // - precompiled functions, which scripts the browser loaded *as code* push onto
103
+ // a global queue, keyed by the very (params, body) the runtime would have
104
+ // handed `new Function` - so a hit is the same function by construction. A
105
+ // global rather than a call, so such a script can load before the library or
106
+ // after it, and serve either build of it (the module, or the CDN global)
107
+ // - safe mode (Component79.safeEval()), in which the runtime never calls
108
+ // `new Function`. That is what a CSP without 'unsafe-eval' demands, and
109
+ // where one throws EvalError that compileWith would swallow as a syntax
110
+ // error, safe mode says what is missing instead of rendering empty in silence.
111
+ // With `{ nonce: true }` a miss is built anyway - as a <script> carrying the
112
+ // page's nonce, which the CSP admits where it refuses eval (buildWithNonce)
113
+ //
114
+ // See RECORD/2026-09-23.no-unsafe-eval.md
115
+ // ---------------------------------------------------------------------------
116
+
117
+ // `fn: null` is a body that did not compile, cached as the syntax error it is
118
+ type PrecompiledEntry = [params: string[], body: string, fn: Function | null]
119
+
120
+
121
+ const precompiled = new Map<string, Function | null>()
122
+ let safeEvalOn = false
123
+ // set by safeEval({ nonce: true }), and then never unset: safe mode is one-way
124
+ let safeEvalNonce: string | undefined
125
+
126
+ // what safe mode could not provide. `reason` completes "<the code> ..." and
127
+ // `hint` says why, so an expression and a script report it the same way
128
+ class SafeEvalMiss extends Error {
129
+ constructor(readonly reason: string, readonly hint: string) {
130
+ super(`${reason}. ${hint}`)
131
+ }
132
+ }
133
+
134
+ const notPrecompiled = () => new SafeEvalMiss(
135
+ "was not precompiled",
136
+ "Safe mode never builds code at runtime: the component's precompiled functions did not reach the page."
137
+ )
138
+
139
+ // drains the queue first: a precompiled script may have loaded since the last
140
+ // lookup. With nothing registered - every page that never precompiled anything
141
+ // - the answer is known without building a key
142
+ const lookupPrecompiled = (params: string[], body: string): Function | null | undefined => {
143
+ const queue = (globalThis as any)[PRECOMPILED_QUEUE]
144
+ if (Array.isArray(queue) && queue.length > 0) {
145
+ for (const [entryParams, entryBody, fn] of queue.splice(0) as PrecompiledEntry[]) {
146
+ precompiled.set(functionKey(entryParams, entryBody), fn)
147
+ }
148
+ }
149
+ return precompiled.size === 0 ? undefined : precompiled.get(functionKey(params, body))
150
+ }
151
+
152
+ // `suffix` goes to `new Function` only and is not part of the key: it is the
153
+ // //# sourceURL that names a script for devtools, which a precompiled script
154
+ // has no use for (it has a URL of its own) and a generator couldn't spell the
155
+ // way the runtime does - it depends on how the component was loaded
156
+ const makeFunction = (params: string[], body: string, suffix = ""): Function => {
157
+ const hit = lookupPrecompiled(params, body)
158
+ if (hit === null) throw new SyntaxError("jq79: this code did not compile when it was precompiled")
159
+ if (hit !== undefined) return hit
160
+ if (safeEvalNonce !== undefined) return buildWithNonce(params, body, suffix, safeEvalNonce)
161
+ if (safeEvalOn) throw notPrecompiled()
162
+ return new Function(...params, body + suffix)
163
+ }
164
+
165
+ // the page's nonce, read off a script that carries one. The `.nonce` property
166
+ // and not the attribute: once the element is in a document under a CSP header,
167
+ // the browser blanks the attribute (so a stylesheet's attribute selector can't
168
+ // leak it) and keeps the value on the property - checked in Chromium, where
169
+ // getAttribute("nonce") read "" and .nonce the real value. The attribute is
170
+ // the fallback for an environment with no such property (jsdom).
171
+ //
172
+ // Searched for, because the library may have no script of its own to read: a
173
+ // module has no document.currentScript. A CSP that works by nonce always has
174
+ // one - the script that loaded the page
175
+ const pageNonce = (): string | undefined => {
176
+ if (typeof document === "undefined") return undefined
177
+ for (const script of Array.from(document.querySelectorAll<HTMLScriptElement>("script[nonce]"))) {
178
+ const nonce = script.nonce || script.getAttribute("nonce")
179
+ if (nonce) return nonce
180
+ }
181
+ return undefined
182
+ }
183
+
184
+ // builds what `new Function` would, as a classic <script> the CSP admits
185
+ // because it carries the page's nonce. Classic scripts are sloppy, so `with`
186
+ // compiles exactly as it does under `new Function`; the text is laid out as
187
+ // `new Function` lays it out ("function anonymous(params\n) {\nbody\n}"), so
188
+ // the function is named the same and devtools reports the same line numbers,
189
+ // and the //# sourceURL suffix names the script as it names the function today.
190
+ //
191
+ // An inline script runs synchronously when it is inserted, so this is a
192
+ // drop-in for the synchronous `new Function`. The function comes back on the
193
+ // element itself (document.currentScript) rather than through a global: nothing
194
+ // for the page to collide with, and nothing to clean up.
195
+ //
196
+ // Two ways it can fail, told apart by what the insertion left behind:
197
+ // - a syntax error is reported to window's `error` event rather than thrown to
198
+ // the inserter. The listener takes it and cancels the report, and it is
199
+ // rethrown here - where `new Function` threw it, and where compileWith
200
+ // caches it as the syntax error it always has been;
201
+ // - a nonce the CSP refuses leaves nothing at all: no function, no error
202
+ // event, only a violation the browser logs. That one says so.
203
+ //
204
+ // A body that closes the function's brace early (`a) } alert(1); { (`) is one
205
+ // place this differs: `new Function` refuses it, and a script runs what comes
206
+ // after the brace. The text is the component's own, which can hold a <script>
207
+ // anyway, so this admits nothing a component couldn't already do.
208
+ //
209
+ // Built functions go into the precompiled map: they hold no state, and a
210
+ // setup script - built once per *instance* - would otherwise insert a script
211
+ // per instance
212
+ const NONCE_BUILT = "__jq79built"
213
+
214
+ const buildWithNonce = (params: string[], body: string, suffix: string, nonce: string): Function => {
215
+ const script = document.createElement("script")
216
+ script.setAttribute("nonce", nonce)
217
+ script.textContent = `document.currentScript.${NONCE_BUILT} = ${functionText(params, body)}${suffix}`
218
+ let syntaxError: unknown
219
+ const onError = (event: ErrorEvent) => {
220
+ syntaxError = event.error ?? new SyntaxError(event.message)
221
+ event.preventDefault()
222
+ }
223
+ window.addEventListener("error", onError)
224
+ try {
225
+ (document.head ?? document.documentElement).append(script)
226
+ } finally {
227
+ window.removeEventListener("error", onError)
228
+ script.remove()
229
+ }
230
+ const fn = (script as any)[NONCE_BUILT]
231
+ if (typeof fn === "function") {
232
+ precompiled.set(functionKey(params, body), fn)
233
+ return fn
234
+ }
235
+ if (syntaxError !== undefined) throw syntaxError
236
+ throw new SafeEvalMiss(
237
+ "could not be built",
238
+ // the value stays out of the message: a nonce has no business in a log
239
+ "The page's CSP refused a <script> carrying the nonce jq79 read from the page."
240
+ )
241
+ }
242
+
243
+ // once per expression, like reportFailedExpr: a :each over 1000 rows misses
244
+ // 1000 times per render
245
+ const reportedSafeEvalMisses = new Set<string>()
246
+
247
+ const reportSafeEvalMiss = (expr: string, miss: SafeEvalMiss) => {
248
+ if (reportedSafeEvalMisses.has(expr)) return
249
+ reportedSafeEvalMisses.add(expr)
250
+ console.error(`jq79: safeEval() is on, and "${expr}" ${miss.reason}, so it rendered as nothing. ${miss.hint}`)
251
+ }
252
+
114
253
  // Resolves a name the `const` prologue could not: its fast read came back
115
254
  // undefined, which means one of three different things. `with` told them apart
116
255
  // by consulting [[HasProperty]] on every read of every name; this consults it
@@ -128,14 +267,13 @@ const resolveName = (scope: Record<string, any>, name: string): any => {
128
267
  throw new ReferenceError(`${name} is not defined`)
129
268
  }
130
269
 
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
270
  const compileWith = (expr: string, params: string[]): Function | null => {
136
271
  try {
137
- return new Function("$scope", "$r", ...params, `with ($scope) { return (${expr}\n); }`)
138
- } catch {
272
+ return makeFunction([...EXPR_PARAMS, ...params], withBody(expr))
273
+ } catch (error) {
274
+ // nothing precompiled, in a mode that won't compile: not a syntax error,
275
+ // though it is cached like one - it will not start compiling later either
276
+ if (error instanceof SafeEvalMiss) reportSafeEvalMiss(expr, error)
139
277
  return null // a syntax error: it will never compile, so don't try again
140
278
  }
141
279
  }
@@ -162,15 +300,13 @@ const compileWith = (expr: string, params: string[]): Function | null => {
162
300
  // differential in tests/expressions.test.ts is what stands behind it
163
301
  const compileScoped = (expr: string, params: string[]): Function | null => {
164
302
  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(" ")}`
303
+ const body = scopedBody(expr, params)
304
+ if (body === null) return null
305
+ // a safe-mode miss lands here too, and stays quiet: the caller falls back to
306
+ // the `with` form, which may well be the one that was precompiled - and
307
+ // reports if it wasn't
172
308
  try {
173
- return new Function("$scope", "$r", ...params, `${prologue} return (${expr}\n);`)
309
+ return makeFunction([...EXPR_PARAMS, ...params], body)
174
310
  } catch {
175
311
  return null
176
312
  }
@@ -374,39 +510,6 @@ const evalHandler = (expr: string, scope: Record<string, any>, extras: Record<st
374
510
  }
375
511
  }
376
512
 
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
513
  // what an interpolated text node renders to, from the parts. `?? ""` on each
411
514
  // expression, and String() over the join, is what template.replace did: a
412
515
  // nullish value contributes nothing and everything else is coerced
@@ -421,20 +524,6 @@ const renderText = (parts: TextPart[], scope: Record<string, any>): string => {
421
524
  }
422
525
 
423
526
 
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
527
  type ConditionalBranch = { expr?: string; node: TemplateNode }
439
528
 
440
529
 
@@ -491,14 +580,6 @@ const wireTagEvent = (instance: Component79, attr: string, expr: string, scope:
491
580
  instance.on(name, listener)
492
581
  }
493
582
 
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
583
  // the stable boundaries of a rendered chunk. An element is its own handle, but
503
584
  // a fragment (a nested component: two anchors with the instance's DOM between
504
585
  // them) empties itself into the parent on insertion - after that its identity
@@ -777,15 +858,6 @@ type SlotMap = Record<string, SlotRenderer>
777
858
  // nested component is handed
778
859
  const SLOTS = Symbol("jq79.slots")
779
860
 
780
- // <slot>, <slot.header-bar>: the hole and its name. Names arrive kebab-case
781
- // whichever way they were authored (the HTML parser lowercases tag names and
782
- // attribute modifiers alike, so expandNameCase normalizes camelCase to kebab
783
- // before parsing) and are camelCase where read - <slot.header-bar> and
784
- // <slot.headerBar> are :slot.header-bar is $slots.headerBar
785
- const isSlotTag = (tag: string): boolean => tag === "slot" || tag.startsWith("slot.")
786
-
787
- const slotName = (suffix: string): string => (suffix ? kebabToCamel(suffix) : "default")
788
-
789
861
  // the content of one slot, as written at the usage site
790
862
  type SlotContent = { nodes: (TemplateNode | string)[]; binder?: string }
791
863
 
@@ -903,6 +975,9 @@ const makeSlotRenderer = (content: SlotContent, parentScope: Record<string, any>
903
975
  Object.defineProperty(scope, ALSO_WAKEN_BY, { value: [...inherited, slotScope] })
904
976
 
905
977
  const contentFx = createEffectScope(scope)
978
+ // and the row the <slot> sits in, when it is a keyed one: the slot props
979
+ // read that row's names, so a new item there has to reach this content
980
+ contentFx.ownedBy(slotScope)
906
981
  // rule 3: the <slot> is the content's lifetime. When the child's subtree at
907
982
  // this position goes - an :if turning false, the instance being replaced,
908
983
  // the whole child being destroyed - the content's effects go with it
@@ -1101,11 +1176,6 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
1101
1176
  // initial value would never reach the child
1102
1177
  const modelAttr = (name: string) => (name === "default" ? ":model" : `:model.${name}`)
1103
1178
  const modelProp = (name: string) => (name === "default" ? "model" : name)
1104
- // the newline keeps `= $value` out of a trailing line comment in the
1105
- // expression (:model="uname // the username") - glued on the same line,
1106
- // the assignment would vanish into the comment and compile as a bare read,
1107
- // dropping every update without a word
1108
- const assignment = (expr: string) => `${expr}\n= $value`
1109
1179
  // the models whose expression will never take an update, decided here rather
1110
1180
  // than at update time: an assignment that landed and one that was dropped
1111
1181
  // both evaluate to the value assigned, so the result can't tell them apart -
@@ -2135,6 +2205,12 @@ const renderConditional = (branches: ConditionalBranch[], scope: Record<string,
2135
2205
  current = boundsOf(rendered)
2136
2206
  anchor.parentNode!.insertBefore(rendered, anchor.nextSibling)
2137
2207
  })
2208
+ // the branch lives inside whatever holds this chain: when that goes - an
2209
+ // outer :if turning false, a :each row removed - the branch's effects go
2210
+ // with it. Left to this chain's own effect, they outlived it, and a write
2211
+ // that tore the outer branch down still woke them against the value that
2212
+ // had just emptied it (`toast = null` evaluating `toast.action.label`)
2213
+ fx.onDispose(() => branchFx?.dispose())
2138
2214
 
2139
2215
  return wrapper
2140
2216
  }
@@ -2216,10 +2292,14 @@ const isPlainObject = (value: any): value is Record<string, any> => {
2216
2292
  // :each="item in items" (or "item, i in items" / "(value, key) in props"),
2217
2293
  // optionally keyed with :key="expr". Only depends on what the list expression
2218
2294
  // reads, and on each run diffs by key: unchanged items (same key, same item
2219
- // reference) keep their DOM/effects, changed/added ones are (re)rendered,
2220
- // removed ones are disposed. Without :key, an array uses position - fine for
2295
+ // reference) keep their DOM/effects, added ones are rendered, removed ones are
2296
+ // disposed. A changed item - a different object under the same key - keeps its
2297
+ // row when the key was given with :key, which is what :key says: this is the
2298
+ // same row. Its DOM stays, and its bindings re-run against the new item
2299
+ // (see EffectScope.rerun). Without :key, an array uses position - fine for
2221
2300
  // append-only lists, wasteful for reordering - and an object uses the
2222
- // property key, which is already the stable identity. Each item gets its own
2301
+ // property key, which is already the stable identity; there a changed item is
2302
+ // rendered again. Each item gets its own
2223
2303
  // scope via Object.create(scope), so the bindings and `$index` shadow
2224
2304
  // same-named outer names without copying the parent scope's keys
2225
2305
  // does anything in this subtree name one of `names` as an identifier? Attribute
@@ -2237,6 +2317,17 @@ const mentionsAny = (node: TemplateNode | string, names: string[]): boolean => {
2237
2317
  node.children.some(child => mentionsAny(child, names))
2238
2318
  }
2239
2319
 
2320
+ // what renders into an effect scope of its own: a conditional chain, a list, a
2321
+ // component (a capitalized tag, or a dashed one - nothing else can resolve to
2322
+ // one, see componentKeyOf) and a <slot>, whose content does. Over-approximating
2323
+ // costs a defineProperty per row; missing one leaves its bindings on the old item
2324
+ const rendersScopes = (node: TemplateNode | string): boolean =>
2325
+ typeof node !== "string" && (
2326
+ ":if" in node.attrs || ":elseif" in node.attrs || ":else" in node.attrs || ":each" in node.attrs ||
2327
+ node.component !== undefined || node.tag.includes("-") || isSlotTag(node.tag) ||
2328
+ node.children.some(rendersScopes)
2329
+ )
2330
+
2240
2331
  // `name` as a whole word: `$index` must not match inside `$indexes`, and `i`
2241
2332
  // must not match inside `items`. `$` counts as a word character here, which is
2242
2333
  // why the boundaries are checked by hand rather than with \b - \b treats `$`
@@ -2274,6 +2365,9 @@ type EachPlan = {
2274
2365
  // can either loop name be mistaken for a component? Decided from the
2275
2366
  // template, so the item scope can be marked plain without being scanned
2276
2367
  namesComponent: boolean
2368
+ // can a row render anything into an effect scope of its own? Only then does
2369
+ // a keyed row need the OWNER mark that lets its re-run reach them
2370
+ nestsScopes: boolean
2277
2371
  }
2278
2372
 
2279
2373
  const eachPlans = new WeakMap<TemplateNode, EachPlan | null>()
@@ -2320,7 +2414,8 @@ const eachPlanOf = (node: TemplateNode): EachPlan | null => {
2320
2414
  const readsPosition = mentionsAny(itemNode, positionalNames)
2321
2415
 
2322
2416
  const namesComponent = /^[A-Z]/.test(itemName) || (atName !== undefined && /^[A-Z]/.test(atName))
2323
- const plan: EachPlan = { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent }
2417
+ const nestsScopes = keyExpr !== undefined && rendersScopes(itemNode)
2418
+ const plan: EachPlan = { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent, nestsScopes }
2324
2419
  eachPlans.set(node, plan)
2325
2420
  return plan
2326
2421
  }
@@ -2329,7 +2424,7 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
2329
2424
  const plan = eachPlanOf(node)
2330
2425
  if (!plan) return document.createComment(`invalid :each expression "${node.attrs[":each"]}"`)
2331
2426
 
2332
- const { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent } = plan
2427
+ const { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent, nestsScopes } = plan
2333
2428
 
2334
2429
  // `:key="row.id"`, or the loop variable itself, is what a key almost always
2335
2430
  // is - and reading one needs neither a scope to resolve names against nor a
@@ -2385,6 +2480,8 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
2385
2480
 
2386
2481
  const seen = new Set<any>()
2387
2482
  const moved: EachEntry[] = []
2483
+ // keyed rows handed a new item: kept, and re-run once they are in place
2484
+ const swapped: EachEntry[] = []
2388
2485
  const nextEntries: EachEntry[] = []
2389
2486
  // each entry's position in the previous pass, in the new order, for the
2390
2487
  // positioning walk below - -1 for one rendered here, which has none
@@ -2447,6 +2544,25 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
2447
2544
  continue
2448
2545
  }
2449
2546
 
2547
+ // the same row with a new item in it: `rows = items.map(i => ({ ...i }))`
2548
+ // hands every row a new object on every pass, and rendering them all
2549
+ // again threw away the DOM - and with it focus, scroll and anything a
2550
+ // user had typed - of a list whose content had not changed. Writing the
2551
+ // loop name is a plain assignment, for the reason the reuse path above
2552
+ // gives; the re-run that brings the bindings over to the new item waits
2553
+ // until the rows are in place
2554
+ if (existing && keyExpr !== undefined) {
2555
+ const entryScope = existing.scope
2556
+ entryScope[itemName] = item
2557
+ if (entryScope.$index !== index) entryScope.$index = index
2558
+ if (atName && entryScope[atName] !== at) entryScope[atName] = at
2559
+ existing.item = item
2560
+ swapped.push(existing)
2561
+ nextEntries.push(existing)
2562
+ positions.push(existing.pos)
2563
+ continue
2564
+ }
2565
+
2450
2566
  if (existing) {
2451
2567
  existing.fx.dispose()
2452
2568
  removeRange(existing.range)
@@ -2459,6 +2575,12 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
2459
2575
  // one WeakSet write per row against one Object.keys per element in it
2460
2576
  if (!namesComponent) plainScopes.add(itemScope)
2461
2577
  const itemFx = createEffectScope(scope)
2578
+ // a keyed row can be handed a new item later, and then everything
2579
+ // rendered inside it re-runs - including what lives in scopes of its
2580
+ // own, which register with the row through this (see OWNER). A row
2581
+ // with none has nothing to register: a defineProperty per row was
2582
+ // +3.9% on replacing 1,000 of them
2583
+ if (nestsScopes) Object.defineProperty(itemScope, OWNER, { value: itemFx })
2462
2584
  // bounds captured before the positioning pass inserts the entry: a
2463
2585
  // component entry is a fragment, which empties on insertion (see boundsOf)
2464
2586
  const range = boundsOf(renderNode(itemNode, itemScope, itemFx, shadow))
@@ -2494,12 +2616,19 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
2494
2616
  // the named key tracked nothing - refresh them so the move reaches those
2495
2617
  // too. Untracked, so these runs don't feed this list effect's own deps
2496
2618
  moved.forEach(entry => untracked(() => entry.fx.refresh()))
2619
+ // after the moves, so a binding that measures its row finds it where it
2620
+ // ends up. A row both moved and swapped is re-run here alone: rerun
2621
+ // covers everything refresh does
2622
+ swapped.forEach(entry => untracked(() => entry.fx.rerun()))
2497
2623
 
2498
2624
  entries = nextEntries
2499
2625
  } finally {
2500
2626
  closeRenderPass(pass)
2501
2627
  }
2502
2628
  })
2629
+ // the rows live inside whatever holds this list, as a :if branch does (see
2630
+ // renderConditional): torn down with it, not left subscribed to the store
2631
+ fx.onDispose(() => entries.forEach(entry => entry.fx.dispose()))
2503
2632
 
2504
2633
  return wrapper
2505
2634
  }
@@ -2869,181 +2998,6 @@ type ComponentParts = {
2869
2998
  name?: string
2870
2999
  }
2871
3000
 
2872
- const VOID_ELEMENTS = new Set([
2873
- "area", "base", "br", "col", "embed", "hr", "img", "input",
2874
- "link", "meta", "param", "source", "track", "wbr",
2875
- ])
2876
-
2877
- // a self-closing tag with its attributes; quoted attribute values are matched
2878
- // as whole chunks so a "/>" inside one doesn't end the tag early. The tag name
2879
- // admits a dot for the named forms of a tag - <slot.header /> - which is a
2880
- // legal HTML tag name (the tokenizer reads to the first space, "/" or ">")
2881
- const SELF_CLOSING_RE = /<([A-Za-z][\w.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)\/>/g
2882
- const RAW_BLOCK_RE = /(<script[\s\S]*?<\/script\s*>|<style[\s\S]*?<\/style\s*>)/gi
2883
-
2884
- // expands self-closing tags (<MyComponent />, <div />) into explicit
2885
- // open+close pairs BEFORE DOM parsing. The HTML parser ignores the slash and
2886
- // would treat them as unclosed, swallowing the following siblings. Void
2887
- // elements keep their native behavior, and <script>/<style> contents are
2888
- // passed through untouched so code inside them is never rewritten
2889
- const expandSelfClosingTags = (src: string): string =>
2890
- src
2891
- .split(RAW_BLOCK_RE)
2892
- .map((chunk, i) =>
2893
- i % 2 === 1 // odd chunks are the captured script/style blocks
2894
- ? chunk
2895
- : chunk.replace(SELF_CLOSING_RE, (match, tag: string, attrs: string) =>
2896
- VOID_ELEMENTS.has(tag.toLowerCase()) ? match : `<${tag}${attrs}></${tag}>`
2897
- )
2898
- )
2899
- .join("")
2900
-
2901
- // a start tag with its attributes, quote-aware so a ">" inside a value doesn't
2902
- // end it early; and a single spread attribute in name position (preceded by
2903
- // start-or-whitespace), its expression an identifier or member path
2904
- const OPEN_TAG_RE = /<([A-Za-z][\w.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g
2905
- const ATTR_SPREAD_RE = /"[^"]*"|'[^']*'|(^|\s)\.\.\.([A-Za-z_$][\w$.]*)/g
2906
-
2907
- // `...expr` as an attribute is sugar for :props="expr" (spread an object's
2908
- // properties as props - see renderNestedComponent). Rewritten BEFORE DOM
2909
- // parsing, into a value-based :props.<n>, because the HTML parser lowercases
2910
- // attribute *names*: with the expression in the name, `...userData` would arrive
2911
- // as `...userdata` and resolve to nothing. Moving it into a value - which the
2912
- // parser leaves untouched - keeps camelCase intact. Same pre-parse string move
2913
- // as expandSelfClosingTags, with the same defenses against rewriting code that
2914
- // only looks like a spread: <script>/<style> bodies are split out (a JS `...rest`
2915
- // there is not an attribute), only a start tag's interior is scanned (text
2916
- // between tags is safe), and quoted values are consumed whole so a genuine JS
2917
- // spread in a value (@click="f(...args)", :x="{ ...a }") is skipped. The <n>
2918
- // suffix (per tag) only keeps several spreads' attribute names distinct. A call
2919
- // (`...getProps()`) stops at the paren and is left alone - use :props="expr()"
2920
- const expandPropsSpread = (src: string): string =>
2921
- src
2922
- .split(RAW_BLOCK_RE)
2923
- .map((chunk, i) =>
2924
- i % 2 === 1
2925
- ? chunk
2926
- : chunk.replace(OPEN_TAG_RE, (_match, tag: string, attrs: string) => {
2927
- let n = 0
2928
- const rewritten = attrs.replace(ATTR_SPREAD_RE, (whole, space: string | undefined, expr: string | undefined) =>
2929
- expr === undefined ? whole : `${space}:props.${n++}="${expr}"`
2930
- )
2931
- return `<${tag}${rewritten}>`
2932
- })
2933
- )
2934
- .join("")
2935
-
2936
- // a `:`-prefixed attribute name in name position, and a </slot.name> closing
2937
- // tag. Both quote-aware for the same reason ATTR_SPREAD_RE is: a colon inside
2938
- // a value (@click="a ? b : c", style="color: red") is not an attribute name
2939
- const ATTR_NAME_RE = /"[^"]*"|'[^']*'|(^|\s)(:[\w.$-]+)/g
2940
- const CLOSE_SLOT_RE = /<\/slot\.([\w.$-]+)(\s*)>/gi
2941
- const SLOT_TAG_RE = /^slot\./i
2942
-
2943
- // camelCase -> kebab-case for every name the HTML parser would lowercase,
2944
- // BEFORE it gets the chance: `:firstName` would arrive as `:firstname` and
2945
- // kebabToCamel (which is what reads these names back out) would have nothing
2946
- // to un-kebab, so the prop, model or slot would silently land under the wrong
2947
- // key. Rewriting to `:first-name` here means both spellings converge on the
2948
- // same camelCase name downstream - the author picks, the runtime doesn't care.
2949
- //
2950
- // Runs FIRST among the pre-parse passes, which is what keeps it simple: it
2951
- // never sees the `:props.<n>` that expandPropsSpread generates, and a
2952
- // <slot.firstName /> is still one occurrence rather than the open+close pair
2953
- // expandSelfClosingTags turns it into. Same defenses as the passes after it -
2954
- // <script>/<style> bodies split out, only start-tag interiors scanned, quoted
2955
- // values consumed whole.
2956
- //
2957
- // Three name positions, not one: attribute names (`:model.firstName`), the
2958
- // dotted tag names (`<slot.firstName>`) and component tags (`<UserCard>`,
2959
- // renamed by componentTagName below). The last two have closing halves that are
2960
- // rewritten too, or the parser sees a mismatched pair
2961
- const kebabTagName = (tag: string): string =>
2962
- SLOT_TAG_RE.test(tag) ? `slot.${camelToKebab(tag.slice("slot.".length))}` : tag
2963
-
2964
- // the same pass records what it declined to rewrite. An uppercase-initial tag
2965
- // is a claim about a component: HTML's own elements are matched
2966
- // case-insensitively but nobody writes <DIV> by accident, and a custom element
2967
- // may not be spelled that way at all. So <UserCard> is a name the author
2968
- // expected to resolve - which is what lets renderNode throw when it doesn't
2969
- // (see unresolvedComponent).
2970
- //
2971
- // Carried in a *value* rather than left in the tag name, because the value is
2972
- // the one place the HTML parser preserves case - the same move expandPropsSpread
2973
- // makes for `...userData`, and for the same reason. elementToAST lifts it
2974
- // straight off attrs into a field, so no attribute loop downstream ever sees
2975
- // it - and since that lift is unconditional, the name has to be one no author
2976
- // would write: a plain `:component` would eat the prop of that name off
2977
- // <Card :component="Widget" />
2978
- const COMPONENT_TAG_ATTR = ":jq79-component"
2979
- const COMPONENT_TAG_RE = /^[A-Z]/
2980
-
2981
- // A component tag is renamed to a name the HTML parser cannot resolve to an
2982
- // element, because a PascalCase tag is lowercased by the parser and what comes
2983
- // out is *the native element of that name*: <Circle /> inside an <svg> is a
2984
- // circle, <Tr /> is a row placed inside its <tbody>, and 70 of 90 ordinary
2985
- // one-word component names collide the same way. The claim the author made -
2986
- // this is a component - survives in the stamp, and the tag stops being a name
2987
- // anything downstream can mistake for an element's.
2988
- //
2989
- // <Circle /> -> <c79-circle :jq79-component="Circle" />
2990
- // </UserCard> -> </c79-user-card>
2991
- //
2992
- // Hyphenated, and that is not cosmetic: `c79-circle` is a valid custom element
2993
- // name, so the parser builds an HTMLElement for it, where `c79circle` would be
2994
- // an HTMLUnknownElement. The hyphen is the shape the platform reserves for what
2995
- // is not native, which is the principle this rests on applied to our own tags -
2996
- // and it reads for itself in the inspector, where a component that resolves to
2997
- // nothing leaves <c79-circle> rather than a plausible-looking <circle>.
2998
- //
2999
- // Every capitalized tag, not only the colliding ones: today's safe name is
3000
- // tomorrow's element. See RECORD/2026-08-25.component-tag-prefix.md
3001
- const COMPONENT_TAG_PREFIX = "c79-"
3002
-
3003
- const componentTagName = (tag: string): string =>
3004
- `${COMPONENT_TAG_PREFIX}${camelToKebab(tag[0].toLowerCase() + tag.slice(1))}`
3005
-
3006
- const rewriteTagName = (tag: string): string =>
3007
- COMPONENT_TAG_RE.test(tag) ? componentTagName(tag) : kebabTagName(tag)
3008
-
3009
- // the closing half of the rename. OPEN_TAG_RE matches open tags only, which was
3010
- // fine while both ends lowercased to the same name; rename one end and not the
3011
- // other and `<c79-circle>` gets closed by `</circle>`, nesting everything that
3012
- // follows inside it. </slot.x> keeps its own pass - it is lowercase and
3013
- // unaffected by this one
3014
- const CLOSE_COMPONENT_RE = /<\/([A-Z][\w.-]*)(\s*)>/g
3015
-
3016
- // appends the stamp inside the tag, *before* a self-closing slash: this pass
3017
- // runs first and expandSelfClosingTags still has to recognize the `/>` that
3018
- // OPEN_TAG_RE swept into the attributes. A slash inside a quoted value can't be
3019
- // mistaken for it - only a trailing one is matched
3020
- const TRAILING_SLASH_RE = /\/\s*$/
3021
-
3022
- const stampComponentTag = (tag: string, attrs: string): string => {
3023
- if (!COMPONENT_TAG_RE.test(tag)) return attrs
3024
- const stamp = ` ${COMPONENT_TAG_ATTR}="${tag}"`
3025
- const slash = TRAILING_SLASH_RE.exec(attrs)
3026
- return slash ? `${attrs.slice(0, slash.index)}${stamp}${slash[0]}` : `${attrs}${stamp}`
3027
- }
3028
-
3029
- const expandNameCase = (src: string): string =>
3030
- src
3031
- .split(RAW_BLOCK_RE)
3032
- .map((chunk, i) =>
3033
- i % 2 === 1
3034
- ? chunk
3035
- : chunk
3036
- .replace(OPEN_TAG_RE, (_match, tag: string, attrs: string) => {
3037
- const rewritten = attrs.replace(ATTR_NAME_RE, (whole, space: string | undefined, name: string | undefined) =>
3038
- name === undefined ? whole : `${space}${camelToKebab(name)}`
3039
- )
3040
- return `<${rewriteTagName(tag)}${stampComponentTag(tag, rewritten)}>`
3041
- })
3042
- .replace(CLOSE_SLOT_RE, (_match, suffix: string, space: string) => `</slot.${camelToKebab(suffix)}${space}>`)
3043
- .replace(CLOSE_COMPONENT_RE, (_match, tag: string, space: string) => `</${componentTagName(tag)}${space}>`)
3044
- )
3045
- .join("")
3046
-
3047
3001
  // <style scoped> support. Every element of the component's own template is
3048
3002
  // stamped with data-jq79="<hash>" and the style's selectors are rewritten to
3049
3003
  // require that attribute, so its rules can't reach anything the component
@@ -3200,12 +3154,6 @@ const scopeCss = (css: string, scope: string): string => {
3200
3154
  return Array.from(sheet.cssRules).map(rule => rule.cssText).join("\n")
3201
3155
  }
3202
3156
 
3203
- // a component name has to be PascalCase to be usable: findComponentKey only
3204
- // ever considers capitalized scope keys, so a lowercase name would declare a
3205
- // component no tag could reference. It is also what keeps the named exports
3206
- // from colliding with a definition's own fields, which are all lowercase
3207
- const COMPONENT_NAME_RE = /^[A-Z][A-Za-z0-9]*$/
3208
-
3209
3157
  // converts a string of HTML into an AST representation of the component:
3210
3158
  // - template: the non-script/style top-level elements, as TemplateNodes
3211
3159
  // - scripts/styles: { attrs, content } blocks in source order
@@ -3234,7 +3182,7 @@ const parseComponentString = (component: string): ComponentParts => {
3234
3182
  // self-closing tag is still one occurrence), then `...expr` -> :props.<n>
3235
3183
  // (which reads the raw camelCase before the parser can lowercase names),
3236
3184
  // then self-closing tags
3237
- const prepared = expandSelfClosingTags(expandPropsSpread(expandNameCase(component)))
3185
+ const prepared = prepareSource(component)
3238
3186
  const parsedDOM = new DOMParser().parseFromString(`<template>${prepared}</template>`, "text/html")
3239
3187
  const root = parsedDOM.querySelector("template") as HTMLTemplateElement
3240
3188
 
@@ -3286,10 +3234,10 @@ const parseComponentString = (component: string): ComponentParts => {
3286
3234
  return parts
3287
3235
  }
3288
3236
 
3289
- // the media types an editor reads as TypeScript. A component is a plain .html
3290
- // file, not an SFC, so no IDE knows what `lang` means inside one - embedded
3291
- // script tooling picks a language from `type` - and the plugin compiles a block
3292
- // marked either way. Either mark still here means the same thing
3237
+ // the media types that mark a script as TypeScript, the other spelling of
3238
+ // `lang="ts"` (dev/vite.ts, TS_TYPE_RE, says why there are two). The plugin
3239
+ // compiles a block marked either way, so either mark still here means the same
3240
+ // thing
3293
3241
  const TS_SCRIPT_TYPE_RE = /^(?:text|application)\/(?:x-)?typescript$/i
3294
3242
 
3295
3243
  // the mark a script is still carrying that says the plugin never compiled it,
@@ -3452,6 +3400,20 @@ type ScriptRun = { settled: Promise<unknown>; sync: boolean }
3452
3400
  const sourceUrlComment = (filename: string | undefined, index: number): string =>
3453
3401
  filename ? `\n//# sourceURL=${filename}?jq79-script=${index}` : ""
3454
3402
 
3403
+ // a script's function, named for devtools. A safe-mode miss throws, as a
3404
+ // script that doesn't compile always has - but saying which script, and why
3405
+ const compileScript = (params: string[], body: string, at: ScriptLocation): Function => {
3406
+ try {
3407
+ return makeFunction(params, body, sourceUrlComment(at.filename, at.index ?? 0))
3408
+ } catch (error) {
3409
+ if (!(error instanceof SafeEvalMiss)) throw error
3410
+ throw new Error(
3411
+ `jq79: safeEval() is on, and script ${at.index ?? 0} of ${at.filename ?? "a component built from a string"} ` +
3412
+ `${error.reason}. ${error.hint}`
3413
+ )
3414
+ }
3415
+ }
3416
+
3455
3417
  // what a <style> block injects into document.head: the scoped rewrite when it
3456
3418
  // has one, the source otherwise. A shadow root uses `content` directly instead
3457
3419
  // - scoping is what a shadow root already does, and doing both would break the
@@ -3474,9 +3436,51 @@ const headStyle = (style: TagBlock): string => style.scoped ?? style.content
3474
3436
  // isConnected check is what makes it survive a head somebody emptied
3475
3437
  const WRAPPER_STYLE = `:where([${COMPONENT_BOX_ATTR}]) { display: contents }`
3476
3438
 
3439
+ // ---------------------------------------------------------------------------
3440
+ // styles under safe mode
3441
+ //
3442
+ // A <style> element is inline style to a CSP, and a `style-src` without
3443
+ // 'unsafe-inline' refuses it - checked in Chromium, for a component's <style>
3444
+ // and <style scoped> alike, and for the wrapper rule every component box
3445
+ // needs. A constructed stylesheet adopted by the document or a shadow root is
3446
+ // CSSOM, which no CSP directive governs: in the same browser, under
3447
+ // `style-src 'self'`, it applied - in the document, in a shadow root, and
3448
+ // after an insertRule - with no violation. So under safe mode, component
3449
+ // styles are adopted sheets.
3450
+ //
3451
+ // Only under safe mode: an adopted sheet cascades after every stylesheet of
3452
+ // the document, <link> and <style> alike, which is a different place from the
3453
+ // end of <head> - a page that didn't ask keeps the order it has. And only
3454
+ // where the browser adopts sheets at all; elsewhere it is <style> elements, as
3455
+ // before (RECORD/2026-09-23.no-unsafe-eval.md)
3456
+ // ---------------------------------------------------------------------------
3457
+
3458
+ const adoptsStyles = (): boolean =>
3459
+ safeEvalOn && typeof Document !== "undefined" && "adoptedStyleSheets" in Document.prototype
3460
+
3461
+ const constructedSheet = (css: string): CSSStyleSheet => {
3462
+ const sheet = new CSSStyleSheet()
3463
+ sheet.replaceSync(css)
3464
+ return sheet
3465
+ }
3466
+
3467
+ const adoptSheet = (target: Document | ShadowRoot, sheet: CSSStyleSheet) => {
3468
+ if (!target.adoptedStyleSheets.includes(sheet)) target.adoptedStyleSheets = [...target.adoptedStyleSheets, sheet]
3469
+ }
3470
+
3471
+ const unadoptSheet = (target: Document | ShadowRoot, sheet: CSSStyleSheet) => {
3472
+ target.adoptedStyleSheets = target.adoptedStyleSheets.filter(adopted => adopted !== sheet)
3473
+ }
3474
+
3477
3475
  let wrapperStyleEl: HTMLStyleElement | null = null
3476
+ // one sheet serves every root that adopts it: the document, and each shadow root
3477
+ let wrapperSheet: CSSStyleSheet | null = null
3478
3478
 
3479
3479
  const ensureWrapperStyle = () => {
3480
+ if (adoptsStyles()) {
3481
+ adoptSheet(document, wrapperSheet ??= constructedSheet(WRAPPER_STYLE))
3482
+ return
3483
+ }
3480
3484
  if (wrapperStyleEl?.isConnected) return
3481
3485
  wrapperStyleEl = document.createElement("style")
3482
3486
  wrapperStyleEl.textContent = WRAPPER_STYLE
@@ -3485,16 +3489,23 @@ const ensureWrapperStyle = () => {
3485
3489
 
3486
3490
  // document.head styles are shared by content and refcounted, so N instances
3487
3491
  // of the same component (e.g. one per :each item) inject a single <style> tag
3488
- // that goes away when the last instance is destroyed
3489
- const styleRegistry = new Map<string, { el: HTMLStyleElement; count: number }>()
3492
+ // that goes away when the last instance is destroyed - or, under safe mode, a
3493
+ // single adopted sheet
3494
+ const styleRegistry = new Map<string, { el?: HTMLStyleElement; sheet?: CSSStyleSheet; count: number }>()
3490
3495
 
3491
3496
  const acquireStyle = (content: string) => {
3492
3497
  let entry = styleRegistry.get(content)
3493
3498
  if (!entry) {
3494
- const el = document.createElement("style")
3495
- el.textContent = content
3496
- document.head.appendChild(el)
3497
- entry = { el, count: 0 }
3499
+ if (adoptsStyles()) {
3500
+ const sheet = constructedSheet(content)
3501
+ adoptSheet(document, sheet)
3502
+ entry = { sheet, count: 0 }
3503
+ } else {
3504
+ const el = document.createElement("style")
3505
+ el.textContent = content
3506
+ document.head.appendChild(el)
3507
+ entry = { el, count: 0 }
3508
+ }
3498
3509
  styleRegistry.set(content, entry)
3499
3510
  }
3500
3511
  entry.count++
@@ -3503,7 +3514,8 @@ const acquireStyle = (content: string) => {
3503
3514
  const releaseStyle = (content: string) => {
3504
3515
  const entry = styleRegistry.get(content)
3505
3516
  if (entry && --entry.count <= 0) {
3506
- entry.el.remove()
3517
+ if (entry.sheet) unadoptSheet(document, entry.sheet)
3518
+ else entry.el?.remove()
3507
3519
  styleRegistry.delete(content)
3508
3520
  }
3509
3521
  }
@@ -3540,10 +3552,9 @@ const runSetupScript = (code: string, scope: Record<string, any>, effect: (run:
3540
3552
  (Reflect.has(target, key) || !(key in globalThis) && !(key in helpers)),
3541
3553
  })
3542
3554
  const state: { done?: boolean } = {}
3543
- const result: Promise<void> = new Function(
3544
- "$scope", "$__effect", "$__import", "$__state", ...Object.keys(helpers),
3545
- `return (async () => { with ($scope) { ${code} }\n;$__state.done = true })()${sourceUrlComment(at.filename, at.index ?? 0)}`
3546
- )(scriptScope, effect, importer, state, ...Object.values(helpers))
3555
+ const result: Promise<void> = compileScript(setupParams(Object.keys(helpers)), setupBody(code), at)(
3556
+ scriptScope, effect, importer, state, ...Object.values(helpers)
3557
+ )
3547
3558
  result.catch(error => console.error("jq79: error in :setup script", error))
3548
3559
  trackScript(result)
3549
3560
  return { settled: result, sync: state.done === true }
@@ -3579,11 +3590,8 @@ const declareProps = (store: Record<string, any>, props: PropDecl[] | null) => {
3579
3590
  // with no :setup at all) stays `null`, so its signature is still read from the
3580
3591
  // factory's first parameter
3581
3592
  const setupSignature = (script: TagBlock): PropDecl[] | null => {
3582
- const pattern = script.attrs[":setup"]
3583
- if (pattern === undefined) return null
3584
- if (pattern.trim() === "") return []
3585
- const props = parsePropsPattern(pattern)
3586
- if (!props) warnUnreadableSignature(script, pattern)
3593
+ const props = readSetupSignature(script)
3594
+ if (!props && script.attrs[":setup"] !== undefined) warnUnreadableSignature(script, script.attrs[":setup"])
3587
3595
  return props
3588
3596
  }
3589
3597
 
@@ -3612,20 +3620,6 @@ const warnUnreadableSignature = (script: TagBlock, pattern: string) => {
3612
3620
  )
3613
3621
  }
3614
3622
 
3615
- // every prop name a component's scripts declare, across both script modes.
3616
- // Read before the store exists, because what a component declares decides
3617
- // which of its file's sibling components it can still see: declaring a name
3618
- // says it comes from the parent, so the file's own definition of that name is
3619
- // deliberately not in this component's scope
3620
- const declaredPropNames = (scripts: TagBlock[]): Set<string> => {
3621
- const names = new Set<string>()
3622
- scripts.forEach(script => {
3623
- const declarations = parseFactoryProps(script.content) ?? setupSignature(script)
3624
- declarations?.forEach(({ name }) => names.add(name))
3625
- })
3626
- return names
3627
- }
3628
-
3629
3623
  // the same names, but null when NO script declared a signature at all - the
3630
3624
  // distinction declareProps already keeps, and the only one that can decide
3631
3625
  // whether to filter what a parent passes. `<script :setup>` and
@@ -3739,10 +3733,9 @@ const interopDefault = (mod: any) => (mod && mod.default !== undefined ? mod.def
3739
3733
  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 => {
3740
3734
  const helpers = { ...SETUP_HELPERS, ...instanceHelpers }
3741
3735
  const $__exports: { default?: (props: Record<string, any>, ctx: Record<string, any>) => any; done?: boolean } = {}
3742
- const result: Promise<void> = new Function(
3743
- "$__exports", "$__default", "$__import", ...Object.keys(helpers),
3744
- `return (async () => { "use strict";\n${code}\n;$__exports.done = true })()${sourceUrlComment(at.filename, at.index ?? 0)}`
3745
- )($__exports, interopDefault, importer, ...Object.values(helpers))
3736
+ const result: Promise<void> = compileScript(factoryParams(Object.keys(helpers)), factoryBody(code), at)(
3737
+ $__exports, interopDefault, importer, ...Object.values(helpers)
3738
+ )
3746
3739
 
3747
3740
  const logError = (error: any) => console.error("jq79: error in factory script", error)
3748
3741
  let invoked = false
@@ -3910,11 +3903,84 @@ const warnIfStuck = (component: Component79, gates: Promise<void>[]) => {
3910
3903
  }
3911
3904
 
3912
3905
  const fetchComponent = async (url: string): Promise<Component79> => {
3913
- const response = await fetch(url)
3914
- if (!response.ok) throw new Error(`failed to fetch component from ${url}: ${response.status}`)
3906
+ // under safeEval()'s worker, the component's functions arrive beside it,
3907
+ // and both have to be in before anyone can render it
3908
+ const [text] = await Promise.all([
3909
+ fetch(url).then(response => {
3910
+ if (!response.ok) throw new Error(`failed to fetch component from ${url}: ${response.status}`)
3911
+ return response.text()
3912
+ }),
3913
+ safeEvalWorker?.then(() => loadPrecompiled(url)),
3914
+ ])
3915
3915
  // the URL names the component's scripts in devtools, and is where the
3916
3916
  // browser will look for the source when a breakpoint lands in one
3917
- return new Component79(await response.text(), { filename: url })
3917
+ return new Component79(text, { filename: url })
3918
+ }
3919
+
3920
+ // ---------------------------------------------------------------------------
3921
+ // safe eval's worker
3922
+ //
3923
+ // Without a bundler, a component's functions come from jq79-sw.js (src/sw.ts):
3924
+ // asked for `<url>?jq79-precompiled`, it fetches the component from the site
3925
+ // itself and answers with the script that registers its functions. Asked for
3926
+ // with a <script src>, which the CSP judges by URL - the site's own - and
3927
+ // carrying the page's nonce where there is one, for a CSP that works by nonce.
3928
+ // ---------------------------------------------------------------------------
3929
+
3930
+ // set by safeEval() when it registers the worker: settles once the worker
3931
+ // controls the page, which is when a `?jq79-precompiled` request reaches it
3932
+ let safeEvalWorker: Promise<void> | undefined
3933
+
3934
+ const DEFAULT_WORKER_URL = "/jq79-sw.js"
3935
+
3936
+ // registers the worker and waits until it controls this page. On a first visit
3937
+ // it claims the page as it activates; a page loaded past it (a hard reload)
3938
+ // isn't controlled until it asks, so it asks
3939
+ const startWorker = async (url: string): Promise<void> => {
3940
+ const container = typeof navigator === "undefined" ? undefined : navigator.serviceWorker
3941
+ if (!container) {
3942
+ throw new Error(
3943
+ "jq79: safeEval() compiles components in a service worker, and this page can't have one - service workers " +
3944
+ "need https (or localhost). Precompile with the jq79/vite plugin instead, or use safeEval({ nonce: true }) " +
3945
+ "on a page whose server issues a nonce."
3946
+ )
3947
+ }
3948
+ let registration: ServiceWorkerRegistration
3949
+ try {
3950
+ registration = await container.register(url)
3951
+ } catch (error) {
3952
+ throw new Error(
3953
+ `jq79: safeEval() couldn't register its service worker at ${url} (${(error as Error).message}). ` +
3954
+ "Serve jq79-sw.js from the jq79 package at your site's root, or pass its URL: safeEval({ worker: \"/path/jq79-sw.js\" })."
3955
+ )
3956
+ }
3957
+ await container.ready
3958
+ if (container.controller) return
3959
+ await new Promise<void>(resolve => {
3960
+ container.addEventListener("controllerchange", () => resolve(), { once: true })
3961
+ if (container.controller) resolve()
3962
+ else registration.active?.postMessage("jq79:claim")
3963
+ })
3964
+ }
3965
+
3966
+ // a component's precompiled script: the component's own URL (no fragment)
3967
+ // with the worker's parameter on it, loaded as a classic <script>
3968
+ const loadPrecompiled = (url: string): Promise<void> => {
3969
+ const at = new URL(url, document.baseURI)
3970
+ at.hash = ""
3971
+ at.searchParams.set(PRECOMPILED_PARAM, "")
3972
+ return new Promise((resolve, reject) => {
3973
+ const script = document.createElement("script")
3974
+ const nonce = pageNonce()
3975
+ if (nonce) script.setAttribute("nonce", nonce)
3976
+ script.src = at.href
3977
+ script.onload = () => { script.remove(); resolve() }
3978
+ script.onerror = () => {
3979
+ script.remove()
3980
+ reject(new Error(`jq79: the precompiled functions of ${url} did not load, and safeEval() can't render it without them`))
3981
+ }
3982
+ document.head.append(script)
3983
+ })
3918
3984
  }
3919
3985
 
3920
3986
  // a parsed single-file component. Typical lifecycle:
@@ -3974,6 +4040,11 @@ export class Component79 {
3974
4040
  // shadow rendering keeps per-instance <style> elements; head rendering goes
3975
4041
  // through the shared refcounted styleRegistry instead
3976
4042
  private styleEls: HTMLStyleElement[] = []
4043
+ // under safe mode, a shadow root's styles: the CSS, and the sheets built from
4044
+ // it once there is a shadow root to adopt them (see placeShadowStyles)
4045
+ private shadowCss: string[] | null = null
4046
+ private shadowSheets: CSSStyleSheet[] = []
4047
+ private sheetRoot: ShadowRoot | null = null
3977
4048
  private ownsSharedStyles = false
3978
4049
  private useShadow = false
3979
4050
  private mountRoot: Element | ShadowRoot | DocumentFragment | null = null
@@ -3984,6 +4055,17 @@ export class Component79 {
3984
4055
  // must not resolve on attach alone - a script awaiting it would wake to an
3985
4056
  // empty component and find nothing to query
3986
4057
  private renderDone = false
4058
+ // what this render generation's scripts asked to run when it is torn down
4059
+ // ($destroyed). Created on the first call, and taken by destroy() before
4060
+ // anything else goes, so a hook still sees the DOM and the store
4061
+ private destroyHooks: (() => void)[] | null = null
4062
+ // this generation's $attached / $detached hooks, and whether it has been
4063
+ // told it is on the page. Only an instance that registered one of them is
4064
+ // in pageWatchers, which is what keeps a page that uses neither from paying
4065
+ // for a walk on every mount (see settleAttached)
4066
+ private attachHooks: (() => void)[] | null = null
4067
+ private detachHooks: (() => void)[] | null = null
4068
+ private onPage = false
3987
4069
  // instance-level listeners for $emit events, registered with on(). Kept
3988
4070
  // outside the render generation so they survive re-render and destroy()
3989
4071
  private emitListeners = new Map<string, Set<EmitListener>>()
@@ -4073,7 +4155,7 @@ export class Component79 {
4073
4155
 
4074
4156
  // shadow styles live inline, right before the DOM they style (attach()
4075
4157
  // appends them ahead of the content), so they go back the same way
4076
- if (shadow) this.styleEls.forEach(el => parent.insertBefore(el, before))
4158
+ if (shadow) this.placeShadowStyles(parent, before)
4077
4159
  parent.insertBefore(this.content!, before)
4078
4160
  this.mountRoot = parent
4079
4161
  this.settleMounted()
@@ -4120,6 +4202,48 @@ export class Component79 {
4120
4202
  return { ...debugFlags }
4121
4203
  }
4122
4204
 
4205
+ // for a page whose CSP has no 'unsafe-eval': from here on the runtime never
4206
+ // calls `new Function`. Opt-in, so a page that doesn't call it behaves
4207
+ // exactly as before; global and one-way, like the CSP it exists for.
4208
+ //
4209
+ // await Component79.safeEval() // precompiled by the worker: a miss is reported
4210
+ // await Component79.safeEval({ nonce: true }) // no worker: every function built with the page's nonce
4211
+ //
4212
+ // Precompiled functions come first either way (RECORD/2026-09-23.no-unsafe-eval.md).
4213
+ //
4214
+ // The plain form registers jq79-sw.js - served from the site's root, or
4215
+ // wherever `worker` says - and resolves once it controls the page. From
4216
+ // then on a component fetched by URL (Component79.fetch, an import() of an
4217
+ // .html from a script) arrives with its functions, compiled by the worker
4218
+ // from the same file. A component built from a string has no file to
4219
+ // compile, and its misses are reported. `worker: false` registers nothing,
4220
+ // for a page whose functions reach it another way - the jq79/vite plugin's.
4221
+ //
4222
+ // `{ nonce: true }` is for a page whose server issues a fresh nonce per
4223
+ // response: it still turns the component's text into code in the browser, as
4224
+ // eval would, only through a door just jq79 holds the key to. A static host's
4225
+ // nonce never changes, and a nonce everyone knows protects nothing. It
4226
+ // registers no worker unless `worker` asks for one too.
4227
+ //
4228
+ // With no nonce on the page, or no worker to be had, the promise rejects -
4229
+ // and safe mode stays on: a page that asked for no eval never falls back to it
4230
+ static safeEval(options: { nonce?: boolean; worker?: string | false } = {}): Promise<void> {
4231
+ safeEvalOn = true
4232
+ const steps: Promise<void>[] = []
4233
+ if (options.nonce) {
4234
+ const nonce = pageNonce()
4235
+ if (nonce === undefined) {
4236
+ steps.push(Promise.reject(new Error(
4237
+ "jq79: safeEval({ nonce: true }) found no nonce on this page - no <script> carries one. " +
4238
+ "The nonce belongs on the script that loads the page, the one the CSP names."
4239
+ )))
4240
+ } else safeEvalNonce = nonce
4241
+ }
4242
+ const worker = options.worker ?? (options.nonce ? false : DEFAULT_WORKER_URL)
4243
+ if (worker !== false) steps.push(safeEvalWorker ??= startWorker(worker))
4244
+ return Promise.all(steps).then(() => undefined)
4245
+ }
4246
+
4123
4247
  static fetch(url: string): PendingComponent79 {
4124
4248
  if (Array.isArray(url)) throw new TypeError("Component79.fetch takes one URL; use fetchAll for an array")
4125
4249
  return new PendingComponent79(fetchComponent(url))
@@ -4164,7 +4288,7 @@ export class Component79 {
4164
4288
  // what this component can see of its file's other components, and which of
4165
4289
  // its declared props arrived empty - both decided by the signature, before
4166
4290
  // the store exists (see siblingsInScope / UNFILLED_PROPS)
4167
- const declared = declaredPropNames(this.scripts)
4291
+ const declared = declaredPropNames(this.scripts, setupSignature)
4168
4292
  const siblingScope = siblingsInScope(this.siblings, declared)
4169
4293
  const raw: Record<string, any> = siblingScope
4170
4294
  ? Object.assign(Object.create(siblingScope), data)
@@ -4239,13 +4363,64 @@ export class Component79 {
4239
4363
  this.resolveMounted = resolveMounted
4240
4364
  this.renderDone = false
4241
4365
 
4366
+ // $destroyed(fn) runs fn when this generation is torn down: destroy(), a
4367
+ // re-render, a hot reload, or a parent's :if/:each removing it - never
4368
+ // detach(), which keeps state for a mount() to resume. Registered after
4369
+ // the generation is gone (a script that awaited past its own destroy), it
4370
+ // runs at once: holding it for a destroy that already happened would leak
4371
+ // exactly what it was written to stop. Returns the unregister
4372
+ const $destroyed = (fn: () => void): (() => void) => {
4373
+ if (typeof fn !== "function") throw new TypeError("jq79: $destroyed expects a function")
4374
+ if (marker !== this.startMarker) {
4375
+ runHook(fn, "$destroyed")
4376
+ return () => {}
4377
+ }
4378
+ return addHook((this.destroyHooks ??= []), fn)
4379
+ }
4380
+ // $attached(fn) runs fn every time this generation goes onto the page -
4381
+ // in the document, rendered - the first mount included; $detached(fn)
4382
+ // every time it leaves, and only after an $attached it balances. A nested
4383
+ // component hears its root's mount and detach (see settleAttached and
4384
+ // notifyDetached). `immediate`, on by default, also runs fn now when the
4385
+ // component is on the page already, so "while I'm on the page" holds
4386
+ // wherever the line sits - above `await $mounted()` or below it. A late
4387
+ // $detached runs at once, as a late $destroyed does; a late $attached has
4388
+ // nothing left to attach
4389
+ const $attached = (fn: () => void, { immediate = true }: { immediate?: boolean } = {}): (() => void) => {
4390
+ if (typeof fn !== "function") throw new TypeError("jq79: $attached expects a function")
4391
+ if (marker !== this.startMarker) return () => {}
4392
+ this.watchPage()
4393
+ const off = addHook((this.attachHooks ??= []), fn)
4394
+ if (immediate && this.onPage) runHook(fn, "$attached")
4395
+ return off
4396
+ }
4397
+ const $detached = (fn: () => void): (() => void) => {
4398
+ if (typeof fn !== "function") throw new TypeError("jq79: $detached expects a function")
4399
+ if (marker !== this.startMarker) {
4400
+ runHook(fn, "$detached")
4401
+ return () => {}
4402
+ }
4403
+ this.watchPage()
4404
+ return addHook((this.detachHooks ??= []), fn)
4405
+ }
4406
+ // the library's $computed, disposed with this generation: one made over a
4407
+ // shared store is otherwise held by that store, and kept recomputing, for
4408
+ // as long as the store lives
4409
+ const $instanceComputed = <T>(get: () => T) => {
4410
+ const computed = $computed(get)
4411
+ $destroyed(() => computed.$dispose())
4412
+ return computed
4413
+ }
4414
+
4242
4415
  // $self / $$self mirror $ / $$ but only search this instance's own
4243
4416
  // output: the sibling nodes between its markers. They work detached too
4244
4417
  // (the holding fragment keeps markers and rendered nodes as siblings),
4245
4418
  // though the template renders after the scripts run, so they only find
4246
4419
  // something from post-await code or callbacks
4247
4420
  const endMarker = this.endMarker
4248
- const $$self = (selector: string): Element[] => {
4421
+ // typed as $ / $$ are (QueryOne / QueryAll in dom.ts): a tag name gives
4422
+ // its element, anything else an HTMLElement unless told
4423
+ const $$self = ((selector: string): Element[] => {
4249
4424
  const found: Element[] = []
4250
4425
  for (let node: Node | null = marker.nextSibling; node && node !== endMarker; node = node.nextSibling) {
4251
4426
  if (node instanceof Element) {
@@ -4254,8 +4429,8 @@ export class Component79 {
4254
4429
  }
4255
4430
  }
4256
4431
  return found
4257
- }
4258
- const $self = (selector: string): Element | null => $$self(selector)[0] ?? null
4432
+ }) as QueryAll
4433
+ const $self = ((selector: string): Element | null => $$self(selector)[0] ?? null) as QueryOne
4259
4434
 
4260
4435
  // import() calls whose specifier was pre-resolved by a bundler (the
4261
4436
  // modules map) get the bundled module; everything else falls back to the
@@ -4302,11 +4477,9 @@ export class Component79 {
4302
4477
  })
4303
4478
 
4304
4479
  // scripts run before the template renders so `$:` values are initialized;
4305
- // a `:mounted` script defers entirely until mount() instead. A top-level
4306
- // `export default` switches the script to factory mode (plain lexical JS)
4307
- // a `:mounted` script is deferred by prepending the await on the code's own
4308
- // first line, so deferring doesn't shift the lines devtools reports for it
4309
- const defer = (code: string) => `await $mounted();${code}`
4480
+ // a `:mounted` script defers entirely until mount() instead (see defer). A
4481
+ // top-level `export default` switches the script to factory mode (plain
4482
+ // lexical JS)
4310
4483
 
4311
4484
  // what the first render is still waiting for. A script holds the template
4312
4485
  // back until it returns or calls $mounted() - whichever comes first - so
@@ -4339,7 +4512,7 @@ export class Component79 {
4339
4512
  // resolve to nothing at all. In setup mode this composes with `with` -
4340
4513
  // scriptScope's `has` declines any name that is a helper, so the
4341
4514
  // parameter is what the name resolves to
4342
- const instanceHelpers = { $mounted, $self, $$self, ...injected, ...siblingScope }
4515
+ const instanceHelpers = { $mounted, $destroyed, $attached, $detached, $computed: $instanceComputed, $self, $$self, ...injected, ...siblingScope }
4343
4516
  const at: ScriptLocation = { filename: this.filename, index }
4344
4517
  const deferred = ":mounted" in script.attrs
4345
4518
  const factoryCode = transformFactoryScript(script.content)
@@ -4429,6 +4602,8 @@ export class Component79 {
4429
4602
  this.endMarker!.parentNode!.insertBefore(renderNodes(this.template, templateScope, fx, shadow), this.endMarker!)
4430
4603
  this.renderDone = true
4431
4604
  this.settleMounted()
4605
+ // a held render that paints on the page is this component's attach
4606
+ Component79.settleAttached()
4432
4607
  })
4433
4608
  warnIfStuck(this, gates)
4434
4609
  }
@@ -4439,13 +4614,18 @@ export class Component79 {
4439
4614
  // the position carries nothing: :where() has no specificity, so an author
4440
4615
  // rule wins wherever it sits. What it does buy is that "the shadow root's
4441
4616
  // style" still means the component's own
4442
- const wrapperEl = document.createElement("style")
4443
- wrapperEl.textContent = WRAPPER_STYLE
4444
- this.styleEls = [...this.styles.map(style => {
4445
- const el = document.createElement("style")
4446
- el.textContent = style.content // the source: a shadow root scopes it already
4447
- return el
4448
- }), wrapperEl]
4617
+ if (adoptsStyles()) {
4618
+ // sheets, and only once there is a shadow root to adopt them
4619
+ this.shadowCss = [...this.styles.map(style => style.content), WRAPPER_STYLE]
4620
+ } else {
4621
+ const wrapperEl = document.createElement("style")
4622
+ wrapperEl.textContent = WRAPPER_STYLE
4623
+ this.styleEls = [...this.styles.map(style => {
4624
+ const el = document.createElement("style")
4625
+ el.textContent = style.content // the source: a shadow root scopes it already
4626
+ return el
4627
+ }), wrapperEl]
4628
+ }
4449
4629
  } else {
4450
4630
  this.styles.forEach(style => acquireStyle(headStyle(style)))
4451
4631
  this.ownsSharedStyles = true
@@ -4482,13 +4662,113 @@ export class Component79 {
4482
4662
  const root = this.useShadow && target instanceof Element
4483
4663
  ? target.shadowRoot ?? target.attachShadow({ mode: "open" })
4484
4664
  : target
4485
- if (this.useShadow) this.styleEls.forEach(el => root.appendChild(el))
4665
+ if (this.useShadow) this.placeShadowStyles(root, null)
4486
4666
  root.appendChild(this.content!)
4487
4667
  this.mountRoot = root
4488
4668
  this.settleMounted()
4669
+ Component79.settleAttached()
4489
4670
  return this
4490
4671
  }
4491
4672
 
4673
+ // whether this generation's DOM is in the document, template built - the
4674
+ // one meaning "on the page" has for $attached. Not "attach() was called": a
4675
+ // nested component is mounted into a fragment its parent inserts later, and
4676
+ // a root can be mounted into an element that isn't in the document
4677
+ private isOnPage(): boolean {
4678
+ return this.renderDone && this.startMarker?.isConnected === true
4679
+ }
4680
+
4681
+ // joins pageWatchers on the first $attached/$detached of a generation, with
4682
+ // the state as it is now: a hook registered while on the page must not fire
4683
+ // for an attach that already happened. Off the page, a settle is queued: a
4684
+ // component inserted by its parent's :if, :each or tag is built in a
4685
+ // fragment and put on the page later on this same stack, with no attach()
4686
+ // of its own to say so - one microtask late, never early
4687
+ private watchPage() {
4688
+ if (pageWatchers.has(this)) return
4689
+ pageWatchers.add(this)
4690
+ this.onPage = this.isOnPage()
4691
+ if (!this.onPage) Component79.queueSettle()
4692
+ }
4693
+
4694
+ private static queueSettle() {
4695
+ if (settleQueued) return
4696
+ settleQueued = true
4697
+ queueMicrotask(() => {
4698
+ settleQueued = false
4699
+ Component79.settleAttached()
4700
+ })
4701
+ }
4702
+
4703
+ // tells every watcher that is on the page and hasn't been told, in document
4704
+ // order - a parent before what it rendered, slot content included, because
4705
+ // the DOM is what says where a component is
4706
+ private static settleAttached() {
4707
+ if (pageWatchers.size === 0) return
4708
+ const due = [...pageWatchers].filter(component => !component.onPage && component.isOnPage())
4709
+ Component79.inDocumentOrder(due).forEach(component => {
4710
+ // a hook that ran before this one may have destroyed it
4711
+ if (component.onPage || !pageWatchers.has(component)) return
4712
+ component.onPage = true
4713
+ component.attachHooks?.slice().forEach(fn => runHook(fn, "$attached"))
4714
+ })
4715
+ }
4716
+
4717
+ // tells this component and every watcher whose DOM is under its markers
4718
+ // that they are leaving the page - before anything moves, so the hooks see
4719
+ // the DOM where it was. One whose DOM already left without it (an :if
4720
+ // removes its branch, then destroys what was in it) has only itself to tell
4721
+ private notifyDetached() {
4722
+ if (pageWatchers.size === 0) return
4723
+ let due: Component79[] = []
4724
+ if (this.startMarker?.isConnected) {
4725
+ const top = new Set<Node>()
4726
+ for (let node: Node | null = this.startMarker; node; node = node.nextSibling) {
4727
+ top.add(node)
4728
+ if (node === this.endMarker) break
4729
+ }
4730
+ const under = (node: Node | null): boolean => {
4731
+ for (; node; node = node.parentNode) if (top.has(node)) return true
4732
+ return false
4733
+ }
4734
+ due = Component79.inDocumentOrder([...pageWatchers].filter(component => component.onPage && under(component.startMarker)))
4735
+ } else if (this.onPage) {
4736
+ due = [this]
4737
+ }
4738
+ due.forEach(component => {
4739
+ if (!component.onPage) return
4740
+ component.onPage = false
4741
+ component.detachHooks?.slice().forEach(fn => runHook(fn, "$detached"))
4742
+ })
4743
+ }
4744
+
4745
+ private static inDocumentOrder(components: Component79[]): Component79[] {
4746
+ return components.sort((a, b) =>
4747
+ a.startMarker!.compareDocumentPosition(b.startMarker!) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1)
4748
+ }
4749
+
4750
+ // where a shadow-rendered component's styles go: <style> elements ahead of
4751
+ // its content, as always - or, under safe mode, sheets adopted by the shadow
4752
+ // root. Built on first placement, since only then is there a root; one that
4753
+ // isn't a ShadowRoot (a fragment) can't adopt, and gets elements after all
4754
+ private placeShadowStyles(root: Node, before: Node | null) {
4755
+ if (this.shadowCss && typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot) {
4756
+ if (this.sheetRoot && this.sheetRoot !== root) this.shadowSheets.forEach(sheet => unadoptSheet(this.sheetRoot!, sheet))
4757
+ if (this.shadowSheets.length === 0) this.shadowSheets = this.shadowCss.map(css => css === WRAPPER_STYLE ? (wrapperSheet ??= constructedSheet(css)) : constructedSheet(css))
4758
+ this.shadowSheets.forEach(sheet => adoptSheet(root, sheet))
4759
+ this.sheetRoot = root
4760
+ return
4761
+ }
4762
+ if (this.shadowCss && this.styleEls.length === 0) {
4763
+ this.styleEls = this.shadowCss.map(css => {
4764
+ const el = document.createElement("style")
4765
+ el.textContent = css
4766
+ return el
4767
+ })
4768
+ }
4769
+ this.styleEls.forEach(el => root.insertBefore(el, before))
4770
+ }
4771
+
4492
4772
  // `await $mounted()` means "rendered and on the page", so it waits for both -
4493
4773
  // whichever lands last calls this. In the ordinary synchronous flow the render
4494
4774
  // is already done and this is the attach; for a component whose first render
@@ -4501,6 +4781,7 @@ export class Component79 {
4501
4781
  // with any updates that happened while detached already applied
4502
4782
  detach(): this {
4503
4783
  if (!this.mountRoot || !this.content || !this.startMarker || !this.endMarker) return this
4784
+ this.notifyDetached()
4504
4785
 
4505
4786
  // move everything between the markers (inclusive) back into the holding
4506
4787
  // fragment - including nodes :if/:each inserted after mounting
@@ -4517,6 +4798,18 @@ export class Component79 {
4517
4798
  }
4518
4799
 
4519
4800
  destroy(): this {
4801
+ // first, while everything they might read is still there: leaving the
4802
+ // page ($detached, for this component and what it rendered), then going
4803
+ // ($destroyed). Taken before running, so a hook that destroys again (or
4804
+ // re-renders) finds none
4805
+ this.notifyDetached()
4806
+ pageWatchers.delete(this)
4807
+ this.attachHooks = null
4808
+ this.detachHooks = null
4809
+ this.onPage = false
4810
+ const hooks = this.destroyHooks
4811
+ this.destroyHooks = null
4812
+ hooks?.forEach(fn => runHook(fn, "$destroyed"))
4520
4813
  this.detach()
4521
4814
  this.fx?.dispose()
4522
4815
  this.fx = null
@@ -4525,6 +4818,12 @@ export class Component79 {
4525
4818
  this.data?.$dispose()
4526
4819
  this.styleEls.forEach(el => el.parentNode?.removeChild(el))
4527
4820
  this.styleEls = []
4821
+ // the wrapper sheet stays: other boxes in that root may need it, as the
4822
+ // wrapper <style> stays in the document
4823
+ if (this.sheetRoot) this.shadowSheets.forEach(sheet => { if (sheet !== wrapperSheet) unadoptSheet(this.sheetRoot!, sheet) })
4824
+ this.shadowCss = null
4825
+ this.shadowSheets = []
4826
+ this.sheetRoot = null
4528
4827
  if (this.ownsSharedStyles) {
4529
4828
  this.styles.forEach(style => releaseStyle(headStyle(style)))
4530
4829
  this.ownsSharedStyles = false
@@ -4625,11 +4924,54 @@ export class PendingComponent79 {
4625
4924
 
4626
4925
  export { Component79 as C79 }
4627
4926
 
4927
+ // a component that takes the props P: what a signature writes for a component
4928
+ // it takes as a prop - `:setup="{ Button }: { Button: Component<{ label: string }> }"` -
4929
+ // so a type-checker can hold the tags that use it, and the parents that pass
4930
+ // one, to P (RECORD/2026-10-01.component-as-prop.md). "~props" is a phantom:
4931
+ // never set, only in the types. A function of P, because props are passed *to*
4932
+ // a component: one that takes more than P (optionally) is a Component<P>, one
4933
+ // that requires something P doesn't have is not
4934
+ // E and S name the events a signature listens for and the slots it fills
4935
+ // (RECORD/2026-10-01.component-prop-events-and-slots.md): functions of them
4936
+ // too, so a component that emits or renders more is one, one that misses a
4937
+ // name is not. Their default, never, asks nothing
4938
+ export type Component<P = any, E extends string = never, S extends string = never> = Component79 & {
4939
+ readonly "~props"?: (props: P) => void
4940
+ readonly "~emits"?: (event: E) => void
4941
+ readonly "~slots"?: (slot: S) => void
4942
+ }
4943
+
4628
4944
  export const parseComponent = (component: string): Component79 => new Component79(component)
4629
4945
 
4946
+ // one broken hook must not leave the others' timers running, or the
4947
+ // teardown half done: reported, and the rest still run
4948
+ const runHook = (fn: () => void, name: string) => {
4949
+ try {
4950
+ fn()
4951
+ } catch (error) {
4952
+ console.error(`jq79: error in a ${name} hook`, error)
4953
+ }
4954
+ }
4955
+
4956
+ // registers fn on a generation's hook list; the returned function takes it off
4957
+ const addHook = (hooks: (() => void)[], fn: () => void): (() => void) => {
4958
+ hooks.push(fn)
4959
+ return () => {
4960
+ const at = hooks.indexOf(fn)
4961
+ if (at !== -1) hooks.splice(at, 1)
4962
+ }
4963
+ }
4964
+
4965
+ // the instances with an $attached or $detached hook, in this generation -
4966
+ // the only ones a mount or a detach has to look at (see settleAttached)
4967
+ const pageWatchers = new Set<Component79>()
4968
+ let settleQueued = false
4969
+
4630
4970
  // library helpers injected into setup scripts. They behave like extra
4631
4971
  // globals: a same-named scope property (render data or a top-level
4632
- // declaration) shadows them
4972
+ // declaration) shadows them. Their names, in this order, are also
4973
+ // SETUP_HELPER_NAMES (source.ts) - what precompile compiles scripts with, so
4974
+ // the two change together
4633
4975
  const SETUP_HELPERS: Record<string, any> = { $, $$, $create, $reactive, $toRaw, Component79 }
4634
4976
 
4635
4977
  // the hot-reload handshake. jq79/dev serves a classic script that sets the flag