jq79 0.5.3 → 0.5.5

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.3",
3
+ "version": "0.5.5",
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
@@ -176,17 +176,47 @@ const reportExprError = (expr: string, scope: Record<string, any>, error: unknow
176
176
  scheduleExprReportFlush()
177
177
  }
178
178
 
179
- const evalExpr = (expr: string, scope: Record<string, any>, extras?: Record<string, any>): any => {
179
+ const runExpr = (expr: string, scope: Record<string, any>, extras?: Record<string, any>): any => {
180
180
  const fn = compileExpr(expr, extras ? Object.keys(extras) : [])
181
- if (!fn) return undefined
181
+ if (!fn) return undefined // a syntax error: compileExpr cached the failure, and it stays undefined
182
+ return fn(scope, ...(extras ? Object.values(extras) : []))
183
+ }
184
+
185
+ const evalExpr = (expr: string, scope: Record<string, any>, extras?: Record<string, any>): any => {
182
186
  try {
183
- return fn(scope, ...(extras ? Object.values(extras) : []))
187
+ return runExpr(expr, scope, extras)
184
188
  } catch (error) {
185
189
  reportExprError(expr, scope, error)
186
190
  return undefined
187
191
  }
188
192
  }
189
193
 
194
+ // the same evaluation for an @event attribute, which swallows far less. Every
195
+ // word of the reason evalExpr catches is about rendering: an expression is
196
+ // re-evaluated per effect run, per interpolation, per :each item, so a value
197
+ // that is briefly undefined mid-render has to render empty rather than tear the
198
+ // render down. A handler runs in an event listener - not in an effect, once,
199
+ // when the user clicked - so there is no transient failure to absorb, only a
200
+ // bug to report, and an exception belongs in the console with its stack. That
201
+ // is what `@click="save"` has always done (the call happens outside the try,
202
+ // on the returned function); this is what makes `@click="save()"` and
203
+ // `@click="count++"` behave the same rather than the other way round.
204
+ //
205
+ // ReferenceError is the exception, for the reason it always is: it is not yet
206
+ // decidable at throw time. A factory assigns its names to the store when it
207
+ // returns, so a click while one is still in flight throws for a name that is
208
+ // about to exist - reportExprError re-checks after the scripts settle and stays
209
+ // quiet if it arrived, which throwing here would replace with a false alarm
210
+ const evalHandler = (expr: string, scope: Record<string, any>, extras: Record<string, any>): any => {
211
+ try {
212
+ return runExpr(expr, scope, extras)
213
+ } catch (error) {
214
+ if (!(error instanceof ReferenceError)) throw error
215
+ reportExprError(expr, scope, error)
216
+ return undefined
217
+ }
218
+ }
219
+
190
220
  // [\s\S] rather than `.` so an expression can span lines, like the ones in
191
221
  // directive attributes (which reach evalExpr wrapped in parens either way)
192
222
  const interpolate = (template: string, scope: Record<string, any>): string =>
@@ -225,7 +255,7 @@ const bindEvent = (el: Element, attr: string, expr: string, scope: Record<string
225
255
  if (mods.has("prevent")) event.preventDefault()
226
256
  if (mods.has("stop")) event.stopPropagation()
227
257
 
228
- const handler = evalExpr(expr, scope, { $event: event })
258
+ const handler = evalHandler(expr, scope, { $event: event })
229
259
  if (typeof handler === "function") handler.call(el, event)
230
260
  }, { once: mods.has("once"), capture: mods.has("capture") })
231
261
  }
@@ -256,7 +286,7 @@ const wireTagEvent = (instance: Component79, attr: string, expr: string, scope:
256
286
  // and the creation effect's definition guard no-ops a spurious wake - but
257
287
  // "what a handler reads is nobody's dependency" shouldn't hinge on either
258
288
  untracked(() => {
259
- const handler = evalExpr(expr, scope, { $event: event })
289
+ const handler = evalHandler(expr, scope, { $event: event })
260
290
  if (typeof handler === "function") handler(event)
261
291
  })
262
292
  }
@@ -1688,31 +1718,33 @@ const importResource = (url: string): Promise<any> =>
1688
1718
  // fetch() against the document; a component in a subdirectory gets a 404 from
1689
1719
  // the first and the page's directory from the second.
