jq79 0.5.4 → 0.5.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jq79",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "Mini reactive component library: single-file components, Svelte-style setup scripts, fine-grained proxy reactivity. Single-file build, zero dependencies.",
5
5
  "keywords": [
6
6
  "reactive",
package/src/jq79.ts CHANGED
@@ -127,6 +127,36 @@ const reportedExprErrors = new Set<string>()
127
127
  let pendingScripts = 0
128
128
  let flushScheduled = false
129
129
 
130
+ // everything else an expression can throw - overwhelmingly a member access on
131
+ // an undefined value, `{{ game.is.loaded }}` over a game with no `is`. There is
132
+ // no pure question to ask about it the way there is for a missing name: `in`
133
+ // answers "declared anywhere?" without evaluating, and nothing answers "would
134
+ // this still throw?" without running the expression again, which is exactly
135
+ // what mustn't happen (`@click="count++ + missing"` increments twice).
136
+ //
137
+ // What is pure is watching the evaluations that were going to happen anyway. An
138
+ // expression whose value is merely late is re-evaluated when the value arrives -
139
+ // the reads that did land registered as dependencies, so the write wakes the
140
+ // effect - and it succeeds then. So transient means "succeeds later", and the
141
+ // re-check is to clear the entry on the next successful evaluation and report
142
+ // only what is still queued once the page has gone quiet.
143
+ //
144
+ // Quiet is a timer rather than the microtask the other queue flushes on,
145
+ // because a value from fetch() arrives many tasks later and clearing on success
146
+ // exists to let it. A second covers that and still lands the warning inside the
147
+ // page load. It is a guess, and the cost is a slower fetch warning about correct
148
+ // code with nothing to take it back - accepted because the silence it replaces
149
+ // hid a render that was actively wrong
150
+ const FAILED_EXPR_DELAY = 1000
151
+
152
+ // keyed by expression text alone, not `message|expr`: the expression is what the
153
+ // author edits, and one broken path throwing two messages is still one bug. The
154
+ // entries hold no scope, only strings, so this queue cannot retain a live
155
+ // component the way pendingReports can
156
+ const failedExprs = new Map<string, string>()
157
+ const reportedFailedExprs = new Set<string>()
158
+ let failedFlushTimer: ReturnType<typeof setTimeout> | undefined
159
+
130
160
  const flushExprReports = () => {
131
161
  flushScheduled = false
132
162
  if (pendingScripts > 0) return // a script started meanwhile; its release re-schedules
@@ -149,6 +179,25 @@ const scheduleExprReportFlush = () => {
149
179
  queueMicrotask(flushExprReports)
150
180
  }
151
181
 
182
+ const flushFailedExprs = () => {
183
+ failedFlushTimer = undefined
184
+ if (pendingScripts > 0) return // a script started meanwhile; its release re-schedules
185
+ failedExprs.forEach((message, expr) => {
186
+ reportedFailedExprs.add(expr)
187
+ console.warn(
188
+ `jq79: ${message} - evaluating "${expr}". It rendered as nothing and was ` +
189
+ `still failing a moment later, so the value is probably never coming: check ` +
190
+ `the path. If it does arrive asynchronously, guard it - "a?.b", or :if on the element.`
191
+ )
192
+ })
193
+ failedExprs.clear()
194
+ }
195
+
196
+ const scheduleFailedExprFlush = () => {
197
+ if (failedFlushTimer !== undefined || pendingScripts > 0 || !failedExprs.size) return
198
+ failedFlushTimer = setTimeout(flushFailedExprs, FAILED_EXPR_DELAY)
199
+ }
200
+
152
201
  // scripts run before the template renders, so the counter is already up when
153
202
  // the first evaluation fails. Both script modes settle through a promise;
154
203
  // the factory's has to cover the merge, not just the module body
@@ -157,12 +206,20 @@ const trackScript = (settled: Promise<unknown>) => {
157
206
  const release = () => {
158
207
  pendingScripts--
159
208
  scheduleExprReportFlush()
209
+ scheduleFailedExprFlush()
160
210
  }
161
211
  settled.then(release, release)
162
212
  }
163
213
 
214
+ const reportFailedExpr = (expr: string, error: unknown) => {
215
+ if (reportedFailedExprs.has(expr) || failedExprs.has(expr)) return
216
+ if (failedExprs.size >= MAX_PENDING_REPORTS) return
217
+ failedExprs.set(expr, (error as Error)?.message || String(error))
218
+ scheduleFailedExprFlush()
219
+ }
220
+
164
221
  const reportExprError = (expr: string, scope: Record<string, any>, error: unknown) => {
165
- if (!(error instanceof ReferenceError)) return
222
+ if (!(error instanceof ReferenceError)) return reportFailedExpr(expr, error)
166
223
  const match = MISSING_NAME_RE.exec(error.message)
167
224
  const name = match?.[1] ?? match?.[2]
168
225
  if (!name) return // an engine whose wording we don't know: stay quiet, as before
@@ -176,17 +233,52 @@ const reportExprError = (expr: string, scope: Record<string, any>, error: unknow
176
233
  scheduleExprReportFlush()
177
234
  }
178
235
 
179
- const evalExpr = (expr: string, scope: Record<string, any>, extras?: Record<string, any>): any => {
236
+ const runExpr = (expr: string, scope: Record<string, any>, extras?: Record<string, any>): any => {
180
237
  const fn = compileExpr(expr, extras ? Object.keys(extras) : [])
181
- if (!fn) return undefined
238
+ if (!fn) return undefined // a syntax error: compileExpr cached the failure, and it stays undefined
239
+ return fn(scope, ...(extras ? Object.values(extras) : []))
240
+ }
241
+
242
+ const evalExpr = (expr: string, scope: Record<string, any>, extras?: Record<string, any>): any => {
182
243
  try {
183
- return fn(scope, ...(extras ? Object.values(extras) : []))
244
+ const value = runExpr(expr, scope, extras)
245
+ // the re-check for the failed-expression queue: this run is the proof that
246
+ // the earlier throw was a value arriving late. Guarded on size so the
247
+ // normal page - where nothing has ever thrown - pays one comparison
248
+ if (failedExprs.size) failedExprs.delete(expr)
249
+ return value
184
250
  } catch (error) {
185
251
  reportExprError(expr, scope, error)
186
252
  return undefined
187
253
  }
188
254
  }
189
255
 
256
+ // the same evaluation for an @event attribute, which swallows far less. Every
257
+ // word of the reason evalExpr catches is about rendering: an expression is
258
+ // re-evaluated per effect run, per interpolation, per :each item, so a value
259
+ // that is briefly undefined mid-render has to render empty rather than tear the
260
+ // render down. A handler runs in an event listener - not in an effect, once,
261
+ // when the user clicked - so there is no transient failure to absorb, only a
262
+ // bug to report, and an exception belongs in the console with its stack. That
263
+ // is what `@click="save"` has always done (the call happens outside the try,
264
+ // on the returned function); this is what makes `@click="save()"` and
265
+ // `@click="count++"` behave the same rather than the other way round.
266
+ //
267
+ // ReferenceError is the exception, for the reason it always is: it is not yet
268
+ // decidable at throw time. A factory assigns its names to the store when it
269
+ // returns, so a click while one is still in flight throws for a name that is
270
+ // about to exist - reportExprError re-checks after the scripts settle and stays
271
+ // quiet if it arrived, which throwing here would replace with a false alarm
272
+ const evalHandler = (expr: string, scope: Record<string, any>, extras: Record<string, any>): any => {
273
+ try {
274
+ return runExpr(expr, scope, extras)
275
+ } catch (error) {
276
+ if (!(error instanceof ReferenceError)) throw error
277
+ reportExprError(expr, scope, error)
278
+ return undefined
279
+ }
280
+ }
281
+
190
282
  // [\s\S] rather than `.` so an expression can span lines, like the ones in
191
283
  // directive attributes (which reach evalExpr wrapped in parens either way)
192
284
  const interpolate = (template: string, scope: Record<string, any>): string =>
@@ -225,7 +317,7 @@ const bindEvent = (el: Element, attr: string, expr: string, scope: Record<string
225
317
  if (mods.has("prevent")) event.preventDefault()
226
318
  if (mods.has("stop")) event.stopPropagation()
227
319
 
228
- const handler = evalExpr(expr, scope, { $event: event })
320
+ const handler = evalHandler(expr, scope, { $event: event })
229
321
  if (typeof handler === "function") handler.call(el, event)
230
322
  }, { once: mods.has("once"), capture: mods.has("capture") })
231
323
  }
@@ -256,7 +348,7 @@ const wireTagEvent = (instance: Component79, attr: string, expr: string, scope:
256
348
  // and the creation effect's definition guard no-ops a spurious wake - but
257
349
  // "what a handler reads is nobody's dependency" shouldn't hinge on either
258
350
  untracked(() => {
259
- const handler = evalExpr(expr, scope, { $event: event })
351
+ const handler = evalHandler(expr, scope, { $event: event })
260
352
  if (typeof handler === "function") handler(event)
261
353
  })
262
354
  }
@@ -1839,7 +1931,35 @@ const declareProps = (store: Record<string, any>, props: PropDecl[] | null) => {
1839
1931
  const setupSignature = (script: TagBlock): PropDecl[] | null => {
1840
1932
  const pattern = script.attrs[":setup"]
1841
1933
  if (pattern === undefined) return null
1842
- return pattern.trim() === "" ? [] : parsePropsPattern(pattern)
1934
+ if (pattern.trim() === "") return []
1935
+ const props = parsePropsPattern(pattern)
1936
+ if (!props) warnUnreadableSignature(script, pattern)
1937
+ return props
1938
+ }
1939
+
1940
+ // script blocks already warned about, keyed by the block itself - parsed once
1941
+ // and shared by every instance of a definition, the same reason warnUndeclared
1942
+ // keys on the template node. It matters for a smaller reason here too:
1943
+ // setupSignature is called three times per render (both declared-name passes
1944
+ // and the script loop's declareProps), so even one mount would say it thrice
1945
+ const signatureWarned = new WeakSet<TagBlock>()
1946
+
1947
+ // a value that isn't a props pattern reads as "declared no signature", which is
1948
+ // the most permissive mode there is - so a typo doesn't fail, it quietly opts
1949
+ // the component out of the contract it was trying to write. That is now the
1950
+ // only accidental route left to permissive: a bare :setup is closed and `_` is
1951
+ // the opt-out you have to ask for, so the mode nothing lands in by accident is
1952
+ // still reachable by getting it wrong. Both of parsePropsPattern's nulls count
1953
+ // - not-an-object (",{ a }", "props") and unbalanced ("{ a, b") - and only `_`
1954
+ // is exempt, because intent is the sole thing separating it from the typos
1955
+ const warnUnreadableSignature = (script: TagBlock, pattern: string) => {
1956
+ if (pattern.trim() === "_" || signatureWarned.has(script)) return
1957
+ signatureWarned.add(script)
1958
+ console.warn(
1959
+ `jq79: :setup="${pattern}" is not a props pattern, so this component declares no ` +
1960
+ `signature and takes whatever a parent passes - write the props it takes ` +
1961
+ `("{ a, b }"), a bare :setup for none, or "_" to stay open on purpose`
1962
+ )
1843
1963
  }
1844
1964
 
1845
1965
  // every prop name a component's scripts declare, across both script modes.
@@ -2218,6 +2338,8 @@ export class Component79 {
2218
2338
  // a different key
2219
2339
  reportedExprErrors.clear()
2220
2340
  pendingReports.clear()
2341
+ reportedFailedExprs.clear()
2342
+ failedExprs.clear()
2221
2343
  const marker = this.startMarker
2222
2344
  const rendered = !!(marker && this.content)
2223
2345