jq79 0.4.0 → 0.4.1
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 +2 -2
- package/dev/dev.ts +12 -2
- package/dev/vite.ts +49 -9
- package/dist/cli.js +8 -2
- package/dist/dev.cjs +8 -2
- package/dist/dev.cjs.map +1 -1
- package/dist/dev.js +8 -2
- package/dist/dev.js.map +1 -1
- package/dist/jq79.cjs +10 -9
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.global.js +10 -9
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +10 -9
- package/dist/jq79.js.map +1 -1
- package/dist/reactive.d.ts +1 -0
- package/dist/vite.cjs +55 -7
- package/dist/vite.cjs.map +1 -1
- package/dist/vite.js +55 -7
- package/dist/vite.js.map +1 -1
- package/package.json +1 -1
- package/src/jq79.ts +137 -36
- package/src/reactive.ts +83 -7
- package/src/transform.ts +102 -1
package/src/reactive.ts
CHANGED
|
@@ -153,7 +153,17 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
153
153
|
const cached = proxies.get(raw)
|
|
154
154
|
if (cached) return cached
|
|
155
155
|
|
|
156
|
+
// keys that were deleted off this object. `with ($scope)` resolves a name
|
|
157
|
+
// through [[HasProperty]], so without a claim here a deleted key would fall
|
|
158
|
+
// through to globalThis and the *whole* expression would die of
|
|
159
|
+
// ReferenceError - `user ? user.name : "none"` must take its else branch
|
|
160
|
+
// instead. The cost: `"user" in store` stays true after a delete
|
|
161
|
+
let tombstones: Set<string> | null = null
|
|
162
|
+
|
|
156
163
|
const proxy: Record<string, any> = new Proxy(raw, {
|
|
164
|
+
has(target, key) {
|
|
165
|
+
return Reflect.has(target, key) || (typeof key === "string" && tombstones?.has(key) === true)
|
|
166
|
+
},
|
|
157
167
|
get(target, key, receiver) {
|
|
158
168
|
if (key === RAW) return target
|
|
159
169
|
if (key === STORE) return path === ""
|
|
@@ -193,12 +203,40 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
193
203
|
// whole store assigned in is the exception: it stays as it is
|
|
194
204
|
const stored = isStore(value) ? value : toRaw(value)
|
|
195
205
|
const isNewKey = !Object.prototype.hasOwnProperty.call(target, key)
|
|
206
|
+
// a primitive write that changes nothing notifies nobody: it's what
|
|
207
|
+
// lets an effect write the value it just read (a normalizing
|
|
208
|
+
// assignment, a prop sync) and settle instead of waking itself
|
|
209
|
+
// forever. Only primitives and functions: re-writing the SAME object
|
|
210
|
+
// reference stays loud, because that is the cross-store "deep touch"
|
|
211
|
+
// channel - a parent's prop sync forwards `user.name = x` to the
|
|
212
|
+
// child's store by re-assigning the same `user`, and the child's
|
|
213
|
+
// listeners live on the child's store, not the parent's. A new key
|
|
214
|
+
// always announces itself - the sweep is its whole point
|
|
215
|
+
if (!isNewKey && Object.is(target[key], stored) && (stored === null || typeof stored !== "object")) return true
|
|
196
216
|
target[key] = stored
|
|
217
|
+
tombstones?.delete(key) // the key exists again: no claim needed
|
|
197
218
|
if (isStore(stored)) bridge(stored, dotKey)
|
|
198
219
|
else unbridge(dotKey)
|
|
199
220
|
const notified = isStore(stored) || !isWrappable(stored) ? stored : wrap(stored, dotKey)
|
|
200
221
|
notify(dotKey, notified, isNewKey)
|
|
201
222
|
return true
|
|
223
|
+
},
|
|
224
|
+
// `delete data.user` is a plain-object mutation like any other, so it
|
|
225
|
+
// notifies like one - with `undefined`, which is what a read returns
|
|
226
|
+
// afterwards. Array methods that shrink (pop, splice) delete their dead
|
|
227
|
+
// slots through this trap too. No new-key sweep: whoever depended on the
|
|
228
|
+
// key tracked it while it existed, so dep matching wakes exactly them
|
|
229
|
+
deleteProperty(target, key) {
|
|
230
|
+
if (typeof key !== "string") return Reflect.deleteProperty(target, key)
|
|
231
|
+
const had = Object.prototype.hasOwnProperty.call(target, key)
|
|
232
|
+
const deleted = Reflect.deleteProperty(target, key)
|
|
233
|
+
if (deleted && had) {
|
|
234
|
+
const dotKey = path ? `${path}.${key}` : key
|
|
235
|
+
;(tombstones ??= new Set()).add(key)
|
|
236
|
+
unbridge(dotKey) // a nested store it held: stop listening to it
|
|
237
|
+
notify(dotKey, undefined)
|
|
238
|
+
}
|
|
239
|
+
return deleted
|
|
202
240
|
}
|
|
203
241
|
})
|
|
204
242
|
|
|
@@ -232,16 +270,41 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
|
|
|
232
270
|
}
|
|
233
271
|
|
|
234
272
|
const $effect = (run: () => void): Unsubscribe => {
|
|
273
|
+
// a notify landing while this effect runs (an item's render writing to
|
|
274
|
+
// the store, waking the very effect that is rendering it) must not
|
|
275
|
+
// re-enter mid-run - the half-done run would race its own repeat over
|
|
276
|
+
// shared state, which is how :each once tripled its rows. It marks the
|
|
277
|
+
// run dirty instead, and repeats *after* it finishes, against settled
|
|
278
|
+
// state, until clean. Still fully synchronous: everything happens before
|
|
279
|
+
// the triggering assignment returns
|
|
280
|
+
let running = false
|
|
281
|
+
let dirty = false
|
|
235
282
|
const effect: Effect = {
|
|
236
283
|
deps: new Set(),
|
|
237
284
|
run: () => {
|
|
238
|
-
|
|
239
|
-
|
|
285
|
+
if (running) {
|
|
286
|
+
dirty = true
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
running = true
|
|
240
290
|
try {
|
|
241
|
-
|
|
291
|
+
let cycles = 0
|
|
292
|
+
do {
|
|
293
|
+
dirty = false
|
|
294
|
+
const deps = new Set<string>()
|
|
295
|
+
trackerStack.push(deps)
|
|
296
|
+
try {
|
|
297
|
+
run()
|
|
298
|
+
} finally {
|
|
299
|
+
trackerStack.pop()
|
|
300
|
+
effect.deps = deps
|
|
301
|
+
}
|
|
302
|
+
} while (dirty && ++cycles < 100)
|
|
303
|
+
// an effect that keeps writing its own dependencies used to die by
|
|
304
|
+
// stack overflow; now it is cut off and named
|
|
305
|
+
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")
|
|
242
306
|
} finally {
|
|
243
|
-
|
|
244
|
-
effect.deps = deps
|
|
307
|
+
running = false
|
|
245
308
|
}
|
|
246
309
|
},
|
|
247
310
|
}
|
|
@@ -272,14 +335,27 @@ export type EffectScope = {
|
|
|
272
335
|
// registers an arbitrary cleanup (e.g. destroying a nested component) to
|
|
273
336
|
// run when this subtree is torn down
|
|
274
337
|
onDispose: (fn: Unsubscribe) => void
|
|
338
|
+
// re-runs every effect registered on this scope, nested scopes excluded:
|
|
339
|
+
// how :each tells a reused, repositioned entry's dep-less bindings (the
|
|
340
|
+
// `{{ $index }}`-only case) about their move. Deps stay as they were -
|
|
341
|
+
// callers run it untracked
|
|
342
|
+
refresh: () => void
|
|
275
343
|
dispose: () => void
|
|
276
344
|
}
|
|
277
345
|
|
|
278
346
|
export const createEffectScope = (scope: Record<string, any>): EffectScope => {
|
|
279
347
|
const disposers: Unsubscribe[] = []
|
|
348
|
+
const runs: (() => void)[] = []
|
|
280
349
|
return {
|
|
281
|
-
effect: run => {
|
|
350
|
+
effect: run => {
|
|
351
|
+
disposers.push(scope.$effect(run))
|
|
352
|
+
runs.push(run)
|
|
353
|
+
},
|
|
282
354
|
onDispose: fn => { disposers.push(fn) },
|
|
283
|
-
|
|
355
|
+
refresh: () => { runs.forEach(run => run()) },
|
|
356
|
+
dispose: () => {
|
|
357
|
+
disposers.splice(0).forEach(dispose => dispose())
|
|
358
|
+
runs.length = 0
|
|
359
|
+
},
|
|
284
360
|
}
|
|
285
361
|
}
|
package/src/transform.ts
CHANGED
|
@@ -60,6 +60,78 @@ const skipToToken = (src: string, start: number): number => {
|
|
|
60
60
|
return i
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// regex literals. The scanners walk the source counting bracket depth, and a
|
|
65
|
+
// regex walked as if it were code poisons that count: `split(/\//)` puts two
|
|
66
|
+
// slashes side by side (a line comment, as far as a scanner knows) and the
|
|
67
|
+
// `)` after them is skipped uncounted; `/[(]/` inflates the depth for good.
|
|
68
|
+
// So a `/` that opens a regex is consumed whole - and whether it opens one is
|
|
69
|
+
// the classic lexer call, made the way every tokenizer makes it: by what came
|
|
70
|
+
// before. Division needs a completed expression on its left; everywhere else
|
|
71
|
+
// a `/` can only be a regex.
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
// reserved words a regex can follow. Reserved only - `of` is not (`const of
|
|
75
|
+
// = 4; of / 2` is legal division), so `for (x of /re/)` stays unrescued
|
|
76
|
+
// rather than risking real code
|
|
77
|
+
const REGEX_AFTER_WORD = new Set([
|
|
78
|
+
"return", "typeof", "case", "in", "instanceof", "new", "delete", "void", "do", "else", "yield", "await",
|
|
79
|
+
])
|
|
80
|
+
|
|
81
|
+
// whether a `/` at `at` opens a regex literal rather than a division: looks
|
|
82
|
+
// backward past whitespace and block comments for the last meaningful thing.
|
|
83
|
+
// A completed expression - identifier, number, closing quote or bracket,
|
|
84
|
+
// postfix ++/-- - takes division; a reserved word or any other punctuator
|
|
85
|
+
// admits a regex. Only consulted for the rare `/` that is neither `//` nor
|
|
86
|
+
// `/*`, so the scanners pay nothing on the common path
|
|
87
|
+
const regexAllowed = (src: string, at: number): boolean => {
|
|
88
|
+
let i = at - 1
|
|
89
|
+
while (i >= 0) {
|
|
90
|
+
const ch = src[i]
|
|
91
|
+
if (/\s/.test(ch)) { i--; continue }
|
|
92
|
+
if (ch === "/" && src[i - 1] === "*") {
|
|
93
|
+
const open = src.lastIndexOf("/*", i - 2)
|
|
94
|
+
if (open === -1) return true // an unopened comment tail: malformed input
|
|
95
|
+
i = open - 1
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
break
|
|
99
|
+
}
|
|
100
|
+
if (i < 0) return true // the start of the source starts an expression
|
|
101
|
+
const ch = src[i]
|
|
102
|
+
if (/[\w$]/.test(ch)) {
|
|
103
|
+
let start = i
|
|
104
|
+
while (start > 0 && /[\w$]/.test(src[start - 1])) start--
|
|
105
|
+
return REGEX_AFTER_WORD.has(src.slice(start, i + 1))
|
|
106
|
+
}
|
|
107
|
+
if ((ch === "+" || ch === "-") && src[i - 1] === ch) return false // postfix ++/--
|
|
108
|
+
return !")]}\"'`.".includes(ch)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// consumes a regex literal (with its flags): backslash escapes, and character
|
|
112
|
+
// classes, where an unescaped `/` doesn't close the literal (`/[/]/` is one
|
|
113
|
+
// regex). A literal can't contain an unescaped newline, so hitting one means
|
|
114
|
+
// the classification was wrong or the input malformed - stop there, bounding
|
|
115
|
+
// any damage to a single line
|
|
116
|
+
const skipRegex = (src: string, start: number): number => {
|
|
117
|
+
let i = start + 1
|
|
118
|
+
let inClass = false
|
|
119
|
+
while (i < src.length) {
|
|
120
|
+
const ch = src[i]
|
|
121
|
+
if (ch === "\\") { i += 2; continue }
|
|
122
|
+
if (ch === "\n") return i
|
|
123
|
+
if (ch === "[") inClass = true
|
|
124
|
+
else if (ch === "]") inClass = false
|
|
125
|
+
else if (ch === "/" && !inClass) {
|
|
126
|
+
i++
|
|
127
|
+
while (i < src.length && /[a-z]/i.test(src[i])) i++ // flags
|
|
128
|
+
return i
|
|
129
|
+
}
|
|
130
|
+
i++
|
|
131
|
+
}
|
|
132
|
+
return src.length
|
|
133
|
+
}
|
|
134
|
+
|
|
63
135
|
// tokens that can't *start* a statement, so a line beginning with one is
|
|
64
136
|
// continuing the previous expression rather than opening a new statement -
|
|
65
137
|
// the same call JS's automatic semicolon insertion makes. Unary-only forms
|
|
@@ -83,6 +155,7 @@ const findStatementEnd = (src: string, start: number): number => {
|
|
|
83
155
|
if (ch === "'" || ch === '"' || ch === "`") { i = skipString(src, i); continue }
|
|
84
156
|
if (ch === "/" && src[i + 1] === "/") { i = skipLineComment(src, i); continue }
|
|
85
157
|
if (ch === "/" && src[i + 1] === "*") { i = skipBlockComment(src, i); continue }
|
|
158
|
+
if (ch === "/" && regexAllowed(src, i)) { i = skipRegex(src, i); continue }
|
|
86
159
|
if ("([{".includes(ch)) depth++
|
|
87
160
|
else if (")]}".includes(ch)) depth--
|
|
88
161
|
else if (depth <= 0 && ch === ";") return i
|
|
@@ -121,6 +194,13 @@ export const transformSetupScript = (src: string): SetupTransform => {
|
|
|
121
194
|
i = end
|
|
122
195
|
continue
|
|
123
196
|
}
|
|
197
|
+
if (ch === "/" && regexAllowed(src, i)) {
|
|
198
|
+
const end = skipRegex(src, i)
|
|
199
|
+
out += src.slice(i, end)
|
|
200
|
+
i = end
|
|
201
|
+
atStatementStart = false
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
124
204
|
|
|
125
205
|
// `import(...)` is a keyword form, so it can't be intercepted through the
|
|
126
206
|
// scope - rewrite the identifier to the injected $__import (which loads
|
|
@@ -155,7 +235,12 @@ export const transformSetupScript = (src: string): SetupTransform => {
|
|
|
155
235
|
if (assign) vars.push(assign[1])
|
|
156
236
|
const start = i + label[0].length
|
|
157
237
|
const end = findStatementEnd(src, start)
|
|
158
|
-
|
|
238
|
+
// the body is re-scanned rather than sliced raw, so an `import()`
|
|
239
|
+
// inside it gets the $__import rewrite like anywhere else. Safe to
|
|
240
|
+
// recurse: strings/comments/regexes copy through unchanged, and a
|
|
241
|
+
// depth-0 declaration inside a labeled statement is a SyntaxError
|
|
242
|
+
// in JS anyway, so nothing else can rewrite
|
|
243
|
+
out += `$__effect(() => { ${transformSetupScript(src.slice(start, end)).code} });`
|
|
159
244
|
i = end
|
|
160
245
|
continue
|
|
161
246
|
}
|
|
@@ -266,6 +351,7 @@ const indexOfTopLevel = (src: string, ch: string): number => {
|
|
|
266
351
|
if (c === "'" || c === '"' || c === "`") { i = skipString(src, i); continue }
|
|
267
352
|
if (c === "/" && src[i + 1] === "/") { i = skipLineComment(src, i); continue }
|
|
268
353
|
if (c === "/" && src[i + 1] === "*") { i = skipBlockComment(src, i); continue }
|
|
354
|
+
if (c === "/" && c !== ch && regexAllowed(src, i)) { i = skipRegex(src, i); continue }
|
|
269
355
|
if ("([{".includes(c)) depth++
|
|
270
356
|
else if (")]}".includes(c)) depth--
|
|
271
357
|
else if (depth === 0 && c === ch) return i
|
|
@@ -284,6 +370,7 @@ const splitTopLevel = (src: string): string[] => {
|
|
|
284
370
|
if (ch === "'" || ch === '"' || ch === "`") { i = skipString(src, i); continue }
|
|
285
371
|
if (ch === "/" && src[i + 1] === "/") { i = skipLineComment(src, i); continue }
|
|
286
372
|
if (ch === "/" && src[i + 1] === "*") { i = skipBlockComment(src, i); continue }
|
|
373
|
+
if (ch === "/" && regexAllowed(src, i)) { i = skipRegex(src, i); continue }
|
|
287
374
|
if (ch !== undefined && "([{".includes(ch)) depth++
|
|
288
375
|
else if (ch !== undefined && ")]}".includes(ch)) depth--
|
|
289
376
|
else if (i === src.length || (ch === "," && depth === 0)) {
|
|
@@ -325,6 +412,9 @@ export const parsePropsPattern = (pattern: string | undefined): PropDecl[] | nul
|
|
|
325
412
|
while (close < src.length) {
|
|
326
413
|
const ch = src[close]
|
|
327
414
|
if (ch === "'" || ch === '"' || ch === "`") { close = skipString(src, close); continue }
|
|
415
|
+
if (ch === "/" && src[close + 1] === "/") { close = skipLineComment(src, close); continue }
|
|
416
|
+
if (ch === "/" && src[close + 1] === "*") { close = skipBlockComment(src, close); continue }
|
|
417
|
+
if (ch === "/" && regexAllowed(src, close)) { close = skipRegex(src, close); continue }
|
|
328
418
|
if ("([{".includes(ch)) depth++
|
|
329
419
|
else if (")]}".includes(ch) && --depth === 0) break
|
|
330
420
|
close++
|
|
@@ -357,6 +447,7 @@ const findExportDefault = (src: string): number => {
|
|
|
357
447
|
if (ch === "'" || ch === '"' || ch === "`") { i = skipString(src, i); atStatementStart = false; continue }
|
|
358
448
|
if (ch === "/" && src[i + 1] === "/") { i = skipLineComment(src, i); continue }
|
|
359
449
|
if (ch === "/" && src[i + 1] === "*") { i = skipBlockComment(src, i); continue }
|
|
450
|
+
if (ch === "/" && regexAllowed(src, i)) { i = skipRegex(src, i); atStatementStart = false; continue }
|
|
360
451
|
|
|
361
452
|
if (ch === "e" && depth === 0 && atStatementStart && (i === 0 || !/[\w$.]/.test(src[i - 1]))) {
|
|
362
453
|
EXPORT_DEFAULT_RE.lastIndex = i
|
|
@@ -396,6 +487,9 @@ const firstParameterSource = (src: string): string | null => {
|
|
|
396
487
|
while (end < src.length) {
|
|
397
488
|
const ch = src[end]
|
|
398
489
|
if (ch === "'" || ch === '"' || ch === "`") { end = skipString(src, end); continue }
|
|
490
|
+
if (ch === "/" && src[end + 1] === "/") { end = skipLineComment(src, end); continue }
|
|
491
|
+
if (ch === "/" && src[end + 1] === "*") { end = skipBlockComment(src, end); continue }
|
|
492
|
+
if (ch === "/" && regexAllowed(src, end)) { end = skipRegex(src, end); continue }
|
|
399
493
|
if ("([{".includes(ch)) depth++
|
|
400
494
|
else if (")]}".includes(ch) && --depth === 0) break
|
|
401
495
|
end++
|
|
@@ -450,6 +544,13 @@ export const transformFactoryScript = (src: string): string | null => {
|
|
|
450
544
|
i = end
|
|
451
545
|
continue
|
|
452
546
|
}
|
|
547
|
+
if (ch === "/" && regexAllowed(src, i)) {
|
|
548
|
+
const end = skipRegex(src, i)
|
|
549
|
+
out += src.slice(i, end)
|
|
550
|
+
i = end
|
|
551
|
+
atStatementStart = false
|
|
552
|
+
continue
|
|
553
|
+
}
|
|
453
554
|
|
|
454
555
|
if (ch === "i" && atWordBoundary) {
|
|
455
556
|
// dynamic import() -> $__import, same rewrite as setup scripts
|