jq79 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,238 @@
1
+ import { parseHTML, type HTMLNode, type HTMLElementNode } from "./html"
2
+ import { transformSetupScript, transformFactoryScript, parsePropsPattern, parseFactoryProps } from "./transform"
3
+ import {
4
+ COMPONENT_NAME_RE, COMPONENT_TAG_ATTR, EACH_PATTERN, EXPR_PARAMS, INSTANCE_HELPER_NAMES, PRECOMPILED_QUEUE,
5
+ SETUP_HELPER_NAMES, functionText,
6
+ assignment, declaredPropNames, defer, factoryBody, factoryParams, functionKey, isControlAttr, isSlotTag,
7
+ kebabToCamel, prepareSource, readSetupSignature, scopedBody, setupBody, setupParams, splitText, withBody,
8
+ type TagBlock, type TemplateNode,
9
+ } from "./source"
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // precompile
13
+ //
14
+ // Every function the runtime would build for a component, found without
15
+ // rendering it: each expression in its template, each of its scripts, each
16
+ // prop default - as the [params, body] pairs makeFunction would be handed, so
17
+ // a table of them answers its lookups (see "safe eval" in jq79.ts). What renders
18
+ // nothing still counts: a branch not taken, a handler never clicked, a :each
19
+ // over an empty list are all here because each *could* run.
20
+ //
21
+ // It reads a file the way parseComponentString does - the same pre-parse
22
+ // rewrites, the same split into the file's own component and its named
23
+ // <template>s - with parseHTML standing in for DOMParser, which node and a
24
+ // worker don't have. It reads a template the way the renderer does, directive
25
+ // by directive, and builds each body with the same functions the runtime
26
+ // compiles with (withBody, scopedBody, setupBody, factoryBody), so a key can
27
+ // only match. Where the renderer's answer depends on the page - which scope
28
+ // key a component tag resolves to - it takes every plausible one: an extra
29
+ // function is an entry nobody looks up. What it can't see is a miss, and safe
30
+ // mode reports a miss by name. tests/precompile.test.ts holds it against what
31
+ // the runtime actually compiles
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export type Precompiled = [params: string[], body: string]
35
+
36
+ const isElementNode = (node: HTMLNode): node is HTMLElementNode => typeof node !== "string"
37
+
38
+ // mirrors elementToAST: the component stamp lifted off attrs into a field
39
+ const toTemplateNode = (el: HTMLElementNode): TemplateNode => {
40
+ const { [COMPONENT_TAG_ATTR]: component, ...attrs } = el.attrs
41
+ return {
42
+ tag: el.tag,
43
+ attrs,
44
+ ...(component === undefined ? {} : { component }),
45
+ children: el.children.map(child => (typeof child === "string" ? child : toTemplateNode(child))),
46
+ }
47
+ }
48
+
49
+ const textOf = (el: HTMLElementNode): string => el.children.map(child => (typeof child === "string" ? child : textOf(child))).join("")
50
+
51
+ // the scope keys a tag can resolve to. findComponentKey matches any
52
+ // capitalized key that equals the tag once dashes and case are gone, so the
53
+ // key itself comes from the page; these are the spellings the file offers -
54
+ // the tag as written, its PascalCase, and every capitalized name the file
55
+ // declares that matches
56
+ const componentKeyCandidates = (node: TemplateNode, known: string[]): string[] => {
57
+ const tag = node.component ?? node.tag
58
+ const normalized = tag.replace(/-/g, "").toLowerCase()
59
+ const keys = new Set(known.filter(name => /^[A-Z]/.test(name) && name.replace(/-/g, "").toLowerCase() === normalized))
60
+ keys.add(node.component ?? kebabToCamel(node.tag).replace(/^./, c => c.toUpperCase()))
61
+ return [...keys]
62
+ }
63
+
64
+ type AddExpression = (expr: string, extras?: string[]) => void
65
+
66
+ // every expression a template can evaluate, walked as renderNodes, renderNode,
67
+ // renderEach, renderConditional, renderSlot and renderNestedComponent would
68
+ // walk it - each clause names the one it follows
69
+ const collectExpressions = (nodes: (TemplateNode | string)[], known: string[], add: AddExpression) => {
70
+ for (const node of nodes) {
71
+ // renderNodes: text with an interpolation in it
72
+ if (typeof node === "string") {
73
+ if (node.includes("{{")) splitText(node).forEach(part => { if (typeof part !== "string") add(part.expr) })
74
+ continue
75
+ }
76
+ const attrs = node.attrs
77
+ // renderConditional (chainOf), renderEach, and :with in renderNode - on any node
78
+ if (":if" in attrs) add(attrs[":if"])
79
+ if (":elseif" in attrs) add(attrs[":elseif"])
80
+ if (":each" in attrs) {
81
+ const match = attrs[":each"].match(EACH_PATTERN)
82
+ if (match) add(match[3])
83
+ if (":key" in attrs) add(attrs[":key"])
84
+ }
85
+ if (":with" in attrs) add(attrs[":with"])
86
+ // bindSlotProps: a :slot binder's defaults, on a component tag or its <template>
87
+ for (const attr in attrs) {
88
+ if (attr === ":slot" || attr.startsWith(":slot.")) {
89
+ parsePropsPattern(attrs[attr] || undefined)?.forEach(({ default: fallback }) => { if (fallback !== undefined) add(fallback) })
90
+ }
91
+ }
92
+
93
+ if (isSlotTag(node.tag)) {
94
+ // renderSlot: a <slot>'s props - read by name, not camelCased
95
+ for (const attr in attrs) {
96
+ if (isControlAttr(attr) || attr.startsWith("@") || !attr.startsWith(":")) continue
97
+ add(attrs[attr] || attr.slice(1))
98
+ }
99
+ } else {
100
+ // renderNestedComponent, for a tag that is or may become a component: the
101
+ // key it resolves through, its props, spreads, models and tag events
102
+ if (node.component !== undefined || node.tag.includes("-")) {
103
+ componentKeyCandidates(node, known).forEach(key => add(key))
104
+ for (const attr in attrs) {
105
+ const value = attrs[attr]
106
+ if (attr === ":props" || attr.startsWith(":props.")) add(value)
107
+ else if (isControlAttr(attr)) continue
108
+ else if (attr.startsWith("@")) add(value, ["$event"])
109
+ else if (attr === ":model" || attr.startsWith(":model.")) {
110
+ const expr = value || (attr === ":model" ? "model" : kebabToCamel(attr.slice(":model.".length)))
111
+ add(expr)
112
+ add(assignment(expr), ["$value"])
113
+ } else if (attr.startsWith(":")) add(value || kebabToCamel(attr.slice(1)))
114
+ else add(JSON.stringify(value))
115
+ }
116
+ }
117
+ // renderNode, for the element it renders as (and a component tag renders
118
+ // as, until it resolves): events, bindings, and the control directives
119
+ for (const attr in attrs) {
120
+ const value = attrs[attr]
121
+ if (attr.startsWith("@")) add(value, ["$event"])
122
+ else if (attr === ":model" || attr.startsWith(":model.")) continue
123
+ else if (attr === ":class" || attr.startsWith(":class.") || attr === ":text" || attr === ":html" ||
124
+ attr === ":html.allowed" || attr === ":value" || attr === ":checked" || attr === ":selected") add(value)
125
+ else if (isControlAttr(attr)) continue
126
+ else if (attr.startsWith(":")) add(value || kebabToCamel(attr.slice(1)))
127
+ }
128
+ }
129
+ collectExpressions(node.children, known, add)
130
+ }
131
+ }
132
+
133
+ // every function the runtime would build for the component in `source`, as
134
+ // the [params, body] makeFunction would be handed - see the note above
135
+ export const precompile = (source: string): Precompiled[] => {
136
+ const found = new Map<string, Precompiled>()
137
+ const add = (params: string[], body: string) => { found.set(functionKey(params, body), [params, body]) }
138
+ // an expression compiles to its scoped form first, and falls back to (or is
139
+ // demoted to) the `with` form - both, where the scoped one exists
140
+ const addExpression: AddExpression = (expr, extras = []) => {
141
+ const params = [...EXPR_PARAMS, ...extras]
142
+ const scoped = scopedBody(expr, extras)
143
+ if (scoped !== null) add(params, scoped)
144
+ add(params, withBody(expr))
145
+ }
146
+
147
+ // parseComponentString's split: a top-level <template> declares another
148
+ // component of the file (a valid name, first one wins), everything else is
149
+ // the file's own
150
+ const top = parseHTML(prepareSource(source)).filter(isElementNode)
151
+ const siblings: string[] = []
152
+ const components: HTMLElementNode[][] = [top.filter(el => el.tag !== "template")]
153
+ top.filter(el => el.tag === "template").forEach(el => {
154
+ const name = el.attrs.name
155
+ if (name === undefined || !COMPONENT_NAME_RE.test(name) || siblings.includes(name)) return
156
+ siblings.push(name)
157
+ components.push(el.children.filter(isElementNode))
158
+ })
159
+
160
+ components.forEach(elements => precompileComponent(elements, siblings, add, addExpression))
161
+
162
+ return [...found.values()]
163
+ }
164
+
165
+ const precompileComponent = (
166
+ elements: HTMLElementNode[],
167
+ siblings: string[],
168
+ add: (params: string[], body: string) => void,
169
+ addExpression: AddExpression
170
+ ) => {
171
+ const scripts: TagBlock[] = elements
172
+ .filter(el => el.tag === "script")
173
+ .map(el => ({ attrs: el.attrs, content: textOf(el) }))
174
+ const template = elements.filter(el => el.tag !== "script" && el.tag !== "style").map(toTemplateNode)
175
+
176
+ // the helper names a script is compiled with: renderWith's
177
+ // { ...SETUP_HELPERS, ...instanceHelpers }, where instanceHelpers is
178
+ // { $mounted, $destroyed, $attached, $detached, $computed, $self, $$self,
179
+ // ...injected, ...siblingScope } - key order included, because the
180
+ // parameters are positional (built as objects, so a sibling named like a
181
+ // helper keeps the helper's place, as it does there)
182
+ //
183
+ // Reading the signatures is also the first thing renderWith does, and where
184
+ // it refuses a component - a factory destructuring a ctx name out of props
185
+ // throws there, before anything compiles. Such a component has nothing to
186
+ // precompile, and returning here leaves its error to the runtime, which is
187
+ // where the page sees it, rather than failing the build (or the worker) on
188
+ // its behalf
189
+ let declared: Set<string>
190
+ try {
191
+ declared = declaredPropNames(scripts, readSetupSignature)
192
+ } catch {
193
+ return
194
+ }
195
+ const siblingScope = Object.fromEntries(siblings.filter(name => !declared.has(name)).map(name => [name, true]))
196
+ const helperNames = Object.keys({
197
+ ...Object.fromEntries(SETUP_HELPER_NAMES.map(name => [name, true])),
198
+ ...Object.fromEntries(INSTANCE_HELPER_NAMES.map(name => [name, true])),
199
+ ...siblingScope,
200
+ })
201
+
202
+ // the names the file declares, for the component tags that resolve to one
203
+ const known = [...siblings]
204
+ scripts.forEach(script => {
205
+ const deferred = ":mounted" in script.attrs
206
+ const factoryCode = transformFactoryScript(script.content)
207
+ if (factoryCode !== null) {
208
+ const props = parseFactoryProps(script.content)
209
+ props?.forEach(({ name, default: fallback }) => { known.push(name); if (fallback !== undefined) addExpression(fallback) })
210
+ add(factoryParams(helperNames), factoryBody(deferred ? defer(factoryCode) : factoryCode))
211
+ } else {
212
+ const { vars, code } = transformSetupScript(script.content)
213
+ known.push(...vars)
214
+ readSetupSignature(script)?.forEach(({ name, default: fallback }) => { known.push(name); if (fallback !== undefined) addExpression(fallback) })
215
+ add(setupParams(helperNames), setupBody(deferred ? defer(code) : code))
216
+ }
217
+ })
218
+
219
+ collectExpressions(template, known, addExpression)
220
+ }
221
+
222
+ // a component's precompiled functions as the classic script that registers
223
+ // them - classic, because they compile under `with`, which is a SyntaxError in
224
+ // strict code and every module is strict.
225
+ //
226
+ // `parses` says whether a function's text parses as that one function: `new
227
+ // Function` where eval is at hand (the Vite plugin, on node), a JavaScript
228
+ // parser where it isn't (the service worker). One that doesn't parse ships as
229
+ // null - the runtime's cached syntax error, which renders nothing, as it does
230
+ // under eval. And because only text that parses as a single function is ever
231
+ // written, no expression can close its function early and run what follows
232
+ // when the script loads
233
+ export const precompiledScript = (entries: Precompiled[], parses: (params: string[], body: string) => boolean): string => {
234
+ const items = entries.map(([params, body]) =>
235
+ `[${JSON.stringify(params)}, ${JSON.stringify(body)}, ${parses(params, body) ? functionText(params, body) : "null"}]`
236
+ )
237
+ return `(self.${PRECOMPILED_QUEUE} = self.${PRECOMPILED_QUEUE} || []).push(\n${items.join(",\n")}\n)\n`
238
+ }
package/src/reactive.ts CHANGED
@@ -173,19 +173,32 @@ const STORE = Symbol("jq79.store")
173
173
  const isStore = (value: any): boolean =>
