jq79 0.5.1 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jq79",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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
@@ -94,12 +94,95 @@ const compileExpr = (expr: string, params: string[]): Function | null => {
94
94
  return fn
95
95
  }
96
96
 
97
+ // a template expression is re-evaluated constantly - once per effect run, once
98
+ // per interpolation, once per :each item - so a value that is briefly undefined
99
+ // mid-render has to fail quietly, and the catch below stays. A ReferenceError
100
+ // is the one failure worth a word: `with` resolves a name against the store and
101
+ // then globalThis, so a name that resolves nowhere is declared nowhere - a
102
+ // typo, a dropped prop, or the trap this was written for, a top-level
103
+ // `function` declaration, which transformSetupScript leaves as an ordinary
104
+ // lexical binding instead of a store property
105
+ //
106
+ // It is reported late rather than where it throws, because "declared nowhere"
107
+ // is not yet decidable at that moment: a factory script assigns its bindings to
108
+ // the store when it returns, so an async factory renders its whole template
109
+ // before any of its names exist. Reporting waits until no script is still
110
+ // running (pendingScripts), and then asks whether the name resolves *now*.
111
+ //
112
+ // The re-check is `name in scope` rather than a re-evaluation, because
113
+ // re-evaluating is not pure: `@click="count++ + missing"` increments before it
114
+ // throws, and running it again to see if it still throws would increment twice
115
+ // and notify. `in` walks the same scope chain (:each scopes are
116
+ // Object.create(scope), :with is a proxy over it) and evaluates nothing
117
+ const MISSING_NAME_RE = /^(?:([\w$]+) is not defined|Can't find variable: ([\w$]+))/
118
+
119
+ // the queue holds live scopes, so it is capped: a script that never settles
120
+ // would otherwise let it grow for the life of the page
121
+ const MAX_PENDING_REPORTS = 100
122
+
123
+ type PendingReport = { name: string; expr: string; scope: Record<string, any> }
124
+
125
+ const pendingReports = new Map<string, PendingReport>()
126
+ const reportedExprErrors = new Set<string>()
127
+ let pendingScripts = 0
128
+ let flushScheduled = false
129
+
130
+ const flushExprReports = () => {
131
+ flushScheduled = false
132
+ if (pendingScripts > 0) return // a script started meanwhile; its release re-schedules
133
+ pendingReports.forEach(({ name, expr, scope }, key) => {
134
+ if (name in scope) return // it arrived late - a factory's bindings, a prop
135
+ reportedExprErrors.add(key)
136
+ console.warn(
137
+ `jq79: ${name} is not defined - evaluating "${expr}". Template expressions ` +
138
+ `resolve against the component store: a top-level let/var/const in a :setup ` +
139
+ `script, a declared prop, or a global. Note a "function name() {}" declaration ` +
140
+ `is not on the store - write "const name = () => {}".`
141
+ )
142
+ })
143
+ pendingReports.clear()
144
+ }
145
+
146
+ const scheduleExprReportFlush = () => {
147
+ if (flushScheduled || pendingScripts > 0 || !pendingReports.size) return
148
+ flushScheduled = true
149
+ queueMicrotask(flushExprReports)
150
+ }
151
+
152
+ // scripts run before the template renders, so the counter is already up when
153
+ // the first evaluation fails. Both script modes settle through a promise;
154
+ // the factory's has to cover the merge, not just the module body
155
+ const trackScript = (settled: Promise<unknown>) => {
156
+ pendingScripts++
157
+ const release = () => {
158
+ pendingScripts--
159
+ scheduleExprReportFlush()
160
+ }
161
+ settled.then(release, release)
162
+ }
163
+
164
+ const reportExprError = (expr: string, scope: Record<string, any>, error: unknown) => {
165
+ if (!(error instanceof ReferenceError)) return
166
+ const match = MISSING_NAME_RE.exec(error.message)
167
+ const name = match?.[1] ?? match?.[2]
168
+ if (!name) return // an engine whose wording we don't know: stay quiet, as before
169
+ // keyed on name and expression, not on the expression alone, so two missing
170
+ // names in one expression stay distinguishable - and so a :each of 1000 items
171
+ // enqueues one entry rather than 1000
172
+ const key = `${name}|${expr}`
173
+ if (reportedExprErrors.has(key) || pendingReports.has(key)) return
174
+ if (pendingReports.size >= MAX_PENDING_REPORTS) return
175
+ pendingReports.set(key, { name, expr, scope })
176
+ scheduleExprReportFlush()
177
+ }
178
+
97
179
  const evalExpr = (expr: string, scope: Record<string, any>, extras?: Record<string, any>): any => {
98
180
  const fn = compileExpr(expr, extras ? Object.keys(extras) : [])
99
181
  if (!fn) return undefined
100
182
  try {
101
183
  return fn(scope, ...(extras ? Object.values(extras) : []))
102
- } catch {
184
+ } catch (error) {
185
+ reportExprError(expr, scope, error)
103
186
  return undefined
104
187
  }
105
188
  }
@@ -1682,6 +1765,7 @@ const runSetupScript = (code: string, scope: Record<string, any>, effect: (run:
1682
1765
  `return (async () => { with ($scope) { ${code} } })()${sourceUrlComment(at.filename, at.index ?? 0)}`
1683
1766
  )(scriptScope, effect, importer, ...Object.values(helpers))
1684
1767
  result.catch(error => console.error("jq79: error in :setup script", error))
1768
+ trackScript(result)
1685
1769
  }
1686
1770
 
1687
1771
  // puts a component's declared props on the store, before any script runs and
@@ -1838,11 +1922,16 @@ const runFactoryScript = (code: string, scope: Record<string, any>, effect: (run
1838
1922
 
1839
1923
  const logError = (error: any) => console.error("jq79: error in factory script", error)
1840
1924
  let invoked = false
1841
- const invoke = () => {
1842
- if (invoked) return
1925
+ // what invoke() is still waiting on, memoized: it is called from both paths
1926
+ // below and does its work once, but the *second* caller is the one whose
1927
+ // promise is tracked - without this it would see `undefined` and count the
1928
+ // script as settled while an async factory's bindings are still on the way
1929
+ let merging: Promise<void> | undefined
1930
+ const invoke = (): Promise<void> | undefined => {
1931
+ if (invoked) return merging
1843
1932
  invoked = true
1844
1933
  const factory = $__exports.default
1845
- if (typeof factory !== "function") return
1934
+ if (typeof factory !== "function") return undefined
1846
1935
  const merge = (bindings: any) => {
1847
1936
  if (bindings && typeof bindings === "object") Object.assign(scope, bindings)
1848
1937
  }
@@ -1853,14 +1942,18 @@ const runFactoryScript = (code: string, scope: Record<string, any>, effect: (run
1853
1942
  // the props it declared (copying, as destructuring does - $props is the
1854
1943
  // live view for a primitive the parent reassigns later)
1855
1944
  const returned = factory(scope, { $data: scope, $props: scope, $effect: effect, ...instanceHelpers })
1856
- if (returned instanceof Promise) returned.then(merge).catch(logError)
1945
+ if (returned instanceof Promise) merging = returned.then(merge).catch(logError)
1857
1946
  else merge(returned)
1858
1947
  } catch (error) {
1859
1948
  logError(error)
1860
1949
  }
1950
+ return merging
1861
1951
  }
1862
1952
 
1863
- result.then(invoke, logError)
1953
+ // tracked through the merge, not just the module body: a factory's names
1954
+ // reach the store in `merge`, and a template expression that reads one before
1955
+ // then is not an authoring mistake (see reportExprError)
1956
+ trackScript(result.then(invoke, logError))
1864
1957
  if ($__exports.done) invoke() // fully-sync body: factory runs before first render
1865
1958
  }
1866
1959
 
@@ -2079,6 +2172,13 @@ export class Component79 {
2079
2172
  // while its markers sit where its DOM actually is
2080
2173
  hotReplace(src: string | ComponentParts): boolean {
2081
2174
  const parts = typeof src === "string" ? parseComponentString(src) : src
2175
+ // the source just changed, so what was already said about it no longer
2176
+ // applies: without this the author fixes the typo, saves, and the next typo
2177
+ // in the same expression is deduped away against the old one. `compiled`
2178
+ // needs no such reset - it is keyed by expression text, so edited source is
2179
+ // a different key
2180
+ reportedExprErrors.clear()
2181
+ pendingReports.clear()
2082
2182
  const marker = this.startMarker
2083
2183
  const rendered = !!(marker && this.content)
2084
2184