1690
1720
  //
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.
1721
+ // What comes back is always a fully absolute URL, and that is the load-bearing
1722
+ // part rather than a detail of formatting. A *path* would be resolved by that
1723
+ // same native import() against the library module's ORIGIN - and the library
1724
+ // is the one file on the page most likely to come from somewhere else:
1725
+ //
1726
+ // page http://localhost:8024/craft/app.html
1727
+ // jq79 https://jgermade.github.io/jq79/jq79.js
1728
+ // "/craft/services/x.js" -> https://jgermade.github.io/craft/services/x.js
1729
+ //
1730
+ // which is a CORS error naming a host the app never mentioned. Only an
1731
+ // absolute URL means the same thing to both branches.
1732
+ //
1733
+ // For the same reason a *root-absolute* specifier is resolved too, not passed
1734
+ // through: `/x.js` means the page's root to whoever wrote it, and the page is
1735
+ // the only base under which the two branches agree. Bare specifiers ("lodash")
1736
+ // are the exception that stays untouched - they belong to the import map or
1737
+ // the bundler, and resolving one would quietly turn it into a path.
1695
1738
  //
1696
1739
  // The base is absolutized first, the way hotKey does and for the same reason:
1697
1740
  // 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
1741
+ // parent), and a relative URL cannot be a base
1742
+ const RESOLVABLE_SPECIFIER_RE = /^(?:\.\.?\/|\/|[a-z][a-z0-9+.-]*:)/i
1743
+
1706
1744
  const resolveSpecifier = (spec: string, filename: string | undefined): string => {
1707
- if (!/^\.\.?\//.test(spec)) return spec
1745
+ if (!RESOLVABLE_SPECIFIER_RE.test(spec)) return spec
1708
1746
  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
1747
+ return new URL(spec, new URL(filename ?? "", document.baseURI)).href
1716
1748
  } catch {
1717
1749
  return spec
1718
1750
  }
@@ -1837,7 +1869,35 @@ const declareProps = (store: Record<string, any>, props: PropDecl[] | null) => {
1837
1869
  const setupSignature = (script: TagBlock): PropDecl[] | null => {
1838
1870
  const pattern = script.attrs[":setup"]
1839
1871
  if (pattern === undefined) return null
1840
- return pattern.trim() === "" ? [] : parsePropsPattern(pattern)
1872
+ if (pattern.trim() === "") return []
1873
+ const props = parsePropsPattern(pattern)
1874
+ if (!props) warnUnreadableSignature(script, pattern)
1875
+ return props
1876
+ }
1877
+
1878
+ // script blocks already warned about, keyed by the block itself - parsed once
1879
+ // and shared by every instance of a definition, the same reason warnUndeclared
1880
+ // keys on the template node. It matters for a smaller reason here too:
1881
+ // setupSignature is called three times per render (both declared-name passes
1882
+ // and the script loop's declareProps), so even one mount would say it thrice
1883
+ const signatureWarned = new WeakSet<TagBlock>()
1884
+
1885
+ // a value that isn't a props pattern reads as "declared no signature", which is
1886
+ // the most permissive mode there is - so a typo doesn't fail, it quietly opts
1887
+ // the component out of the contract it was trying to write. That is now the
1888
+ // only accidental route left to permissive: a bare :setup is closed and `_` is
1889
+ // the opt-out you have to ask for, so the mode nothing lands in by accident is
1890
+ // still reachable by getting it wrong. Both of parsePropsPattern's nulls count
1891
+ // - not-an-object (",{ a }", "props") and unbalanced ("{ a, b") - and only `_`
1892
+ // is exempt, because intent is the sole thing separating it from the typos
1893
+ const warnUnreadableSignature = (script: TagBlock, pattern: string) => {
1894
+ if (pattern.trim() === "_" || signatureWarned.has(script)) return
1895
+ signatureWarned.add(script)
1896
+ console.warn(
1897
+ `jq79: :setup="${pattern}" is not a props pattern, so this component declares no ` +
1898
+ `signature and takes whatever a parent passes - write the props it takes ` +
1899
+ `("{ a, b }"), a bare :setup for none, or "_" to stay open on purpose`
1900
+ )
1841
1901
  }
1842
1902
 
1843
1903
  // every prop name a component's scripts declare, across both script modes.