174
174
  value !== null && typeof value === "object" && value[STORE] === true
175
175
 
176
+ // the effect the last $effect call made, set as that call returns - after its
177
+ // first run, so the effects that run created don't leave theirs here instead.
178
+ // How a Scope gets hold of the record behind the disposer $effect hands back,
179
+ // without $effect returning anything more than it always has (see Scope.effect)
180
+ let created: Effect | null = null
181
+
176
182
  // active $effect() runs, innermost last - a module-level stack (rather than
177
183
  // one per store) so nested effects across stores still nest correctly; reads
178
184
  // during a proxy's `get` trap are attributed to whichever run is on top
179
185
  const trackerStack: Set<string>[] = []
180
186
 
187
+ // the effect whose run is on top of trackerStack, or null while tracking is
188
+ // suspended. A store read by an effect it doesn't hold adopts it (see adopt)
189
+ let activeEffect: Effect | null = null
190
+
181
191
  // runs fn with dependency tracking suspended - reads inside it are attributed
182
192
  // to a throwaway set instead of the currently running effect
183
193
  export const untracked = <T>(fn: () => T): T => {
184
194
  trackerStack.push(new Set())
195
+ const outer = activeEffect
196
+ activeEffect = null
185
197
  try {
186
198
  return fn()
187
199
  } finally {
188
200
  trackerStack.pop()
201
+ activeEffect = outer
189
202
  }
190
203
  }
