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/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
  }