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.
@@ -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;
@@ -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.13",
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
- const SELF_CLOSING_RE = /<([A-Za-z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)\/>/g
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-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g
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 = { $emit, $mounted, $self, $$self, ...siblingScope }
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 template renders under a scope that also answers $emit (unless the
1796
- // store shadows the name), so an inline handler can emit without routing
1797
- // through a setup function: @input="$emit('update', $event.target.value)".
1798
- // has/get only - never an own key - so Object.keys, snapshot spreads and
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 === "$emit" || Reflect.has(target, key),
2079
+ has: (target, key) => (typeof key === "string" && key in injected) || Reflect.has(target, key),
1803
2080
  get: (target, key, receiver) =>
1804
- key === "$emit" && !Reflect.has(target, key) ? $emit : Reflect.get(target, key, receiver),
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
- $effect: (run: () => void) => Unsubscribe
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
- export type PropDecl = { name: string; default?: string }
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
- props.push(fallback === undefined ? { name } : { name, default: fallback })
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
  }