191
204
 
@@ -193,7 +206,39 @@ export const untracked = <T>(fn: () => T): T => {
193
206
  // own, plus any it was attached to), each keeping that store's trie in step
194
207
  // with the deps of the last settled run. It lives on the effect rather than
195
208
  // in a per-store map because `run` has to reach it without a lookup
196
- export type Effect = { deps: Set<string>; run: () => void; reindex: Set<(deps: Set<string>) => void>; deep: boolean; order: number }
209
+ //
210
+ // `home` is the effect set of the store that created it, which is what lets a
211
+ // read skip the adoption check with one comparison. `adopted` maps every other
212
+ // store that took it on (by that store's effect set) to the handle detaching
213
+ // it, and `read` collects the ones the current run read - a store the settled
214
+ // run didn't read lets go of it (see adopt)
215
+ export type Effect = {
216
+ deps: Set<string>
217
+ run: () => void
218
+ reindex: Set<(deps: Set<string>) => void>
219
+ deep: boolean
220
+ order: number
221
+ home: Set<Effect>
222
+ adopted: Map<Set<Effect>, Unsubscribe> | null
223
+ read: Set<Set<Effect>> | null
224
+ }
225
+
226
+ // the settled run read none of these stores: they stop waking the effect.
227
+ // `current = second` must leave `first` with nothing of it, or a write to the
228
+ // store it dropped still re-runs it - it is indexed there under the same
229
+ // namespace-free paths it reads off `second`
230
+ const releaseUnread = (effect: Effect) => {
231
+ effect.adopted!.forEach((detach, store) => {
232
+ if (effect.read?.has(store)) return
233
+ detach()
234
+ effect.adopted!.delete(store)
235
+ })
236
+ }
237
+
238
+ const releaseAll = (effect: Effect) => {
239
+ effect.adopted?.forEach(detach => detach())
240
+ effect.adopted = null
241
+ }
197
242
 
198
243
  // creation order, module-wide. The flat `effects` set used to give this for
199
244
  // free - iterating it ran effects oldest-first, so a parent's bindings always
@@ -227,12 +272,25 @@ const NO_DEPS: ReadonlySet<string> = new Set()
227
272
  // never a stale render
228
273
  const ATTACH = "$__attach"
229
274
 
275
+ // whether an effect is registered with this store - the holder of a bridge
276
+ // asks, so it doesn't wake what the nested store already will (see effectsFor)
277
+ const HOLDS = "$__holds"
278
+
230
279
  // the extra stores every effect created off a scope must be attached to. Read
231
280
  // by createEffectScope off the scope it is given, so a scope can hand the
232
281
  // arrangement down to whatever renders inside it (nested :each item scopes,
233
282
  // a nested component's prop-sync effects) without every call site knowing
234
283
  export const ALSO_WAKEN_BY = Symbol("jq79.alsoWakenBy")
235
284
 
285
+ // the effect scope that owns everything rendered under a data scope - set on a
286
+ // keyed :each row's item scope, pointing at that row's scope. Read the same
287
+ // way ALSO_WAKEN_BY is, off the data scope a new effect scope is created over,
288
+ // so every scope made anywhere inside the row registers with it: an :if branch,
289
+ // a nested :each and its rows, a component's prop sync, and slot content the
290
+ // row hands a child component, which is created by the child but closes over
291
+ // the row's names. That registry is what EffectScope.rerun walks
292
+ export const OWNER = Symbol("jq79.owner")
293
+
236
294
  export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepData<T> => {
237
295
  const exactListeners = new Map<string, Set<ChangeListener>>()
238
296
  const anyListeners = new Set<AnyChangeListener>()
@@ -339,7 +397,12 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
339
397
  eachDeep(node, effect => matched.add(effect))
340
398
  // a nested store sits here: an effect that read through it holds this
341
399
  // path and nothing below it, so its own set is the whole channel
342
- if (bridges.has(path)) eachOwn(node, effect => matched.add(effect))
400
+ //
401
+ // ...except an effect the nested store already wakes itself: one that
402
+ // read into it was adopted there (see adopt), and matches its own paths
403
+ // precisely. Waking it here too ran it twice per write
404
+ const bridged = bridges.get(path)
405
+ if (bridged) eachOwn(node, effect => { if (!bridged.store[HOLDS](effect)) matched.add(effect) })
343
406
  // ...whereas an array's length stands for the array: everything that
344
407
  // read an element has to hear a truncation, and those deps are below
345
408
  if (depth === segments.length - 2 && segments[depth + 1] === "length") {
@@ -774,6 +837,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
774
837
  // KEYS_SEGMENT), so adds and deletes wake exactly the effects enumerating
775
838
  ownKeys(target) {
776
839
  trackerStack[trackerStack.length - 1]?.add(keysPath(path))
840
+ if (activeEffect !== null && activeEffect.home !== effects) adopt(activeEffect)
777
841
  return Reflect.ownKeys(target)
778
842
  },
779
843
  get(target, key, receiver) {
@@ -784,6 +848,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
784
848
 
785
849
  const dotKey = path ? `${path}.${key}` : key
786
850
  trackerStack[trackerStack.length - 1]?.add(dotKey)
851
+ if (activeEffect !== null && activeEffect.home !== effects) adopt(activeEffect)
787
852
 
788
853
  // nested objects are wrapped here rather than up front, so the object
789
854
  // handed to $reactive is never rewritten
@@ -904,6 +969,9 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
904
969
  reindex: new Set(),
905
970
  deep,
906
971
  order: effectsCreated++,
972
+ home: effects,
973
+ adopted: null,
974
+ read: null,
907
975
  run: () => {
908
976
  if (running) {
909
977
  dirty = true
@@ -914,12 +982,16 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
914
982
  let cycles = 0
915
983
  do {
916
984
  dirty = false
985
+ effect.read = null
917
986
  const deps = new Set<string>()
918
987
  trackerStack.push(deps)
988
+ const outer = activeEffect
989
+ activeEffect = effect
919
990
  try {
920
991
  run()
921
992
  } finally {
922
993
  trackerStack.pop()
994
+ activeEffect = outer
923
995
  effect.deps = deps
924
996
  }
925
997
  } while (dirty && ++cycles < 100)
@@ -928,6 +1000,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
928
1000
  if (dirty) console.error("jq79: an effect re-woke itself 100 times in a row (it writes what it reads); giving up on it settling")
929
1001
  } finally {
930
1002
  running = false
1003
+ if (effect.adopted) releaseUnread(effect)
931
1004
  // the settled deps are the only ones worth indexing: the repeats of
932
1005
  // a dirty run overwrite each other, and a notify that lands mid-run
933
1006
  // is queued rather than dispatched, so nothing reads the index in
@@ -942,6 +1015,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
942
1015
  const forget = () => {
943
1016
  effects.delete(effect)
944
1017
  stopIndexing()
1018
+ releaseAll(effect)
945
1019
  }
946
1020
  // the shared case is rare (only slot content asks for it) and this
947
1021
  // function is on the stack for as long as whatever it renders - a
@@ -949,6 +1023,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
949
1023
  // shape it had, and the extra bookkeeping lives in its own frame
950
1024
  if (alsoWakenBy?.length) return attachAndRun(effect, alsoWakenBy, forget)
951
1025
  effect.run()
1026
+ created = effect
952
1027
  return forget
953
1028
  }
954
1029
 
@@ -957,6 +1032,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
957
1032
  const attachAndRun = (effect: Effect, alsoWakenBy: Record<string, any>[], forget: Unsubscribe): Unsubscribe => {
958
1033
  const detach = alsoWakenBy.map(store => store?.[ATTACH]?.(effect)).filter(Boolean) as Unsubscribe[]
959
1034
  effect.run()
1035
+ created = effect
960
1036
  return () => {
961
1037
  forget()
962
1038
  detach.forEach(drop => drop())
@@ -972,9 +1048,29 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
972
1048
  }
973
1049
  }
974
1050
 
1051
+ // an effect of another store just read this one. Tracking spans stores -
1052
+ // the deps it recorded are this store's paths - but waking did not: the
1053
+ // effect sat in its own store's index alone, where `list.1.busy` means
1054
+ // nothing, so a write here reached it only if it had also read the path
1055
+ // this store sits at in its own, and then through the bridge's catch-all.
1056
+ // A `:each` row reads its item directly and never does, so it never updated.
1057
+ // Registered here like slot content is (see ATTACH), and detached when the
1058
+ // effect is disposed
1059
+ //
1060
+ // Only an effect that isn't already registered here some other way: slot
1061
+ // content is attached to both its stores for as long as it lives, and is
1062
+ // not this function's to release
1063
+ const adopt = (effect: Effect) => {
1064
+ ;(effect.read ??= new Set()).add(effects)
1065
+ if (effect.adopted?.has(effects) || effects.has(effect)) return
1066
+ ;(effect.adopted ??= new Map()).set(effects, $__attach(effect))
1067
+ }
1068
+
975
1069
  const $dispose = () => {
976
1070
  bridges.forEach(({ unsubscribe }) => unsubscribe())
977
1071
  bridges.clear()
1072
+ // ...and the stores that adopted this one's effects by being read
1073
+ effects.forEach(effect => { if (effect.home === effects) releaseAll(effect) })
978
1074
  }
979
1075
 
980
1076
  storeApi.$on = $on
@@ -982,6 +1078,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
982
1078
  storeApi.$effect = $effect
983
1079
  storeApi.$dispose = $dispose
984
1080
  storeApi[ATTACH] = $__attach
1081
+ storeApi[HOLDS] = (effect: Effect) => effects.has(effect)
985
1082
 
986
1083
  return reactive
987
1084
  }
@@ -997,9 +1094,20 @@ export type EffectScope = {
997
1094
  onDispose: (fn: Unsubscribe) => void
998
1095
  // re-runs every effect registered on this scope, nested scopes excluded:
999
1096
  // how :each tells a reused, repositioned entry's dep-less bindings (the
1000
- // `{{ $index }}`-only case) about their move. Deps stay as they were -
1001
- // callers run it untracked
1097
+ // `{{ $index }}`-only case) about their move. Each run tracks its own deps,
1098
+ // as a woken one does; callers run it untracked, so none of them land on
1099
+ // the list effect that asked
1002
1100
  refresh: () => void
1101
+ // re-runs every effect on this scope and on every scope registered with it
1102
+ // (see OWNER), tracking each run as if a write had woken it. How a keyed
1103
+ // :each row that was handed a new item for the same key updates in place:
1104
+ // the loop name is a plain scope var, so nothing it read can wake the
1105
+ // bindings, and the deps they hold are the old item's
1106
+ rerun: () => void
1107
+ // registers this scope with the row owning `scope` as well (see OWNER):
1108
+ // slot content's own chain is the parent's, but the slot props it reads
1109
+ // come from wherever the child put the <slot>, which can be a keyed row
1110
+ ownedBy: (scope: Record<string, any>) => void
1003
1111
  dispose: () => void
1004
1112
  }
1005
1113
 
@@ -1019,10 +1127,17 @@ export type EffectScope = {
1019
1127
  // function (`runSetupScript(..., fx.effect, ...)` did) must wrap it instead
1020
1128
  // (`run => fx.effect(run)`). Both such call sites are in renderComponent
1021
1129
  class Scope implements EffectScope {
1022
- // built on first use: `runs` in particular is only ever read by refresh(),
1023
- // which only a :each whose template names a position ever calls
1130
+ // built on first use: `runs` in particular is only ever read by refresh()
1131
+ // and rerun(), which only a :each calls - for a row that moved and names a
1132
+ // position, or a keyed row handed a new item
1024
1133
  private disposers: Unsubscribe[] | null = null
1025
- private runs: (() => void)[] | null = null
1134
+ private runs: Effect[] | null = null
1135
+ // the scopes created under this one's data scope, when this one owns it
1136
+ // (see OWNER), and the owners this one registered with - two for slot
1137
+ // content written in one keyed row and placed in another (see ownedBy).
1138
+ // Neither exists outside a keyed :each row
1139
+ private children: Set<Scope> | null = null
1140
+ private owners: Scope[] | null = null
1026
1141
  // one options object for the whole scope instead of one per effect - $effect
1027
1142
  // destructures it on entry and keeps nothing. Left undefined on the common
1028
1143
  // path, which is $effect's own fast path
@@ -1033,11 +1148,21 @@ class Scope implements EffectScope {
1033
1148
  // it today): the stores this scope's effects belong to besides their own
1034
1149
  const alsoWakenBy: Record<string, any>[] | undefined = (scope as any)[ALSO_WAKEN_BY]
1035
1150
  this.options = deep || alsoWakenBy ? { deep, alsoWakenBy } : undefined
1151
+ this.ownedBy(scope)
1152
+ }
1153
+
1154
+ ownedBy(scope: Record<string, any>) {
1155
+ const owner: Scope | undefined = (scope as any)[OWNER]
1156
+ if (!owner || this.owners?.includes(owner)) return
1157
+ ;(this.owners ??= []).push(owner)
1158
+ ;(owner.children ??= new Set()).add(this)
1036
1159
  }
1037
1160
 
1161
+ // the effect record rather than `run`: a re-run through it tracks, so the
1162
+ // deps it settles on are the ones the run actually read
1038
1163
  effect(run: () => void) {
1039
1164
  ;(this.disposers ??= []).push(this.scope.$effect(run, this.options))
1040
- ;(this.runs ??= []).push(run)
1165
+ ;(this.runs ??= []).push(created!)
1041
1166
  }
1042
1167
 
1043
1168
  onDispose(fn: Unsubscribe) {
@@ -1045,7 +1170,19 @@ class Scope implements EffectScope {
1045
1170
  }
1046
1171
 
1047
1172
  refresh() {
1048
- this.runs?.forEach(run => run())
1173
+ this.runs?.forEach(effect => effect.run())
1174
+ }
1175
+
1176
+ // this scope's own effects first, then the scopes under it, in the order
1177
+ // they were created - parents before what they rendered, as a write would
1178
+ // wake them. Both lists are copied before anything runs: an :if that switches
1179
+ // branch disposes one child scope and renders another, already up to date,
1180
+ // and a disposed scope has nothing left to run
1181
+ rerun() {
1182
+ const runs = this.runs ? [...this.runs] : null
1183
+ const children = this.children ? [...this.children] : null
1184
+ runs?.forEach(effect => { if (this.runs) effect.run() })
1185
+ children?.forEach(child => child.rerun())
1049
1186
  }
1050
1187
 
1051
1188
  dispose() {
@@ -1055,8 +1192,40 @@ class Scope implements EffectScope {
1055
1192
  const disposers = this.disposers
1056
1193
  this.disposers = null
1057
1194
  this.runs = null
1195
+ this.children = null
1196
+ this.owners?.forEach(owner => owner.children?.delete(this))
1197
+ this.owners = null
1058
1198
  if (disposers) for (let i = 0; i < disposers.length; i++) disposers[i]()
1059
1199
  }
1060
1200
  }
1061
1201
 
1062
1202
  export const createEffectScope = (scope: Record<string, any>, deep = false): EffectScope => new Scope(scope, deep)
1203
+
1204
+ // a derived value: `get`'s result on `.value`, recomputed whenever anything it
1205
+ // read changes - in any store, the way any effect is woken. What comes back is
1206
+ // a store, so it does everything one does (passed as a prop, held in another
1207
+ // store and bridged there, $on("value")), seen through a read-only view: a
1208
+ // write would be overwritten by the next recompute, so it is refused aloud
1209
+ // instead (see RECORD/2026-10-01.computed.md)
1210
+ export type Computed<T> = ReactiveDeepData<{ readonly value: T }>
1211
+
1212
+ export const $computed = <T>(get: () => T): Computed<T> => {
1213
+ const box = $reactive({ value: undefined as T })
1214
+ box.$effect(() => {
1215
+ let value: T
1216
+ try {
1217
+ value = get()
1218
+ } catch (error) {
1219
+ // left to propagate, it would throw out of whatever write woke it -
1220
+ // `cart.items.push(x)` failing for code that has nothing to do with it
1221
+ console.error("jq79: error in $computed, keeping its last value", error)
1222
+ return
1223
+ }
1224
+ box.value = value
1225
+ })
1226
+ const refuse = (_target: object, key: string | symbol): boolean => {
1227
+ console.warn(`jq79: a $computed is read-only - the write to ${String(key)} was ignored`)
1228
+ return true
1229
+ }
1230
+ return new Proxy(box, { set: refuse, deleteProperty: refuse, defineProperty: refuse }) as Computed<T>
1231
+ }