jq79 0.5.1 → 0.5.3

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.3",
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
  }
@@ -1598,6 +1681,43 @@ const componentPartsFrom = (elements: Element[], hashSource: string): ComponentP
1598
1681
  const importResource = (url: string): Promise<any> =>
1599
1682
  /\.html?([?#]|$)/.test(url) ? fetchComponent(url) : import(url)
1600
1683
 
1684
+ // a relative specifier means "next to the file that wrote it", so it is
1685
+ // resolved against the component's own URL before importResource sees it -
1686
+ // neither of that function's two branches would otherwise land there. The
1687
+ // native import() inside it resolves against *this module* (dist/jq79.js), and
1688
+ // fetch() against the document; a component in a subdirectory gets a 404 from
1689
+ // the first and the page's directory from the second.
1690
+ //
1691
+ // Only `./` and `../` are resolved. A bare specifier ("lodash") belongs to the
1692
+ // import map or the native resolver, and resolving it would quietly turn it
1693
+ // into a path; anything already absolute means one thing under any base. That
1694
+ // keeps this the browser's rule rather than a third one of our own.
1695
+ //
1696
+ // The base is absolutized first, the way hotKey does and for the same reason:
1697
+ // the filename may itself be relative ("./card.html", from an import() in a
1698
+ // parent), and a relative URL cannot be a base.
1699
+ //
1700
+ // What comes back is a path whenever it lands on the page's own origin, and a
1701
+ // full URL only when it doesn't (a component served from a CDN resolves its
1702
+ // siblings on that CDN). Both are the same request, but the path keeps the
1703
+ // value the shape everything downstream already sees - what hotKey keys on,
1704
+ // what devtools shows as the script's name - so only the cross-origin case,
1705
+ // which has no path form, introduces a new one
1706
+ const resolveSpecifier = (spec: string, filename: string | undefined): string => {
1707
+ if (!/^\.\.?\//.test(spec)) return spec
1708
+ try {
1709
+ const page = new URL(document.baseURI)
1710
+ const url = new URL(spec, new URL(filename ?? "", page))
1711
+ // "null" is what an opaque origin (file:, blob:) reports, for both sides
1712
+ // and for anything else - it says the origins are unknown, not equal
1713
+ return url.origin !== "null" && url.origin === page.origin
1714
+ ? `${url.pathname}${url.search}${url.hash}`
1715
+ : url.href
1716
+ } catch {
1717
+ return spec
1718
+ }
1719
+ }
1720
+
1601
1721
  // ---------------------------------------------------------------------------
1602
1722
  // naming scripts for devtools
1603
1723
  //
@@ -1682,6 +1802,7 @@ const runSetupScript = (code: string, scope: Record<string, any>, effect: (run:
1682
1802
  `return (async () => { with ($scope) { ${code} } })()${sourceUrlComment(at.filename, at.index ?? 0)}`
1683
1803
  )(scriptScope, effect, importer, ...Object.values(helpers))
1684
1804
  result.catch(error => console.error("jq79: error in :setup script", error))
1805
+ trackScript(result)
1685
1806
  }
1686
1807
 
1687
1808
  // puts a component's declared props on the store, before any script runs and
@@ -1838,11 +1959,16 @@ const runFactoryScript = (code: string, scope: Record<string, any>, effect: (run
1838
1959
 
1839
1960
  const logError = (error: any) => console.error("jq79: error in factory script", error)
1840
1961
  let invoked = false
1841
- const invoke = () => {
1842
- if (invoked) return
1962
+ // what invoke() is still waiting on, memoized: it is called from both paths
1963
+ // below and does its work once, but the *second* caller is the one whose
1964
+ // promise is tracked - without this it would see `undefined` and count the
1965
+ // script as settled while an async factory's bindings are still on the way
1966
+ let merging: Promise<void> | undefined
1967
+ const invoke = (): Promise<void> | undefined => {
1968
+ if (invoked) return merging
1843
1969
  invoked = true
1844
1970
  const factory = $__exports.default
1845
- if (typeof factory !== "function") return
1971
+ if (typeof factory !== "function") return undefined
1846
1972
  const merge = (bindings: any) => {
1847
1973
  if (bindings && typeof bindings === "object") Object.assign(scope, bindings)
1848
1974
  }
@@ -1853,14 +1979,18 @@ const runFactoryScript = (code: string, scope: Record<string, any>, effect: (run
1853
1979
  // the props it declared (copying, as destructuring does - $props is the
1854
1980
  // live view for a primitive the parent reassigns later)
1855
1981
  const returned = factory(scope, { $data: scope, $props: scope, $effect: effect, ...instanceHelpers })
1856
- if (returned instanceof Promise) returned.then(merge).catch(logError)
1982
+ if (returned instanceof Promise) merging = returned.then(merge).catch(logError)
1857
1983
  else merge(returned)
1858
1984
  } catch (error) {
1859
1985
  logError(error)
1860
1986
  }
1987
+ return merging
1861
1988
  }
1862
1989
 
1863
- result.then(invoke, logError)
1990
+ // tracked through the merge, not just the module body: a factory's names
1991
+ // reach the store in `merge`, and a template expression that reads one before
1992
+ // then is not an authoring mistake (see reportExprError)
1993
+ trackScript(result.then(invoke, logError))
1864
1994
  if ($__exports.done) invoke() // fully-sync body: factory runs before first render
1865
1995
  }
1866
1996
 
@@ -2079,6 +2209,13 @@ export class Component79 {
2079
2209
  // while its markers sit where its DOM actually is
2080
2210
  hotReplace(src: string | ComponentParts): boolean {
2081
2211
  const parts = typeof src === "string" ? parseComponentString(src) : src
2212
+ // the source just changed, so what was already said about it no longer
2213
+ // applies: without this the author fixes the typo, saves, and the next typo
2214
+ // in the same expression is deduped away against the old one. `compiled`
2215
+ // needs no such reset - it is keyed by expression text, so edited source is
2216
+ // a different key
2217
+ reportedExprErrors.clear()
2218
+ pendingReports.clear()
2082
2219
  const marker = this.startMarker
2083
2220
  const rendered = !!(marker && this.content)
2084
2221
 
@@ -2253,10 +2390,15 @@ export class Component79 {
2253
2390
 
2254
2391
  // import() calls whose specifier was pre-resolved by a bundler (the
2255
2392
  // modules map) get the bundled module; everything else falls back to the
2256
- // runtime importResource (fetch for .html, native import otherwise)
2393
+ // runtime importResource (fetch for .html, native import otherwise),
2394
+ // relative to this component's file. The map is keyed by the literal
2395
+ // specifier the script wrote, so it is consulted *before* resolution -
2396
+ // what the bundler hoisted and what the source says are the same string
2257
2397
  const modules = this.modules
2258
2398
  const $import = (url: string): Promise<any> =>
2259
- modules && url in modules ? Promise.resolve(modules[url]) : importResource(url)
2399
+ modules && url in modules
2400
+ ? Promise.resolve(modules[url])
2401
+ : importResource(resolveSpecifier(url, this.filename))
2260
2402
 
2261
2403
  // the writeback half of :model, from the child's side: one argument is the
2262
2404
  // value for the default model (the bare :model), two are a name and a