jq79 0.4.13 → 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/dist/jq79.cjs +13 -13
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.d.ts +4 -1
- package/dist/jq79.global.js +13 -13
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +13 -13
- package/dist/jq79.js.map +1 -1
- package/dist/reactive.d.ts +2 -1
- package/dist/transform.d.ts +1 -0
- package/package.json +1 -1
- package/src/jq79.ts +294 -15
- package/src/reactive.ts +55 -4
- package/src/transform.ts +12 -2
package/dist/reactive.d.ts
CHANGED
|
@@ -7,10 +7,11 @@ type Unsubscribe = () => void;
|
|
|
7
7
|
export type ReactiveDeepData<T> = T & {
|
|
8
8
|
$on: (dotKey: string, listener: ChangeListener, options?: ListenerOptions) => Unsubscribe;
|
|
9
9
|
$onAny: (listener: AnyChangeListener, options?: ListenerOptions) => Unsubscribe;
|
|
10
|
-
$effect: (run: () => void) => Unsubscribe;
|
|
10
|
+
$effect: (run: () => void, alsoWakenBy?: Record<string, any>[]) => Unsubscribe;
|
|
11
11
|
$dispose: () => void;
|
|
12
12
|
};
|
|
13
13
|
export declare const untracked: <T>(fn: () => T) => T;
|
|
14
|
+
export declare const ALSO_WAKEN_BY: unique symbol;
|
|
14
15
|
export declare const $reactive: <T extends Record<string, any>>(data: T) => ReactiveDeepData<T>;
|
|
15
16
|
export type EffectScope = {
|
|
16
17
|
effect: (run: () => void) => void;
|
package/dist/transform.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export declare const transformSetupScript: (src: string) => SetupTransform;
|
|
|
6
6
|
export type PropDecl = {
|
|
7
7
|
name: string;
|
|
8
8
|
default?: string;
|
|
9
|
+
as?: string;
|
|
9
10
|
};
|
|
10
11
|
export declare const parsePropsPattern: (pattern: string | undefined) => PropDecl[] | null;
|
|
11
12
|
export declare const parseFactoryProps: (src: string) => PropDecl[] | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jq79",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.14",
|
|
4
4
|
"description": "Mini reactive component library: single-file components, Svelte-style setup scripts, fine-grained proxy reactivity. Single-file build, zero dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"reactive",
|
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 `.`
|
|
@@ -222,6 +229,230 @@ const findComponentKey = (scope: Record<string, any>, tag: string): string | nul
|
|
|
222
229
|
const MAX_NESTING_DEPTH = 200
|
|
223
230
|
let nestingDepth = 0
|
|
224
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
|
+
|
|
225
456
|
// <MyComponent :user :title="'str'"></MyComponent> - renders a child
|
|
226
457
|
// component instance at this position. Props: `:name="expr"` evaluates expr
|
|
227
458
|
// in the parent scope (`:name` alone is shorthand for `:name="name"`), plain
|
|
@@ -245,6 +476,11 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
245
476
|
const wrapper = document.createDocumentFragment()
|
|
246
477
|
wrapper.append(anchor, endAnchor)
|
|
247
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
|
+
|
|
248
484
|
const props: Record<string, string> = {} // prop name -> expression in parent scope
|
|
249
485
|
const models: Record<string, string> = {} // model name -> assignable expression in parent scope
|
|
250
486
|
const events: Array<[string, string]> = [] // @attr (modifiers included) -> handler expression
|
|
@@ -386,6 +622,9 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
386
622
|
siblings: nextDef.siblings,
|
|
387
623
|
name: nextDef.name,
|
|
388
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
|
|
389
628
|
// the writeback half of :model - one event, one contract. The name is
|
|
390
629
|
// normalized like the attribute was (kebab->camel; absent means default),
|
|
391
630
|
// and everything off-contract warns and does nothing: an event protocol's
|
|
@@ -568,6 +807,12 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
568
807
|
const withExpr = node.attrs[":with"]
|
|
569
808
|
const scope = withExpr !== undefined ? createWithScope(withExpr, outerScope) : outerScope
|
|
570
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
|
+
|
|
571
816
|
const componentKey = findComponentKey(scope, node.tag)
|
|
572
817
|
if (componentKey) return renderNestedComponent(componentKey, node, scope, fx, shadow)
|
|
573
818
|
|
|
@@ -671,6 +916,13 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
671
916
|
const options = allowedExpr !== undefined ? { allowUrl: normalizeAllowUrl(evalExpr(allowedExpr, scope)) } : undefined
|
|
672
917
|
el.innerHTML = sanitizeHTML(String(evalExpr(htmlExpr, scope) ?? ""), options)
|
|
673
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))
|
|
674
926
|
} else {
|
|
675
927
|
el.appendChild(renderNodes(node.children, scope, fx, shadow))
|
|
676
928
|
}
|
|
@@ -959,8 +1211,10 @@ const VOID_ELEMENTS = new Set([
|
|
|
959
1211
|
])
|
|
960
1212
|
|
|
961
1213
|
// a self-closing tag with its attributes; quoted attribute values are matched
|
|
962
|
-
// as whole chunks so a "/>" inside one doesn't end the tag early
|
|
963
|
-
|
|
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
|
|
964
1218
|
const RAW_BLOCK_RE = /(<script[\s\S]*?<\/script\s*>|<style[\s\S]*?<\/style\s*>)/gi
|
|
965
1219
|
|
|
966
1220
|
// expands self-closing tags (<MyComponent />, <div />) into explicit
|
|
@@ -983,7 +1237,7 @@ const expandSelfClosingTags = (src: string): string =>
|
|
|
983
1237
|
// a start tag with its attributes, quote-aware so a ">" inside a value doesn't
|
|
984
1238
|
// end it early; and a single spread attribute in name position (preceded by
|
|
985
1239
|
// start-or-whitespace), its expression an identifier or member path
|
|
986
|
-
const OPEN_TAG_RE = /<([A-Za-z][\w
|
|
1240
|
+
const OPEN_TAG_RE = /<([A-Za-z][\w.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g
|
|
987
1241
|
const ATTR_SPREAD_RE = /"[^"]*"|'[^']*'|(^|\s)\.\.\.([A-Za-z_$][\w$.]*)/g
|
|
988
1242
|
|
|
989
1243
|
// `...expr` as an attribute is sugar for :props="expr" (spread an object's
|
|
@@ -1535,6 +1789,12 @@ export class Component79 {
|
|
|
1535
1789
|
// declared; the file's own component has none - it is the default, and a
|
|
1536
1790
|
// default is named by whoever imports it
|
|
1537
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
|
|
1538
1798
|
|
|
1539
1799
|
data: ReactiveDeepData<Record<string, any>> | null = null
|
|
1540
1800
|
|
|
@@ -1690,6 +1950,11 @@ export class Component79 {
|
|
|
1690
1950
|
: { ...data }
|
|
1691
1951
|
const unfilled = new Set([...declared].filter(name => !(name in data)))
|
|
1692
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 })
|
|
1693
1958
|
|
|
1694
1959
|
const store = $reactive(raw)
|
|
1695
1960
|
const fx = createEffectScope(store)
|
|
@@ -1759,6 +2024,20 @@ export class Component79 {
|
|
|
1759
2024
|
const $import = (url: string): Promise<any> =>
|
|
1760
2025
|
modules && url in modules ? Promise.resolve(modules[url]) : importResource(url)
|
|
1761
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
|
+
|
|
1762
2041
|
// scripts run before the template renders so `$:` values are initialized;
|
|
1763
2042
|
// a `:mounted` script defers entirely until mount() instead. A top-level
|
|
1764
2043
|
// `export default` switches the script to factory mode (plain lexical JS)
|
|
@@ -1773,7 +2052,7 @@ export class Component79 {
|
|
|
1773
2052
|
// resolve to nothing at all. In setup mode this composes with `with` -
|
|
1774
2053
|
// scriptScope's `has` declines any name that is a helper, so the
|
|
1775
2054
|
// parameter is what the name resolves to
|
|
1776
|
-
const instanceHelpers = { $
|
|
2055
|
+
const instanceHelpers = { $mounted, $self, $$self, ...injected, ...siblingScope }
|
|
1777
2056
|
const at: ScriptLocation = { filename: this.filename, index }
|
|
1778
2057
|
const factoryCode = transformFactoryScript(script.content)
|
|
1779
2058
|
if (factoryCode !== null) {
|
|
@@ -1792,16 +2071,16 @@ export class Component79 {
|
|
|
1792
2071
|
})
|
|
1793
2072
|
|
|
1794
2073
|
const content = document.createDocumentFragment()
|
|
1795
|
-
// the
|
|
1796
|
-
//
|
|
1797
|
-
// through
|
|
1798
|
-
//
|
|
1799
|
-
// the component-key scan don't see it, and every read still forwards
|
|
1800
|
-
// 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
|
|
1801
2078
|
const templateScope = new Proxy(store as Record<string, any>, {
|
|
1802
|
-
has: (target, key) => key === "
|
|
2079
|
+
has: (target, key) => (typeof key === "string" && key in injected) || Reflect.has(target, key),
|
|
1803
2080
|
get: (target, key, receiver) =>
|
|
1804
|
-
key === "
|
|
2081
|
+
typeof key === "string" && key in injected && !Reflect.has(target, key)
|
|
2082
|
+
? injected[key]
|
|
2083
|
+
: Reflect.get(target, key, receiver),
|
|
1805
2084
|
})
|
|
1806
2085
|
content.append(this.startMarker, renderNodes(this.template, templateScope, fx, shadow), this.endMarker)
|
|
1807
2086
|
this.content = content
|
package/src/reactive.ts
CHANGED
|
@@ -11,8 +11,10 @@ export type ReactiveDeepData<T> = T & {
|
|
|
11
11
|
$on: (dotKey: string, listener: ChangeListener, options?: ListenerOptions) => Unsubscribe
|
|
12
12
|
$onAny: (listener: AnyChangeListener, options?: ListenerOptions) => Unsubscribe
|
|
13
13
|
// runs `run` immediately, recording every dotKey it reads off this store, then
|
|
14
|
-
// re-runs it whenever a changed dotKey overlaps one of those - see pathsOverlap
|
|
15
|
-
|
|
14
|
+
// re-runs it whenever a changed dotKey overlaps one of those - see pathsOverlap.
|
|
15
|
+
// `alsoWakenBy` registers the same effect with other stores as well, so a
|
|
16
|
+
// change in any of them wakes it too (see ATTACH)
|
|
17
|
+
$effect: (run: () => void, alsoWakenBy?: Record<string, any>[]) => Unsubscribe
|
|
16
18
|
// drops this store's subscriptions to the stores nested inside it (see
|
|
17
19
|
// bridge). A store that outlives the one holding it - the shared-state case -
|
|
18
20
|
// would otherwise keep the dead holder's listeners on its own list forever
|
|
@@ -85,6 +87,29 @@ export const untracked = <T>(fn: () => T): T => {
|
|
|
85
87
|
|
|
86
88
|
type Effect = { deps: Set<string>; run: () => void }
|
|
87
89
|
|
|
90
|
+
// an effect lives in exactly one store's `effects` set - the one whose
|
|
91
|
+
// $effect created it - and only that store's notify walks it. Content that
|
|
92
|
+
// reads two stores at once (a component's slot content: the parent's names
|
|
93
|
+
// plus the slot props the child passes it) needs one record in both sets, so
|
|
94
|
+
// a store serves this attach handle beside $on/$effect. Tracking already
|
|
95
|
+
// spans stores - trackerStack is module-level, so one run's deps are whatever
|
|
96
|
+
// it read, wherever it read it - only the waking didn't.
|
|
97
|
+
//
|
|
98
|
+
// Named like the compiled scripts' internals ($__effect, $__import) because it
|
|
99
|
+
// is one: `key in store` never answers true for a storeApi name, so `with`
|
|
100
|
+
// can't see it and no template expression can reach it.
|
|
101
|
+
//
|
|
102
|
+
// The cost, accepted: deps are dot-paths with no store namespace, so a name
|
|
103
|
+
// that exists in both stores wakes the effect from either. A spurious re-run,
|
|
104
|
+
// never a stale render
|
|
105
|
+
const ATTACH = "$__attach"
|
|
106
|
+
|
|
107
|
+
// the extra stores every effect created off a scope must be attached to. Read
|
|
108
|
+
// by createEffectScope off the scope it is given, so a scope can hand the
|
|
109
|
+
// arrangement down to whatever renders inside it (nested :each item scopes,
|
|
110
|
+
// a nested component's prop-sync effects) without every call site knowing
|
|
111
|
+
export const ALSO_WAKEN_BY = Symbol("jq79.alsoWakenBy")
|
|
112
|
+
|
|
88
113
|
export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepData<T> => {
|
|
89
114
|
const exactListeners = new Map<string, Set<ChangeListener>>()
|
|
90
115
|
const anyListeners = new Set<AnyChangeListener>()
|
|
@@ -269,7 +294,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
269
294
|
return () => anyListeners.delete(listener)
|
|
270
295
|
}
|
|
271
296
|
|
|
272
|
-
const $effect = (run: () => void): Unsubscribe => {
|
|
297
|
+
const $effect = (run: () => void, alsoWakenBy?: Record<string, any>[]): Unsubscribe => {
|
|
273
298
|
// a notify landing while this effect runs (an item's render writing to
|
|
274
299
|
// the store, waking the very effect that is rendering it) must not
|
|
275
300
|
// re-enter mid-run - the half-done run would race its own repeat over
|
|
@@ -309,7 +334,28 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
309
334
|
},
|
|
310
335
|
}
|
|
311
336
|
effects.add(effect)
|
|
337
|
+
// the shared case is rare (only slot content asks for it) and this
|
|
338
|
+
// function is on the stack for as long as whatever it renders - a
|
|
339
|
+
// component that renders itself stacks 200 of these - so it keeps the
|
|
340
|
+
// shape it had, and the extra bookkeeping lives in its own frame
|
|
341
|
+
if (alsoWakenBy?.length) return attachAndRun(effect, alsoWakenBy)
|
|
342
|
+
effect.run()
|
|
343
|
+
return () => { effects.delete(effect) }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// attached before the first run, so a store that notifies during it (a setup
|
|
347
|
+
// script's write, a prop sync) reaches this effect like any other
|
|
348
|
+
const attachAndRun = (effect: Effect, alsoWakenBy: Record<string, any>[]): Unsubscribe => {
|
|
349
|
+
const detach = alsoWakenBy.map(store => store?.[ATTACH]?.(effect)).filter(Boolean) as Unsubscribe[]
|
|
312
350
|
effect.run()
|
|
351
|
+
return () => {
|
|
352
|
+
effects.delete(effect)
|
|
353
|
+
detach.forEach(drop => drop())
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const $__attach = (effect: Effect): Unsubscribe => {
|
|
358
|
+
effects.add(effect)
|
|
313
359
|
return () => { effects.delete(effect) }
|
|
314
360
|
}
|
|
315
361
|
|
|
@@ -322,6 +368,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
322
368
|
storeApi.$onAny = $onAny
|
|
323
369
|
storeApi.$effect = $effect
|
|
324
370
|
storeApi.$dispose = $dispose
|
|
371
|
+
storeApi[ATTACH] = $__attach
|
|
325
372
|
|
|
326
373
|
return reactive
|
|
327
374
|
}
|
|
@@ -346,9 +393,13 @@ export type EffectScope = {
|
|
|
346
393
|
export const createEffectScope = (scope: Record<string, any>): EffectScope => {
|
|
347
394
|
const disposers: Unsubscribe[] = []
|
|
348
395
|
const runs: (() => void)[] = []
|
|
396
|
+
// whatever the scope was handed (slot content is the only thing that sets
|
|
397
|
+
// it today): the stores this scope's effects belong to besides their own.
|
|
398
|
+
// Left undefined when there are none, which is $effect's fast path
|
|
399
|
+
const alsoWakenBy: Record<string, any>[] | undefined = (scope as any)[ALSO_WAKEN_BY]
|
|
349
400
|
return {
|
|
350
401
|
effect: run => {
|
|
351
|
-
disposers.push(scope.$effect(run))
|
|
402
|
+
disposers.push(scope.$effect(run, alsoWakenBy))
|
|
352
403
|
runs.push(run)
|
|
353
404
|
},
|
|
354
405
|
onDispose: fn => { disposers.push(fn) },
|
package/src/transform.ts
CHANGED
|
@@ -475,7 +475,11 @@ const staticImportToAwait = (clause: string | undefined, spec: string, n: number
|
|
|
475
475
|
// identifier, no attribute at all) declares nothing and stays permissive.
|
|
476
476
|
// ---------------------------------------------------------------------------
|
|
477
477
|
|
|
478
|
-
|
|
478
|
+
// `as` is the local name the pattern binds the key to, when it isn't the key
|
|
479
|
+
// itself (`{ item: row }`). A prop signature has no use for it - what the
|
|
480
|
+
// store holds is the key - but the slot binder (`:slot="{ item: row }"`) is
|
|
481
|
+
// the same pattern read for the other half: which names the content uses
|
|
482
|
+
export type PropDecl = { name: string; default?: string; as?: string }
|
|
479
483
|
|
|
480
484
|
const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/
|
|
481
485
|
|
|
@@ -576,7 +580,13 @@ export const parsePropsPattern = (pattern: string | undefined): PropDecl[] | nul
|
|
|
576
580
|
const colon = indexOfTopLevel(named, ":")
|
|
577
581
|
const name = (colon === -1 ? named : named.slice(0, colon)).trim()
|
|
578
582
|
if (!IDENTIFIER_RE.test(name)) continue
|
|
579
|
-
|
|
583
|
+
const decl: PropDecl = { name }
|
|
584
|
+
if (fallback !== undefined) decl.default = fallback
|
|
585
|
+
// only a plain rename is kept: `{ user: { id } }` binds no single name, so
|
|
586
|
+
// there is nothing to record - the prop is still declared under its key
|
|
587
|
+
const local = colon === -1 ? "" : named.slice(colon + 1).trim()
|
|
588
|
+
if (IDENTIFIER_RE.test(local)) decl.as = local
|
|
589
|
+
props.push(decl)
|
|
580
590
|
}
|
|
581
591
|
return props
|
|
582
592
|
}
|