jq79 0.4.0 → 0.4.2

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
@@ -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
- const deps = new Set<string>()
239
- trackerStack.push(deps)
285
+ if (running) {
286
+ dirty = true
287
+ return
288
+ }
289
+ running = true
240
290
  try {
241
- run()
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
- trackerStack.pop()
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 => { disposers.push(scope.$effect(run)) },
350
+ effect: run => {
351
+ disposers.push(scope.$effect(run))
352
+ runs.push(run)
353
+ },
282
354
  onDispose: fn => { disposers.push(fn) },
283
- dispose: () => { disposers.splice(0).forEach(dispose => dispose()) },
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
- out += `$__effect(() => { ${src.slice(start, end)} });`
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