jq79 0.4.12 → 0.4.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dev/vite.ts +47 -0
- package/dist/jq79.cjs +12 -12
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.d.ts +10 -1
- package/dist/jq79.global.js +12 -12
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +12 -12
- package/dist/jq79.js.map +1 -1
- package/dist/reactive.d.ts +2 -1
- package/dist/transform.d.ts +1 -0
- package/dist/vite.cjs +39 -0
- package/dist/vite.cjs.map +1 -1
- package/dist/vite.js +39 -0
- package/dist/vite.js.map +1 -1
- package/package.json +1 -1
- package/src/jq79.ts +543 -27
- package/src/reactive.ts +55 -4
- package/src/transform.ts +12 -2
package/src/jq79.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
|
|
2
2
|
import { $, $$, $create, sanitizeHTML, allowedHosts } from "./dom"
|
|
3
3
|
import type { AllowUrl } from "./dom"
|
|
4
|
-
import { $reactive, untracked, createEffectScope } from "./reactive"
|
|
4
|
+
import { $reactive, untracked, createEffectScope, ALSO_WAKEN_BY } from "./reactive"
|
|
5
5
|
import type { ReactiveDeepData, EffectScope } from "./reactive"
|
|
6
6
|
import { transformSetupScript, transformFactoryScript, parsePropsPattern, parseFactoryProps, type PropDecl } from "./transform"
|
|
7
7
|
|
|
@@ -31,10 +31,16 @@ const elementAttrs = (el: Element): Record<string, string> =>
|
|
|
31
31
|
// to decide what it's worth (nothing in a block or flex container, one space
|
|
32
32
|
// between inline elements). Trimming it here, as this used to, silently glued
|
|
33
33
|
// siblings together and ate the spaces in `hola <b>mundo</b> adios`
|
|
34
|
+
//
|
|
35
|
+
// A <template>'s children are read from its .content fragment: that is where
|
|
36
|
+
// the HTML parser puts them, and its childNodes are empty. Without the descent
|
|
37
|
+
// they are not in the AST at all - which is where slot content is written
|
|
38
|
+
// (<template :slot.name>), and why a nested <template> used to render as an
|
|
39
|
+
// empty element whatever was inside it
|
|
34
40
|
const elementToAST = (el: Element): TemplateNode => ({
|
|
35
41
|
tag: el.tagName.toLowerCase(),
|
|
36
42
|
attrs: elementAttrs(el),
|
|
37
|
-
children: Array.from(el.childNodes).flatMap((node): (TemplateNode | string)[] => {
|
|
43
|
+
children: Array.from((el instanceof HTMLTemplateElement ? el.content : el).childNodes).flatMap((node): (TemplateNode | string)[] => {
|
|
38
44
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
39
45
|
const text = node.textContent ?? ""
|
|
40
46
|
return text ? [text] : []
|
|
@@ -103,7 +109,8 @@ const CONTROL_ATTRS = new Set([":attrs", ":class", ":value", ":checked", ":selec
|
|
|
103
109
|
// single-flag shorthand) and `:props.<n>` (one spread among several) are
|
|
104
110
|
// open-ended, so they're matched by prefix - they can't be enumerated into the set
|
|
105
111
|
const isControlAttr = (attr: string): boolean =>
|
|
106
|
-
CONTROL_ATTRS.has(attr) || attr.startsWith(":class.") || attr.startsWith(":props.")
|
|
112
|
+
CONTROL_ATTRS.has(attr) || attr.startsWith(":class.") || attr.startsWith(":props.") ||
|
|
113
|
+
attr === ":slot" || attr.startsWith(":slot.")
|
|
107
114
|
// `item in items`, `item, i in items`, `(value, key) in props` - the second
|
|
108
115
|
// binding is the array index or the object key, parens optional (Vue-style).
|
|
109
116
|
// The list expression can span lines, so it matches [\s\S] rather than `.`
|
|
@@ -216,6 +223,236 @@ const findComponentKey = (scope: Record<string, any>, tag: string): string | nul
|
|
|
216
223
|
return null
|
|
217
224
|
}
|
|
218
225
|
|
|
226
|
+
// how deep a component may nest inside itself before the runtime calls it a
|
|
227
|
+
// cycle. Deeper than any real tree, shallower than the JS stack: a truncated
|
|
228
|
+
// render with an error on the console beats a stack overflow with none
|
|
229
|
+
const MAX_NESTING_DEPTH = 200
|
|
230
|
+
let nestingDepth = 0
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// slots - content projection
|
|
234
|
+
//
|
|
235
|
+
// A component tag's children are content the child renders where it wrote a
|
|
236
|
+
// <slot>. The dot marks the named variant on both sides, like :model.<name>
|
|
237
|
+
// and :class.<name> already do:
|
|
238
|
+
//
|
|
239
|
+
// <!-- Card.html --> <!-- the parent -->
|
|
240
|
+
// <section> <Card>
|
|
241
|
+
// <header> <template :slot.header><h2>{{ t }}</h2></template>
|
|
242
|
+
// <slot.header>?</slot.header>
|
|
243
|
+
// </header> <p>{{ body }}</p>
|
|
244
|
+
// <slot /> </Card>
|
|
245
|
+
// </section>
|
|
246
|
+
//
|
|
247
|
+
// Three rules decide everything below:
|
|
248
|
+
//
|
|
249
|
+
// 1. Content belongs to the parent - its AST, its scope, its effects, its
|
|
250
|
+
// scoped styles. The child decides *where* it goes and *whether* it goes,
|
|
251
|
+
// never what the names in it mean.
|
|
252
|
+
// 2. Slot props are declared, not injected: `:slot="{ item }"` on the usage
|
|
253
|
+
// site, for the same reason :each writes `item in rows`. Every bare name in
|
|
254
|
+
// the parent's file is introduced by the parent, so a `<slot :item>` the
|
|
255
|
+
// child adds later can't silently capture one.
|
|
256
|
+
// 3. What isn't projected isn't rendered. No <slot>, or one behind a false
|
|
257
|
+
// :if, and the content's effects never exist.
|
|
258
|
+
//
|
|
259
|
+
// The content travels as a thunk, not as DOM: an instance is replaced (a
|
|
260
|
+
// definition swap, a hot reload) and one <slot> may render many times, so a
|
|
261
|
+
// pre-rendered fragment would leak effects and could only be inserted once
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
// renders one slot's content at the position the child put the <slot>: it is
|
|
265
|
+
// handed the slot's props (lazy, so each read re-evaluates in the child's
|
|
266
|
+
// scope), that position's scope and effect scope, and the style mode the
|
|
267
|
+
// child renders under
|
|
268
|
+
type SlotRenderer = (
|
|
269
|
+
props: Record<string, () => any>,
|
|
270
|
+
slotScope: Record<string, any>,
|
|
271
|
+
fx: EffectScope,
|
|
272
|
+
shadow: boolean
|
|
273
|
+
) => Node
|
|
274
|
+
|
|
275
|
+
type SlotMap = Record<string, SlotRenderer>
|
|
276
|
+
|
|
277
|
+
// the content an instance was handed, by slot name. Symbol-keyed and
|
|
278
|
+
// non-enumerable on the store's data, like UNFILLED_PROPS: it rides the scope
|
|
279
|
+
// chain (so a <slot> inside an :each or a :with finds it) and never shows up
|
|
280
|
+
// as data - not in Object.keys, not in a snapshot spread, not in the props a
|
|
281
|
+
// nested component is handed
|
|
282
|
+
const SLOTS = Symbol("jq79.slots")
|
|
283
|
+
|
|
284
|
+
// <slot>, <slot.header-bar>: the hole and its name. Names are kebab-case where
|
|
285
|
+
// written (the HTML parser lowercases tag names and attribute modifiers alike)
|
|
286
|
+
// and camelCase where read - <slot.header-bar> is :slot.header-bar is
|
|
287
|
+
// $slots.headerBar
|
|
288
|
+
const isSlotTag = (tag: string): boolean => tag === "slot" || tag.startsWith("slot.")
|
|
289
|
+
|
|
290
|
+
const slotName = (suffix: string): string => (suffix ? kebabToCamel(suffix) : "default")
|
|
291
|
+
|
|
292
|
+
// the content of one slot, as written at the usage site
|
|
293
|
+
type SlotContent = { nodes: (TemplateNode | string)[]; binder?: string }
|
|
294
|
+
|
|
295
|
+
// the :slot attribute of a <template>, if it carries one
|
|
296
|
+
const slotAttrOf = (node: TemplateNode): string | undefined =>
|
|
297
|
+
Object.keys(node.attrs).find(attr => attr === ":slot" || attr.startsWith(":slot."))
|
|
298
|
+
|
|
299
|
+
const slotAttrName = (name: string) => (name === "default" ? ":slot" : `:slot.${name}`)
|
|
300
|
+
|
|
301
|
+
// whitespace-only text between two <template :slot> blocks is the indentation
|
|
302
|
+
// between them and nothing else - the same call renderNodes makes between the
|
|
303
|
+
// branches of an :if chain. It is what decides whether a tag has default
|
|
304
|
+
// content at all, which is what $slots.default answers
|
|
305
|
+
const isMeaningful = (node: TemplateNode | string): boolean => typeof node !== "string" || node.trim() !== ""
|
|
306
|
+
|
|
307
|
+
// a component tag's children, partitioned by slot name: a direct
|
|
308
|
+
// <template :slot.<name>> child fills that name, everything else is the
|
|
309
|
+
// default slot's content. The attribute's value is the pattern the content
|
|
310
|
+
// binds the slot's props to - on the tag itself for the default, since the
|
|
311
|
+
// default content has no <template> of its own to carry it
|
|
312
|
+
const partitionSlots = (node: TemplateNode): Record<string, SlotContent> => {
|
|
313
|
+
const contents: Record<string, SlotContent> = {}
|
|
314
|
+
const loose: (TemplateNode | string)[] = []
|
|
315
|
+
|
|
316
|
+
node.children.forEach(child => {
|
|
317
|
+
const attr = typeof child === "object" && child.tag === "template" ? slotAttrOf(child) : undefined
|
|
318
|
+
if (typeof child === "string" || attr === undefined) {
|
|
319
|
+
loose.push(child)
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
const name = slotName(attr.slice(":slot.".length))
|
|
323
|
+
// first wins, like two <template name="X"> in one file: a duplicate is a
|
|
324
|
+
// typo, and the fix is to delete one - not to guess which
|
|
325
|
+
if (name in contents) {
|
|
326
|
+
console.warn(`jq79: two <template ${slotAttrName(name)}> in <${node.tag}>; the second was ignored`)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
contents[name] = { nodes: child.children, binder: child.attrs[attr] || undefined }
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
const hasLoose = loose.some(isMeaningful)
|
|
333
|
+
if (hasLoose && "default" in contents) {
|
|
334
|
+
console.warn(
|
|
335
|
+
`jq79: <${node.tag}> has both a <template :slot> and content outside it - ` +
|
|
336
|
+
"the <template> is the default slot's content, and the rest was ignored"
|
|
337
|
+
)
|
|
338
|
+
} else if (hasLoose) {
|
|
339
|
+
contents.default = { nodes: loose, binder: node.attrs[":slot"] || undefined }
|
|
340
|
+
}
|
|
341
|
+
return contents
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// `:slot="{ item, index: i, total = 0 }"` - the names the content binds the
|
|
345
|
+
// slot's props to. The bindings are accessors, not values: each read
|
|
346
|
+
// re-evaluates the child's expression, so an effect that reads `item` tracks
|
|
347
|
+
// exactly what that expression touches, on every run (createWithScope's design)
|
|
348
|
+
const bindSlotProps = (scope: Record<string, any>, binder: string | undefined, props: Record<string, () => any>) => {
|
|
349
|
+
parsePropsPattern(binder)?.forEach(({ name, as, default: fallback }) => {
|
|
350
|
+
const local = as ?? name
|
|
351
|
+
Object.defineProperty(scope, local, {
|
|
352
|
+
enumerable: true,
|
|
353
|
+
configurable: true,
|
|
354
|
+
get: () => {
|
|
355
|
+
const value = props[name]?.()
|
|
356
|
+
return value === undefined && fallback !== undefined ? evalExpr(fallback, scope) : value
|
|
357
|
+
},
|
|
358
|
+
// a slot prop is the child's value: it arrives on every read and there
|
|
359
|
+
// is nowhere for a write to go. Silence would be worse - `with` swallows
|
|
360
|
+
// an assignment to a getter without a word
|
|
361
|
+
set: () => console.warn(`jq79: "${local}" is a slot prop - it comes from the component, so assigning to it does nothing`),
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// a <template :slot> only fills a slot as a direct child of a component tag,
|
|
367
|
+
// where the usage site takes it out of the children before they are ever
|
|
368
|
+
// rendered (see partitionSlots). Anywhere else the position is a mistake, and
|
|
369
|
+
// rendering the content in place - in the wrong scope, into a <template>
|
|
370
|
+
// nobody clones - would be a strange way to say so. A comment rather than
|
|
371
|
+
// nothing: an :if branch needs a node to hold on to (see boundsOf)
|
|
372
|
+
const misplacedSlotContent = (node: TemplateNode): Node => {
|
|
373
|
+
const attr = slotAttrOf(node)
|
|
374
|
+
console.warn(`jq79: <template ${attr}> fills a slot only as a direct child of a component tag; here it rendered nothing`)
|
|
375
|
+
return document.createComment(`misplaced ${attr}`)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// what a usage site hands its instance: every slot it filled, as the thunk
|
|
379
|
+
// that renders it. Built once per site, and in one call - a component tag is
|
|
380
|
+
// on the stack while its whole subtree renders below it (a component that
|
|
381
|
+
// renders itself does this 200 deep), so the intermediates stay in here rather
|
|
382
|
+
// than in the frame that waits
|
|
383
|
+
const buildSlots = (node: TemplateNode, scope: Record<string, any>): SlotMap | null => {
|
|
384
|
+
const contents = Object.entries(partitionSlots(node))
|
|
385
|
+
if (!contents.length) return null
|
|
386
|
+
const slots: SlotMap = {}
|
|
387
|
+
contents.forEach(([name, content]) => { slots[name] = makeSlotRenderer(content, scope) })
|
|
388
|
+
return slots
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// the thunk one slot's content becomes: the usage site closes over its AST and
|
|
392
|
+
// its scope, the child calls it wherever (and however many times) it renders
|
|
393
|
+
// the matching <slot>
|
|
394
|
+
const makeSlotRenderer = (content: SlotContent, parentScope: Record<string, any>): SlotRenderer =>
|
|
395
|
+
(props, slotScope, fx, shadow) => {
|
|
396
|
+
// the parent's scope, plus the names the content declared for the slot's
|
|
397
|
+
// props (rule 1: what the content says is decided where it was written)
|
|
398
|
+
const scope: Record<string, any> = Object.create(parentScope)
|
|
399
|
+
bindSlotProps(scope, content.binder, props)
|
|
400
|
+
// this content reads the parent's store (its own names) and the child's
|
|
401
|
+
// (through the slot props), so every effect created anywhere inside it is
|
|
402
|
+
// registered with both - see ALSO_WAKEN_BY. Appended rather than assigned:
|
|
403
|
+
// content forwarded through a <slot> inside slot content is still woken by
|
|
404
|
+
// the store it came from
|
|
405
|
+
const inherited: Record<string, any>[] = (scope as any)[ALSO_WAKEN_BY] ?? []
|
|
406
|
+
Object.defineProperty(scope, ALSO_WAKEN_BY, { value: [...inherited, slotScope] })
|
|
407
|
+
|
|
408
|
+
const contentFx = createEffectScope(scope)
|
|
409
|
+
// rule 3: the <slot> is the content's lifetime. When the child's subtree at
|
|
410
|
+
// this position goes - an :if turning false, the instance being replaced,
|
|
411
|
+
// the whole child being destroyed - the content's effects go with it
|
|
412
|
+
fx.onDispose(() => contentFx.dispose())
|
|
413
|
+
return renderNodes(content.nodes, scope, contentFx, shadow)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// <slot />, <slot.name>fallback</slot.name>: where the parent's content goes.
|
|
417
|
+
// Unfilled, the slot renders its own children instead - in this component's
|
|
418
|
+
// scope, since that content is this component's. Every attribute that isn't a
|
|
419
|
+
// directive is a slot prop: `:item="item"` evaluates here and reaches the
|
|
420
|
+
// content under the name it declared, a plain attribute passes a literal
|
|
421
|
+
// string, and there are no reserved names (the slot's own name is in the tag).
|
|
422
|
+
// Bracketed by anchors like a nested component, so the chunk has stable bounds
|
|
423
|
+
// even when it renders nothing (see boundsOf)
|
|
424
|
+
const renderSlot = (node: TemplateNode, scope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
|
|
425
|
+
const name = slotName(node.tag.slice("slot.".length))
|
|
426
|
+
const wrapper = document.createDocumentFragment()
|
|
427
|
+
const anchor = document.createComment(node.tag)
|
|
428
|
+
const endAnchor = document.createComment(`/${node.tag}`)
|
|
429
|
+
wrapper.append(anchor, endAnchor)
|
|
430
|
+
|
|
431
|
+
const render = (scope as any)[SLOTS]?.[name] as SlotRenderer | undefined
|
|
432
|
+
if (!render) {
|
|
433
|
+
wrapper.insertBefore(renderNodes(node.children, scope, fx, shadow), endAnchor)
|
|
434
|
+
return wrapper
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const props: Record<string, () => any> = {}
|
|
438
|
+
Object.entries(node.attrs).forEach(([attr, value]) => {
|
|
439
|
+
// the scope stamp is the component's, not a prop; @events have no element
|
|
440
|
+
// to bind here; and a directive means what it means everywhere else -
|
|
441
|
+
// :if/:each/:with decide whether and how often this slot renders, so they
|
|
442
|
+
// are the renderer's, not the content's
|
|
443
|
+
if (attr === SCOPE_ATTR || isControlAttr(attr) || attr.startsWith("@")) return
|
|
444
|
+
if (attr.startsWith(":")) {
|
|
445
|
+
const expr = value || attr.slice(1)
|
|
446
|
+
props[kebabToCamel(attr.slice(1))] = () => evalExpr(expr, scope)
|
|
447
|
+
} else {
|
|
448
|
+
props[kebabToCamel(attr)] = () => value
|
|
449
|
+
}
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
wrapper.insertBefore(render(props, scope, fx, shadow), endAnchor)
|
|
453
|
+
return wrapper
|
|
454
|
+
}
|
|
455
|
+
|
|
219
456
|
// <MyComponent :user :title="'str'"></MyComponent> - renders a child
|
|
220
457
|
// component instance at this position. Props: `:name="expr"` evaluates expr
|
|
221
458
|
// in the parent scope (`:name` alone is shorthand for `:name="name"`), plain
|
|
@@ -239,6 +476,11 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
239
476
|
const wrapper = document.createDocumentFragment()
|
|
240
477
|
wrapper.append(anchor, endAnchor)
|
|
241
478
|
|
|
479
|
+
// the tag's children, as content for the child's <slot>s. Built once per
|
|
480
|
+
// usage site (the AST doesn't change) and closed over the parent's scope
|
|
481
|
+
// here, so every instance this site ever renders is handed the same thunks
|
|
482
|
+
const slots = buildSlots(node, scope)
|
|
483
|
+
|
|
242
484
|
const props: Record<string, string> = {} // prop name -> expression in parent scope
|
|
243
485
|
const models: Record<string, string> = {} // model name -> assignable expression in parent scope
|
|
244
486
|
const events: Array<[string, string]> = [] // @attr (modifiers included) -> handler expression
|
|
@@ -329,9 +571,34 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
329
571
|
let currentDef: Component79 | null = null
|
|
330
572
|
let childFx: EffectScope | null = null
|
|
331
573
|
|
|
574
|
+
// a usage site that resolves to no component renders nothing, which is
|
|
575
|
+
// deliberate - `undefined` while an `await import(...)` is in flight has to
|
|
576
|
+
// wait quietly, and the child appears when it lands. Two cases can never
|
|
577
|
+
// resolve, though, and both are wiring mistakes worth naming: a value that
|
|
578
|
+
// isn't a component (and so will never become one by waiting), and a name
|
|
579
|
+
// the component declared as a prop that the parent passed nothing for. Once
|
|
580
|
+
// each, per usage site: an effect re-runs
|
|
581
|
+
const reported = new Set<string>()
|
|
582
|
+
const reportUnresolved = (value: any) => {
|
|
583
|
+
if (value === undefined || value === null) {
|
|
584
|
+
const unfilled: Set<string> | undefined = (scope as any)[UNFILLED_PROPS]
|
|
585
|
+
if (!unfilled?.has(key) || reported.has("unfilled")) return
|
|
586
|
+
reported.add("unfilled")
|
|
587
|
+
console.error(
|
|
588
|
+
`jq79: <${node.tag}> is declared as a prop and the parent passed nothing - nothing renders here. ` +
|
|
589
|
+
`Pass it (:${key}="…"), or drop it from the signature to use the one declared in this file.`
|
|
590
|
+
)
|
|
591
|
+
return
|
|
592
|
+
}
|
|
593
|
+
if (reported.has("type")) return
|
|
594
|
+
reported.add("type")
|
|
595
|
+
console.error(`jq79: <${node.tag}> is ${typeof value}, not a component - nothing renders here`)
|
|
596
|
+
}
|
|
597
|
+
|
|
332
598
|
fx.effect(() => {
|
|
333
599
|
const value = evalExpr(key, scope)
|
|
334
600
|
const nextDef = value instanceof Component79 ? value : null
|
|
601
|
+
if (!nextDef) reportUnresolved(value)
|
|
335
602
|
if (nextDef === currentDef) return
|
|
336
603
|
|
|
337
604
|
childFx?.dispose()
|
|
@@ -349,7 +616,15 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
349
616
|
styles: nextDef.styles,
|
|
350
617
|
modules: nextDef.modules,
|
|
351
618
|
filename: nextDef.filename,
|
|
619
|
+
// its file's other components, and which of them it is: without the
|
|
620
|
+
// first a child rendered here loses the siblings its definition could
|
|
621
|
+
// see, and without the second hot reload can't tell it what it is
|
|
622
|
+
siblings: nextDef.siblings,
|
|
623
|
+
name: nextDef.name,
|
|
352
624
|
})
|
|
625
|
+
// the content this site wrote inside the tag, before the first render: a
|
|
626
|
+
// <slot> is resolved while rendering, so the map has to be there by then
|
|
627
|
+
if (slots) instance.slots = slots
|
|
353
628
|
// the writeback half of :model - one event, one contract. The name is
|
|
354
629
|
// normalized like the attribute was (kebab->camel; absent means default),
|
|
355
630
|
// and everything off-contract warns and does nothing: an event protocol's
|
|
@@ -395,7 +670,24 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
395
670
|
// shadow-rendered child keeps its <style> elements inline, next to the DOM
|
|
396
671
|
// they style, and the parent's shadow root is what scopes both
|
|
397
672
|
const holder = document.createDocumentFragment()
|
|
398
|
-
|
|
673
|
+
// rendering a child happens on this same stack, so a component that
|
|
674
|
+
// renders itself recurses as deep as its data does - and a cycle in that
|
|
675
|
+
// data would recurse until the JS stack gave out, ~900 identical frames
|
|
676
|
+
// naming nothing. Cut and named instead, exactly like the effect runner
|
|
677
|
+
// cuts an effect that wakes itself
|
|
678
|
+
if (nestingDepth >= MAX_NESTING_DEPTH) {
|
|
679
|
+
console.error(
|
|
680
|
+
`jq79: <${node.tag}> is ${MAX_NESTING_DEPTH} levels deep inside itself; giving up here. ` +
|
|
681
|
+
"A component that renders itself stops when its data stops - is there a cycle in it?"
|
|
682
|
+
)
|
|
683
|
+
return
|
|
684
|
+
}
|
|
685
|
+
nestingDepth++
|
|
686
|
+
try {
|
|
687
|
+
;(shadow ? instance.renderShadow(seed) : instance.render(seed)).mount(holder)
|
|
688
|
+
} finally {
|
|
689
|
+
nestingDepth--
|
|
690
|
+
}
|
|
399
691
|
endAnchor.parentNode!.insertBefore(holder, endAnchor)
|
|
400
692
|
|
|
401
693
|
const syncFx = createEffectScope(scope)
|
|
@@ -515,6 +807,12 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
515
807
|
const withExpr = node.attrs[":with"]
|
|
516
808
|
const scope = withExpr !== undefined ? createWithScope(withExpr, outerScope) : outerScope
|
|
517
809
|
|
|
810
|
+
// before the component-key scan, so <slot> is <slot> even in a file that
|
|
811
|
+
// happens to have a component named Slot in scope: the tag is the library's
|
|
812
|
+
// now, and a name that resolved it away would be a very quiet surprise
|
|
813
|
+
if (isSlotTag(node.tag)) return renderSlot(node, scope, fx, shadow)
|
|
814
|
+
if (node.tag === "template" && slotAttrOf(node) !== undefined) return misplacedSlotContent(node)
|
|
815
|
+
|
|
518
816
|
const componentKey = findComponentKey(scope, node.tag)
|
|
519
817
|
if (componentKey) return renderNestedComponent(componentKey, node, scope, fx, shadow)
|
|
520
818
|
|
|
@@ -618,6 +916,13 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
618
916
|
const options = allowedExpr !== undefined ? { allowUrl: normalizeAllowUrl(evalExpr(allowedExpr, scope)) } : undefined
|
|
619
917
|
el.innerHTML = sanitizeHTML(String(evalExpr(htmlExpr, scope) ?? ""), options)
|
|
620
918
|
})
|
|
919
|
+
} else if (el instanceof HTMLTemplateElement) {
|
|
920
|
+
// a plain nested <template> stays what HTML says it is: an inert element
|
|
921
|
+
// whose children live in .content, which is where whoever clones it looks
|
|
922
|
+
// for them. They render (bindings and all) and go there - appended as
|
|
923
|
+
// childNodes they would be in the DOM but in no document fragment, seen by
|
|
924
|
+
// nothing and rendered by nobody
|
|
925
|
+
el.content.appendChild(renderNodes(node.children, scope, fx, shadow))
|
|
621
926
|
} else {
|
|
622
927
|
el.appendChild(renderNodes(node.children, scope, fx, shadow))
|
|
623
928
|
}
|
|
@@ -889,6 +1194,15 @@ type ComponentParts = {
|
|
|
889
1194
|
// where this component came from (a URL for fetch(), a path for the vite
|
|
890
1195
|
// plugin). Names the setup scripts in devtools - see scriptSourceUrl
|
|
891
1196
|
filename?: string
|
|
1197
|
+
// the components the file's <template name="..."> blocks declared, by name.
|
|
1198
|
+
// Every component parsed out of one file holds this same map - itself
|
|
1199
|
+
// included - which is what makes a sibling usable without an import, and
|
|
1200
|
+
// what lets a <template name="TreeNode"> render a <TreeNode>
|
|
1201
|
+
siblings?: Record<string, Component79>
|
|
1202
|
+
// which of the file's components this is: a template's name, or undefined
|
|
1203
|
+
// for the file's own. The file is the hot-reload unit, so a reparse hands
|
|
1204
|
+
// each live instance the parts belonging to the component it is
|
|
1205
|
+
name?: string
|
|
892
1206
|
}
|
|
893
1207
|
|
|
894
1208
|
const VOID_ELEMENTS = new Set([
|
|
@@ -897,8 +1211,10 @@ const VOID_ELEMENTS = new Set([
|
|
|
897
1211
|
])
|
|
898
1212
|
|
|
899
1213
|
// a self-closing tag with its attributes; quoted attribute values are matched
|
|
900
|
-
// as whole chunks so a "/>" inside one doesn't end the tag early
|
|
901
|
-
|
|
1214
|
+
// as whole chunks so a "/>" inside one doesn't end the tag early. The tag name
|
|
1215
|
+
// admits a dot for the named forms of a tag - <slot.header /> - which is a
|
|
1216
|
+
// legal HTML tag name (the tokenizer reads to the first space, "/" or ">")
|
|
1217
|
+
const SELF_CLOSING_RE = /<([A-Za-z][\w.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)\/>/g
|
|
902
1218
|
const RAW_BLOCK_RE = /(<script[\s\S]*?<\/script\s*>|<style[\s\S]*?<\/style\s*>)/gi
|
|
903
1219
|
|
|
904
1220
|
// expands self-closing tags (<MyComponent />, <div />) into explicit
|
|
@@ -921,7 +1237,7 @@ const expandSelfClosingTags = (src: string): string =>
|
|
|
921
1237
|
// a start tag with its attributes, quote-aware so a ">" inside a value doesn't
|
|
922
1238
|
// end it early; and a single spread attribute in name position (preceded by
|
|
923
1239
|
// start-or-whitespace), its expression an identifier or member path
|
|
924
|
-
const OPEN_TAG_RE = /<([A-Za-z][\w
|
|
1240
|
+
const OPEN_TAG_RE = /<([A-Za-z][\w.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g
|
|
925
1241
|
const ATTR_SPREAD_RE = /"[^"]*"|'[^']*'|(^|\s)\.\.\.([A-Za-z_$][\w$.]*)/g
|
|
926
1242
|
|
|
927
1243
|
// `...expr` as an attribute is sugar for :props="expr" (spread an object's
|
|
@@ -1015,9 +1331,16 @@ const scopeCss = (css: string, scope: string): string => {
|
|
|
1015
1331
|
return Array.from(sheet.cssRules).map(rule => rule.cssText).join("\n")
|
|
1016
1332
|
}
|
|
1017
1333
|
|
|
1334
|
+
// a component name has to be PascalCase to be usable: findComponentKey only
|
|
1335
|
+
// ever considers capitalized scope keys, so a lowercase name would declare a
|
|
1336
|
+
// component no tag could reference. It is also what keeps the named exports
|
|
1337
|
+
// from colliding with a definition's own fields, which are all lowercase
|
|
1338
|
+
const COMPONENT_NAME_RE = /^[A-Z][A-Za-z0-9]*$/
|
|
1339
|
+
|
|
1018
1340
|
// converts a string of HTML into an AST representation of the component:
|
|
1019
1341
|
// - template: the non-script/style top-level elements, as TemplateNodes
|
|
1020
1342
|
// - scripts/styles: { attrs, content } blocks in source order
|
|
1343
|
+
// - siblings: the components its top-level <template name="..."> declared
|
|
1021
1344
|
const parseComponentString = (component: string): ComponentParts => {
|
|
1022
1345
|
// example
|
|
1023
1346
|
// <script :setup="{ fname, lname }">
|
|
@@ -1043,11 +1366,64 @@ const parseComponentString = (component: string): ComponentParts => {
|
|
|
1043
1366
|
const parsedDOM = new DOMParser().parseFromString(`<template>${prepared}</template>`, "text/html")
|
|
1044
1367
|
const root = parsedDOM.querySelector("template") as HTMLTemplateElement
|
|
1045
1368
|
|
|
1369
|
+
// a top-level <template> declares another component of this file; everything
|
|
1370
|
+
// else is this one's own
|
|
1371
|
+
const own: Element[] = []
|
|
1372
|
+
const declarations: HTMLTemplateElement[] = []
|
|
1373
|
+
Array.from(root.content.children).forEach(el => {
|
|
1374
|
+
if (el.tagName === "TEMPLATE") declarations.push(el as HTMLTemplateElement)
|
|
1375
|
+
else own.push(el)
|
|
1376
|
+
})
|
|
1377
|
+
|
|
1378
|
+
// the file's own component hashes the whole file: every component in it
|
|
1379
|
+
// re-renders on any edit anyway (the file is the hot-reload unit), so a
|
|
1380
|
+
// stamp that changes when a sibling is edited costs nothing, and scopeHash
|
|
1381
|
+
// gets to keep hashing the source it was handed
|
|
1382
|
+
const parts = componentPartsFrom(own, component)
|
|
1383
|
+
|
|
1384
|
+
// one map, shared by reference: it is filled below, after each definition
|
|
1385
|
+
// has already been handed it, so every component of the file sees all the
|
|
1386
|
+
// others *and itself* - which is what makes a recursive component possible
|
|
1387
|
+
const siblings: Record<string, Component79> = {}
|
|
1388
|
+
declarations.forEach(el => {
|
|
1389
|
+
const name = el.getAttribute("name")
|
|
1390
|
+
// ignored rather than fatal, like every other malformed thing here: a bad
|
|
1391
|
+
// save mid-typing must not take the page down, least of all under HMR
|
|
1392
|
+
if (name === null) {
|
|
1393
|
+
console.warn("jq79: a top-level <template> without a name declares nothing and was ignored")
|
|
1394
|
+
return
|
|
1395
|
+
}
|
|
1396
|
+
if (!COMPONENT_NAME_RE.test(name)) {
|
|
1397
|
+
console.warn(
|
|
1398
|
+
`jq79: <template name="${name}"> was ignored - a component name has to be PascalCase, ` +
|
|
1399
|
+
"or no tag could ever reference it (only capitalized names resolve as components)"
|
|
1400
|
+
)
|
|
1401
|
+
return
|
|
1402
|
+
}
|
|
1403
|
+
if (name in siblings) {
|
|
1404
|
+
console.warn(`jq79: two <template name="${name}"> in one file; the second was ignored`)
|
|
1405
|
+
return
|
|
1406
|
+
}
|
|
1407
|
+
// its own source is its own scope: a named template is a shadow root
|
|
1408
|
+
// inside a shadow root, so the file's scoped rules stop at its boundary
|
|
1409
|
+
// and its own stop there too
|
|
1410
|
+
siblings[name] = new Component79({ ...componentPartsFrom(Array.from(el.content.children), el.innerHTML), siblings, name })
|
|
1411
|
+
})
|
|
1412
|
+
if (Object.keys(siblings).length) parts.siblings = siblings
|
|
1413
|
+
|
|
1414
|
+
return parts
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// the script/style/markup split of one component's top-level elements, with
|
|
1418
|
+
// <style scoped> resolved against the source those elements came from - the
|
|
1419
|
+
// whole file for its own component, a <template>'s contents for a named one,
|
|
1420
|
+
// so the two get different stamps and neither can style the other
|
|
1421
|
+
const componentPartsFrom = (elements: Element[], hashSource: string): ComponentParts => {
|
|
1046
1422
|
const scripts: TagBlock[] = []
|
|
1047
1423
|
const styles: TagBlock[] = []
|
|
1048
1424
|
const template: TemplateNode[] = []
|
|
1049
1425
|
|
|
1050
|
-
|
|
1426
|
+
elements.forEach(el => {
|
|
1051
1427
|
const block: TagBlock = { attrs: elementAttrs(el), content: el.textContent ?? "" }
|
|
1052
1428
|
|
|
1053
1429
|
if (el.tagName === "SCRIPT") scripts.push(block)
|
|
@@ -1074,7 +1450,7 @@ const parseComponentString = (component: string): ComponentParts => {
|
|
|
1074
1450
|
// in something that isn't CSS yet would only garble what devtools shows
|
|
1075
1451
|
const isScoped = (style: TagBlock) => "scoped" in style.attrs && !("lang" in style.attrs)
|
|
1076
1452
|
if (styles.some(isScoped)) {
|
|
1077
|
-
const scope = scopeHash(
|
|
1453
|
+
const scope = scopeHash(hashSource)
|
|
1078
1454
|
stampScope(template, scope)
|
|
1079
1455
|
styles.forEach(style => {
|
|
1080
1456
|
if (isScoped(style)) style.scoped = scopeCss(style.content, scope)
|
|
@@ -1193,6 +1569,50 @@ const declareProps = (store: Record<string, any>, props: PropDecl[] | null) => {
|
|
|
1193
1569
|
})
|
|
1194
1570
|
}
|
|
1195
1571
|
|
|
1572
|
+
// every prop name a component's scripts declare, across both script modes.
|
|
1573
|
+
// Read before the store exists, because what a component declares decides
|
|
1574
|
+
// which of its file's sibling components it can still see: declaring a name
|
|
1575
|
+
// says it comes from the parent, so the file's own definition of that name is
|
|
1576
|
+
// deliberately not in this component's scope
|
|
1577
|
+
const declaredPropNames = (scripts: TagBlock[]): Set<string> => {
|
|
1578
|
+
const names = new Set<string>()
|
|
1579
|
+
scripts.forEach(script => {
|
|
1580
|
+
const declarations = parseFactoryProps(script.content) ?? parsePropsPattern(script.attrs[":setup"])
|
|
1581
|
+
declarations?.forEach(({ name }) => names.add(name))
|
|
1582
|
+
})
|
|
1583
|
+
return names
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// the sibling components this one resolves by name, or null when there are
|
|
1587
|
+
// none left to resolve. They go on the store's *prototype* rather than in it:
|
|
1588
|
+
// the component-key scan walks the chain, so <Row> resolves; they stay out of
|
|
1589
|
+
// the data, so Object.keys, snapshots and spreads never see them; and an own
|
|
1590
|
+
// key shadows a prototype one, so a prop the parent did pass wins for free
|
|
1591
|
+
const siblingsInScope = (
|
|
1592
|
+
siblings: Record<string, Component79> | undefined,
|
|
1593
|
+
declared: Set<string>
|
|
1594
|
+
): Record<string, Component79> | null => {
|
|
1595
|
+
if (!siblings) return null
|
|
1596
|
+
// null-prototype, for the same reason storeApi is: `key in scope` must not
|
|
1597
|
+
// start answering true for toString, constructor and the rest
|
|
1598
|
+
const inScope: Record<string, Component79> = Object.create(null)
|
|
1599
|
+
let any = false
|
|
1600
|
+
Object.entries(siblings).forEach(([name, component]) => {
|
|
1601
|
+
if (declared.has(name)) return
|
|
1602
|
+
inScope[name] = component
|
|
1603
|
+
any = true
|
|
1604
|
+
})
|
|
1605
|
+
return any ? inScope : null
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// names a component declared as props and the parent passed nothing for. Such
|
|
1609
|
+
// a name can never become a component later - there is no binding on the tag
|
|
1610
|
+
// to update it - so a <Tag> reading one is a wiring mistake that can be named
|
|
1611
|
+
// on sight, unlike the `undefined` of an import still in flight. Symbol-keyed
|
|
1612
|
+
// and non-enumerable: it rides the scope chain (so an :each item scope finds
|
|
1613
|
+
// it too) without ever showing up as data
|
|
1614
|
+
const UNFILLED_PROPS = Symbol("jq79.unfilledProps")
|
|
1615
|
+
|
|
1196
1616
|
// default-import interop for factory scripts: real modules expose .default,
|
|
1197
1617
|
// while importing an .html component resolves to the Component79 itself
|
|
1198
1618
|
const interopDefault = (mod: any) => (mod && mod.default !== undefined ? mod.default : mod)
|
|
@@ -1297,6 +1717,13 @@ export const hotUpdate = (filename: string, src: string): number => {
|
|
|
1297
1717
|
// parsed once and shared by every instance - which is already what a
|
|
1298
1718
|
// definition and the clones :component makes from it do
|
|
1299
1719
|
const parts = parseComponentString(src)
|
|
1720
|
+
// the file is the hot-reload unit, so one reparse serves every component it
|
|
1721
|
+
// declares: an instance is handed the parts of the component it *is*, by
|
|
1722
|
+
// name. A name that is no longer in the file (a <template> renamed or
|
|
1723
|
+
// deleted) has no parts to be given, and only a reload can fix the page
|
|
1724
|
+
let orphaned = false
|
|
1725
|
+
const partsFor = (instance: Component79): ComponentParts | null =>
|
|
1726
|
+
instance.name === undefined ? parts : parts.siblings?.[instance.name] ?? null
|
|
1300
1727
|
|
|
1301
1728
|
let rerendered = 0
|
|
1302
1729
|
for (const [name, refs] of hotRegistry) {
|
|
@@ -1307,11 +1734,16 @@ export const hotUpdate = (filename: string, src: string): number => {
|
|
|
1307
1734
|
refs.delete(ref) // collected since the last update
|
|
1308
1735
|
continue
|
|
1309
1736
|
}
|
|
1310
|
-
|
|
1737
|
+
const next = partsFor(instance)
|
|
1738
|
+
if (!next) {
|
|
1739
|
+
orphaned = true
|
|
1740
|
+
continue
|
|
1741
|
+
}
|
|
1742
|
+
if (instance.hotReplace(next)) rerendered++
|
|
1311
1743
|
}
|
|
1312
1744
|
if (!refs.size) hotRegistry.delete(name)
|
|
1313
1745
|
}
|
|
1314
|
-
return rerendered
|
|
1746
|
+
return orphaned ? 0 : rerendered
|
|
1315
1747
|
}
|
|
1316
1748
|
|
|
1317
1749
|
// starts tracking instances, so hotUpdate can find them. jq79/dev's client
|
|
@@ -1324,6 +1756,14 @@ export const enableHotReload = (): void => {
|
|
|
1324
1756
|
|
|
1325
1757
|
type EmitListener = (event: CustomEvent, payload: any) => void
|
|
1326
1758
|
|
|
1759
|
+
const fetchComponent = async (url: string): Promise<Component79> => {
|
|
1760
|
+
const response = await fetch(url)
|
|
1761
|
+
if (!response.ok) throw new Error(`failed to fetch component from ${url}: ${response.status}`)
|
|
1762
|
+
// the URL names the component's scripts in devtools, and is where the
|
|
1763
|
+
// browser will look for the source when a breakpoint lands in one
|
|
1764
|
+
return new Component79(await response.text(), { filename: url })
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1327
1767
|
// a parsed single-file component. Typical lifecycle:
|
|
1328
1768
|
//
|
|
1329
1769
|
// const jq79 = new Component79(src) // or await Component79.fetch(url)
|
|
@@ -1341,6 +1781,20 @@ export class Component79 {
|
|
|
1341
1781
|
modules?: Record<string, any>
|
|
1342
1782
|
// the component's origin, used to name its scripts in devtools
|
|
1343
1783
|
filename?: string
|
|
1784
|
+
// the other components declared in the same file, by name (see
|
|
1785
|
+
// ComponentParts.siblings). They are also this definition's own properties,
|
|
1786
|
+
// so `const { Row } = await Component79.fetch(url)` reaches them
|
|
1787
|
+
siblings?: Record<string, Component79>
|
|
1788
|
+
// this component's name inside its file, for the components a <template>
|
|
1789
|
+
// declared; the file's own component has none - it is the default, and a
|
|
1790
|
+
// default is named by whoever imports it
|
|
1791
|
+
name?: string
|
|
1792
|
+
// the content the usage site handed this instance, by slot name (see the
|
|
1793
|
+
// slots section). Not part of a definition - it belongs to the tag that
|
|
1794
|
+
// wrote it - so renderNestedComponent sets it on the instance it creates,
|
|
1795
|
+
// and every render reads it from here: a hot reload re-renders from a data
|
|
1796
|
+
// snapshot, which a symbol on the store would not survive
|
|
1797
|
+
slots?: SlotMap
|
|
1344
1798
|
|
|
1345
1799
|
data: ReactiveDeepData<Record<string, any>> | null = null
|
|
1346
1800
|
|
|
@@ -1372,9 +1826,29 @@ export class Component79 {
|
|
|
1372
1826
|
this.styles = parts.styles
|
|
1373
1827
|
this.modules = options.modules ?? (typeof src === "string" ? undefined : src.modules)
|
|
1374
1828
|
this.filename = options.filename ?? (typeof src === "string" ? undefined : src.filename)
|
|
1829
|
+
this.siblings = parts.siblings
|
|
1830
|
+
this.name = parts.name
|
|
1831
|
+
this.adoptSiblings()
|
|
1375
1832
|
hotRegister(this) // a no-op unless the page enabled hot reload
|
|
1376
1833
|
}
|
|
1377
1834
|
|
|
1835
|
+
// the parser builds a file's sibling definitions before anyone has told it
|
|
1836
|
+
// where the file came from, so whoever holds the parse hands its origin down
|
|
1837
|
+
// - and keeps doing it after a hot reload, which parses the file afresh.
|
|
1838
|
+
// Without it a reloaded child would have no filename, and an instance with
|
|
1839
|
+
// no filename is not tracked: the next edit would never reach it
|
|
1840
|
+
private adoptSiblings() {
|
|
1841
|
+
if (!this.siblings) return
|
|
1842
|
+
Object.entries(this.siblings).forEach(([name, sibling]) => {
|
|
1843
|
+
sibling.filename ??= this.filename
|
|
1844
|
+
sibling.modules ??= this.modules
|
|
1845
|
+
// the file's own component also *is* the file: its named components hang
|
|
1846
|
+
// off it as properties, which is what `const { Row } = …` reads (and
|
|
1847
|
+
// what the bundler re-exports by name)
|
|
1848
|
+
if (!this.name) (this as any)[name] = sibling
|
|
1849
|
+
})
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1378
1852
|
// swaps this component's parsed parts for `src`'s and, if it is on the page,
|
|
1379
1853
|
// re-renders it where it stands - seeded with a snapshot of its data, so
|
|
1380
1854
|
// props and store values survive (the setup script runs again, so whatever it
|
|
@@ -1410,6 +1884,11 @@ export class Component79 {
|
|
|
1410
1884
|
this.template = parts.template
|
|
1411
1885
|
this.scripts = parts.scripts
|
|
1412
1886
|
this.styles = parts.styles
|
|
1887
|
+
// the file's other components as they are now: the next render resolves
|
|
1888
|
+
// <Row> against these, so a parent picks up an edited child even when the
|
|
1889
|
+
// child's own instances are patched separately
|
|
1890
|
+
this.siblings = parts.siblings
|
|
1891
|
+
this.adoptSiblings()
|
|
1413
1892
|
if (!rendered) return false // a definition: its clones re-render themselves
|
|
1414
1893
|
|
|
1415
1894
|
this.renderWith(data, shadow)
|
|
@@ -1424,12 +1903,13 @@ export class Component79 {
|
|
|
1424
1903
|
return true
|
|
1425
1904
|
}
|
|
1426
1905
|
|
|
1427
|
-
static
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1906
|
+
static fetch(url: string): Promise<Component79>
|
|
1907
|
+
static fetch(urls: string[]): Promise<Component79[]>
|
|
1908
|
+
// an array of URLs fetches them all at once and resolves to the components in
|
|
1909
|
+
// the same order, so one await destructures them - and, like Promise.all, the
|
|
1910
|
+
// first failure rejects the whole thing
|
|
1911
|
+
static fetch(urls: string | string[]): Promise<Component79 | Component79[]> {
|
|
1912
|
+
return Array.isArray(urls) ? Promise.all(urls.map(fetchComponent)) : fetchComponent(urls)
|
|
1433
1913
|
}
|
|
1434
1914
|
|
|
1435
1915
|
// subscribes to this instance's $emit events, on top of the DOM CustomEvent
|
|
@@ -1460,7 +1940,23 @@ export class Component79 {
|
|
|
1460
1940
|
private renderWith(data: Record<string, any>, shadow: boolean): this {
|
|
1461
1941
|
this.destroy()
|
|
1462
1942
|
|
|
1463
|
-
|
|
1943
|
+
// what this component can see of its file's other components, and which of
|
|
1944
|
+
// its declared props arrived empty - both decided by the signature, before
|
|
1945
|
+
// the store exists (see siblingsInScope / UNFILLED_PROPS)
|
|
1946
|
+
const declared = declaredPropNames(this.scripts)
|
|
1947
|
+
const siblingScope = siblingsInScope(this.siblings, declared)
|
|
1948
|
+
const raw: Record<string, any> = siblingScope
|
|
1949
|
+
? Object.assign(Object.create(siblingScope), data)
|
|
1950
|
+
: { ...data }
|
|
1951
|
+
const unfilled = new Set([...declared].filter(name => !(name in data)))
|
|
1952
|
+
if (unfilled.size) Object.defineProperty(raw, UNFILLED_PROPS, { value: unfilled })
|
|
1953
|
+
// the slot content, for the <slot>s the template renders, and the static
|
|
1954
|
+
// map of which names were filled, for the component to ask about
|
|
1955
|
+
// (`<footer :if="$slots.footer">`). Filled at the usage site, so it can
|
|
1956
|
+
// only change when the tag itself re-renders - which builds a new instance
|
|
1957
|
+
if (this.slots) Object.defineProperty(raw, SLOTS, { value: this.slots })
|
|
1958
|
+
|
|
1959
|
+
const store = $reactive(raw)
|
|
1464
1960
|
const fx = createEffectScope(store)
|
|
1465
1961
|
this.data = store
|
|
1466
1962
|
this.fx = fx
|
|
@@ -1528,6 +2024,20 @@ export class Component79 {
|
|
|
1528
2024
|
const $import = (url: string): Promise<any> =>
|
|
1529
2025
|
modules && url in modules ? Promise.resolve(modules[url]) : importResource(url)
|
|
1530
2026
|
|
|
2027
|
+
// the names a component answers on top of its store: $emit, so an inline
|
|
2028
|
+
// handler can emit without routing through a setup function
|
|
2029
|
+
// (@input="$emit('update', $event.target.value)"), and $slots, the static
|
|
2030
|
+
// map of the names the usage site filled, so a wrapper can be dropped when
|
|
2031
|
+
// nothing filled it (<footer :if="$slots.footer">). Both reach the
|
|
2032
|
+
// template (through templateScope, below) and both script modes (as
|
|
2033
|
+
// instance helpers), and a same-named store key shadows either.
|
|
2034
|
+
// Null-prototype, for the same reason storeApi is: `key in injected` must
|
|
2035
|
+
// not start answering true for toString, constructor and the rest
|
|
2036
|
+
const injected: Record<string, any> = Object.assign(Object.create(null), {
|
|
2037
|
+
$emit,
|
|
2038
|
+
$slots: Object.fromEntries(Object.keys(this.slots ?? {}).map(name => [name, true])),
|
|
2039
|
+
})
|
|
2040
|
+
|
|
1531
2041
|
// scripts run before the template renders so `$:` values are initialized;
|
|
1532
2042
|
// a `:mounted` script defers entirely until mount() instead. A top-level
|
|
1533
2043
|
// `export default` switches the script to factory mode (plain lexical JS)
|
|
@@ -1536,7 +2046,13 @@ export class Component79 {
|
|
|
1536
2046
|
const defer = (code: string) => `await $mounted();${code}`
|
|
1537
2047
|
|
|
1538
2048
|
this.scripts.forEach((script, index) => {
|
|
1539
|
-
|
|
2049
|
+
// the file's other components are passed as parameters of the compiled
|
|
2050
|
+
// script, not just left on the store's prototype: a factory script runs
|
|
2051
|
+
// as plain lexical JS with no `with`, so a bare `Row` in one would
|
|
2052
|
+
// resolve to nothing at all. In setup mode this composes with `with` -
|
|
2053
|
+
// scriptScope's `has` declines any name that is a helper, so the
|
|
2054
|
+
// parameter is what the name resolves to
|
|
2055
|
+
const instanceHelpers = { $mounted, $self, $$self, ...injected, ...siblingScope }
|
|
1540
2056
|
const at: ScriptLocation = { filename: this.filename, index }
|
|
1541
2057
|
const factoryCode = transformFactoryScript(script.content)
|
|
1542
2058
|
if (factoryCode !== null) {
|
|
@@ -1555,16 +2071,16 @@ export class Component79 {
|
|
|
1555
2071
|
})
|
|
1556
2072
|
|
|
1557
2073
|
const content = document.createDocumentFragment()
|
|
1558
|
-
// the
|
|
1559
|
-
//
|
|
1560
|
-
// through
|
|
1561
|
-
//
|
|
1562
|
-
// the component-key scan don't see it, and every read still forwards
|
|
1563
|
-
// through the reactive store, keeping dependency tracking intact
|
|
2074
|
+
// the injected names, served by has/get only - never as own keys - so
|
|
2075
|
+
// Object.keys, snapshot spreads and the component-key scan don't see them,
|
|
2076
|
+
// and every read still forwards through the reactive store, keeping
|
|
2077
|
+
// dependency tracking intact
|
|
1564
2078
|
const templateScope = new Proxy(store as Record<string, any>, {
|
|
1565
|
-
has: (target, key) => key === "
|
|
2079
|
+
has: (target, key) => (typeof key === "string" && key in injected) || Reflect.has(target, key),
|
|
1566
2080
|
get: (target, key, receiver) =>
|
|
1567
|
-
key === "
|
|
2081
|
+
typeof key === "string" && key in injected && !Reflect.has(target, key)
|
|
2082
|
+
? injected[key]
|
|
2083
|
+
: Reflect.get(target, key, receiver),
|
|
1568
2084
|
})
|
|
1569
2085
|
content.append(this.startMarker, renderNodes(this.template, templateScope, fx, shadow), this.endMarker)
|
|
1570
2086
|
this.content = content
|