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.
- package/README.md +3 -2
- package/dev/vite.ts +152 -10
- package/dist/dom.d.ts +22 -4
- package/dist/html.d.ts +9 -0
- package/dist/jq79-sw.js +32 -0
- package/dist/jq79.cjs +18 -14
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.d.ts +27 -13
- package/dist/jq79.global.js +18 -14
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +18 -14
- package/dist/jq79.js.map +1 -1
- package/dist/precompile.cjs +24 -0
- package/dist/precompile.cjs.map +1 -0
- package/dist/precompile.d.ts +3 -0
- package/dist/precompile.js +24 -0
- package/dist/precompile.js.map +1 -0
- package/dist/reactive.d.ts +10 -0
- package/dist/source.d.ts +65 -0
- package/dist/vite.cjs +85 -4
- package/dist/vite.cjs.map +1 -1
- package/dist/vite.d.ts +3 -0
- package/dist/vite.js +86 -5
- package/dist/vite.js.map +1 -1
- package/package.json +12 -2
- package/src/dom.ts +28 -4
- package/src/html.ts +289 -0
- package/src/jq79.ts +705 -363
- package/src/precompile.ts +238 -0
- package/src/reactive.ts +178 -9
- package/src/source.ts +389 -0
- package/src/sw.ts +87 -0
- package/src/transform.ts +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
+
}
|