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/README.md +3 -2
- package/dev/vite.ts +152 -10
- package/dist/dom.d.ts +22 -4
- package/dist/html.d.ts +9 -0
- package/dist/jq79-sw.js +32 -0
- package/dist/jq79.cjs +18 -14
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.d.ts +27 -13
- package/dist/jq79.global.js +18 -14
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +18 -14
- package/dist/jq79.js.map +1 -1
- package/dist/precompile.cjs +24 -0
- package/dist/precompile.cjs.map +1 -0
- package/dist/precompile.d.ts +3 -0
- package/dist/precompile.js +24 -0
- package/dist/precompile.js.map +1 -0
- package/dist/reactive.d.ts +10 -0
- package/dist/source.d.ts +65 -0
- package/dist/vite.cjs +85 -4
- package/dist/vite.cjs.map +1 -1
- package/dist/vite.d.ts +3 -0
- package/dist/vite.js +86 -5
- package/dist/vite.js.map +1 -1
- package/package.json +12 -2
- package/src/dom.ts +28 -4
- package/src/html.ts +289 -0
- package/src/jq79.ts +705 -363
- package/src/precompile.ts +238 -0
- package/src/reactive.ts +178 -9
- package/src/source.ts +389 -0
- package/src/sw.ts +87 -0
- package/src/transform.ts +1 -1
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,
|
|
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 {
|
|
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
|
|
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
|
|
166
|
-
if (
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
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
|
|
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,
|
|
2220
|
-
//
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
3290
|
-
//
|
|
3291
|
-
//
|
|
3292
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
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.
|
|
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> =
|
|
3544
|
-
|
|
3545
|
-
|
|
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
|
|
3583
|
-
if (
|
|
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> =
|
|
3743
|
-
|
|
3744
|
-
|
|
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
|
-
|
|
3914
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
|
4306
|
-
// `export default` switches the script to factory mode (plain
|
|
4307
|
-
//
|
|
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
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
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.
|
|
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
|