jq79 0.5.0 → 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.0",
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
  }
@@ -689,6 +772,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
689
772
  // or an undeclared name would be filtered on the first render and reappear
690
773
  // on the next update
691
774
  const declared = declaredPropSet(instance.scripts)
775
+ warnUndeclared(node, key, Object.keys(props), declared)
692
776
  const seed = pickDeclared(untracked(resolveProps), declared)
693
777
  // mounting into a fragment attaches no shadow root of its own: a
694
778
  // shadow-rendered child keeps its <style> elements inline, next to the DOM
@@ -819,6 +903,41 @@ const normalizeAllowUrl = (policy: any): AllowUrl => {
819
903
  return () => false
820
904
  }
821
905
 
906
+ // HTML's boolean attributes, verbatim from the spec's list. Presence is the
907
+ // whole message for these: `disabled="false"` and `disabled="0"` both disable,
908
+ // so the value they carry is noise. This is a table of a fact, not of a jq79
909
+ // convention - nobody in this repo decides what belongs in it, which is what
910
+ // earns it a place in a codebase that otherwise has no name tables
911
+ const BOOLEAN_ATTRS = new Set([
912
+ "allowfullscreen", "async", "autofocus", "autoplay", "checked", "controls",
913
+ "default", "defer", "disabled", "formnovalidate", "inert", "ismap",
914
+ "itemscope", "loop", "multiple", "muted", "nomodule", "novalidate", "open",
915
+ "playsinline", "readonly", "required", "reversed", "selected",
916
+ ])
917
+
918
+ // the one value rule, shared by `:attr="expr"` and `:attrs` so the two forms
919
+ // can never disagree:
920
+ //
921
+ // - a boolean attribute is removed by ANY falsy value and set to "" when
922
+ // truthy, so `:disabled="items.length"` enables the button on an empty list
923
+ // (with `value !== false` as the only test, 0 set the attribute and disabled
924
+ // it - the trap renderComponent.test.ts used to pin);
925
+ // - every other attribute is removed only by null/undefined, so `false`, `0`
926
+ // and `""` are written. `aria-expanded="false"` and a `data-` flag mean
927
+ // something that absent cannot say.
928
+ //
929
+ // Asking the DOM which family a name belongs to (`typeof el[name] ===
930
+ // "boolean"`) is deliberately not what this does: jsdom and Chrome disagree on
931
+ // `autofocus` and every `aria-*`, so the tests would pin a semantics the
932
+ // browser doesn't have - and `readonly`/`novalidate`/`ismap` reflect under
933
+ // camelCase property names no kebab->camel pass can produce, failing toward
934
+ // `readonly="false"`, which is read-only
935
+ const applyAttr = (el: Element, name: string, value: any) => {
936
+ const boolean = BOOLEAN_ATTRS.has(name)
937
+ if (boolean ? !value : value == null) el.removeAttribute(name)
938
+ else el.setAttribute(name, boolean ? "" : String(value))
939
+ }
940
+
822
941
  // renders a single element node: static attrs, @event listeners, a reactive
823
942
  // :attrs object, and its content - :text/:html override the element's own
824
943
  // children with a reactive textContent/innerHTML, otherwise children render
@@ -848,7 +967,11 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
848
967
  // imported component after `await`). Watch for the key: the effect tracks
849
968
  // no deps, so it only re-runs on the store's new-key sweep, and swaps the
850
969
  // placeholder element for the component exactly once
851
- if (el instanceof HTMLUnknownElement || node.tag.includes("-")) {
970
+ // dashes included, because findComponentKey matches them case-insensitively
971
+ // with dashes stripped: <drop-area> resolves DropArea, so a dashed tag is a
972
+ // possible component too, not only a custom element
973
+ const mayUpgrade = el instanceof HTMLUnknownElement || node.tag.includes("-")
974
+ if (mayUpgrade) {
852
975
  let upgraded = false
853
976
  fx.effect(() => {
854
977
  if (upgraded) return
@@ -871,10 +994,30 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
871
994
  // the native-element form is parked there). Warn on a real element, but
872
995
  // not on a tag that may still upgrade into a component - the upgrade
873
996
  // re-renders through renderNestedComponent, models and all
874
- if (!(el instanceof HTMLUnknownElement || node.tag.includes("-"))) {
997
+ if (!mayUpgrade) {
875
998
  console.warn(`jq79: ${key} on <${node.tag}> does nothing - :model binds component tags only (for now)`)
876
999
  }
877
- } else if (!isControlAttr(key)) el.setAttribute(key, value)
1000
+ } else if (isControlAttr(key)) {
1001
+ // a directive of its own, bound further down (or by renderNodes)
1002
+ } else if (key.startsWith(":")) {
1003
+ // :name="expr" binds that one attribute, reactively - the single-key
1004
+ // case :attrs="{ name: expr }" was carrying. `:name` alone is shorthand
1005
+ // for `:name="name"`, like props and :model.<name>, and the shorthand
1006
+ // reads the camelCase variable while the attribute keeps its written
1007
+ // (kebab) name: `:aria-expanded` binds `ariaExpanded`, because
1008
+ // `aria-expanded` as an expression is a subtraction.
1009
+ //
1010
+ // On a tag that may still upgrade this is a *parameter*, not an
1011
+ // attribute: leave it written verbatim, as before, so the upgrade's
1012
+ // renderNestedComponent still finds it. A component tag has no single
1013
+ // root for an attribute to land on anyway (TODOS/2026-07-15.class-directive.md)
1014
+ if (mayUpgrade) el.setAttribute(key, value)
1015
+ else {
1016
+ const name = key.slice(1)
1017
+ const expr = value || kebabToCamel(name)
1018
+ fx.effect(() => applyAttr(el, name, evalExpr(expr, scope)))
1019
+ }
1020
+ } else el.setAttribute(key, value)
878
1021
  })
879
1022
 
880
1023
  const bindExpr = node.attrs[":attrs"]
@@ -885,10 +1028,7 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
885
1028
  boundKeys.forEach(key => el.removeAttribute(key))
886
1029
  const bound = evalExpr(bindExpr, scope)
887
1030
  boundKeys = bound && typeof bound === "object" ? Object.keys(bound) : []
888
- boundKeys.forEach(key => {
889
- const value = bound[key]
890
- if (value != null && value !== false) el.setAttribute(key, String(value))
891
- })
1031
+ boundKeys.forEach(key => applyAttr(el, key, bound[key]))
892
1032
  })
893
1033
  }
894
1034
 
@@ -1625,6 +1765,7 @@ const runSetupScript = (code: string, scope: Record<string, any>, effect: (run:
1625
1765
  `return (async () => { with ($scope) { ${code} } })()${sourceUrlComment(at.filename, at.index ?? 0)}`
1626
1766
  )(scriptScope, effect, importer, ...Object.values(helpers))
1627
1767
  result.catch(error => console.error("jq79: error in :setup script", error))
1768
+ trackScript(result)
1628
1769
  }
1629
1770
 
1630
1771
  // puts a component's declared props on the store, before any script runs and
@@ -1646,6 +1787,22 @@ const declareProps = (store: Record<string, any>, props: PropDecl[] | null) => {
1646
1787
  })
1647
1788
  }
1648
1789
 
1790
+ // a setup script's signature. A bare `<script :setup>` is a CLOSED signature -
1791
+ // the same as `<script :setup="{}">`, declaring zero props and taking none -
1792
+ // because the difference between "takes nothing" and "takes anything" should
1793
+ // not be a pair of braces somebody didn't type. Permissive is still reachable,
1794
+ // it just has to be asked for: `<script :setup="_">`, the same `_` convention
1795
+ // factory scripts already use, which parsePropsPattern reads as no signature.
1796
+ //
1797
+ // Only the empty *value* is closed. An absent attribute (a factory <script>
1798
+ // with no :setup at all) stays `null`, so its signature is still read from the
1799
+ // factory's first parameter
1800
+ const setupSignature = (script: TagBlock): PropDecl[] | null => {
1801
+ const pattern = script.attrs[":setup"]
1802
+ if (pattern === undefined) return null
1803
+ return pattern.trim() === "" ? [] : parsePropsPattern(pattern)
1804
+ }
1805
+
1649
1806
  // every prop name a component's scripts declare, across both script modes.
1650
1807
  // Read before the store exists, because what a component declares decides
1651
1808
  // which of its file's sibling components it can still see: declaring a name
@@ -1654,7 +1811,7 @@ const declareProps = (store: Record<string, any>, props: PropDecl[] | null) => {
1654
1811
  const declaredPropNames = (scripts: TagBlock[]): Set<string> => {
1655
1812
  const names = new Set<string>()
1656
1813
  scripts.forEach(script => {
1657
- const declarations = parseFactoryProps(script.content) ?? parsePropsPattern(script.attrs[":setup"])
1814
+ const declarations = parseFactoryProps(script.content) ?? setupSignature(script)
1658
1815
  declarations?.forEach(({ name }) => names.add(name))
1659
1816
  })
1660
1817
  return names
@@ -1662,13 +1819,13 @@ const declaredPropNames = (scripts: TagBlock[]): Set<string> => {
1662
1819
 
1663
1820
  // the same names, but null when NO script declared a signature at all - the
1664
1821
  // distinction declareProps already keeps, and the only one that can decide
1665
- // whether to filter what a parent passes: `<script :setup>` declares nothing
1666
- // and stays permissive, `<script :setup="{}">` is a closed signature that
1667
- // declares zero props and takes none
1822
+ // whether to filter what a parent passes. `<script :setup>` and
1823
+ // `<script :setup="{}">` are both closed signatures that take nothing (see
1824
+ // setupSignature); `<script :setup="_">` is the permissive one
1668
1825
  const declaredPropSet = (scripts: TagBlock[]): Set<string> | null => {
1669
1826
  let names: Set<string> | null = null
1670
1827
  scripts.forEach(script => {
1671
- const declarations = parseFactoryProps(script.content) ?? parsePropsPattern(script.attrs[":setup"])
1828
+ const declarations = parseFactoryProps(script.content) ?? setupSignature(script)
1672
1829
  if (!declarations) return
1673
1830
  const into = (names ??= new Set())
1674
1831
  declarations.forEach(({ name }) => into.add(name))
@@ -1690,6 +1847,30 @@ const pickDeclared = (props: Record<string, any>, declared: Set<string> | null):
1690
1847
  return out
1691
1848
  }
1692
1849
 
1850
+ // names already reported by warnUndeclared, keyed by the template node - which
1851
+ // is the usage site itself, built once and shared by every instance it ever
1852
+ // renders. So a :each over 200 rows says it once, not once per row, and a
1853
+ // definition swap doesn't repeat what the last one already said
1854
+ const undeclaredWarned = new WeakMap<TemplateNode, Set<string>>()
1855
+
1856
+ // a parameter the child's signature doesn't declare is dropped by pickDeclared
1857
+ // and never reaches its store - `{{ bar }}` renders empty at the other end of
1858
+ // the file. Written parameters only: this is handed the named ones (`:bar`,
1859
+ // and the prop each :model binds), never a `:props` spread's keys, because a
1860
+ // spread of an object wider than the component is the documented, intended use
1861
+ // and taking only the declared few is its point - see pickDeclared. A
1862
+ // component with no signature at all declares nothing to compare against
1863
+ const warnUndeclared = (node: TemplateNode, name: string, written: string[], declared: Set<string> | null) => {
1864
+ if (declared === null) return
1865
+ const said = undeclaredWarned.get(node) ?? new Set<string>()
1866
+ undeclaredWarned.set(node, said)
1867
+ written.forEach(prop => {
1868
+ if (declared.has(prop) || said.has(prop)) return
1869
+ said.add(prop)
1870
+ console.warn(`jq79: :${prop} is not declared by <${name}> - add it to the :setup signature, or drop it`)
1871
+ })
1872
+ }
1873
+
1693
1874
  // the sibling components this one resolves by name, or null when there are
1694
1875
  // none left to resolve. They go on the store's *prototype* rather than in it:
1695
1876
  // the component-key scan walks the chain, so <Row> resolves; they stay out of
@@ -1741,11 +1922,16 @@ const runFactoryScript = (code: string, scope: Record<string, any>, effect: (run
1741
1922
 
1742
1923
  const logError = (error: any) => console.error("jq79: error in factory script", error)
1743
1924
  let invoked = false
1744
- const invoke = () => {
1745
- 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
1746
1932
  invoked = true
1747
1933
  const factory = $__exports.default
1748
- if (typeof factory !== "function") return
1934
+ if (typeof factory !== "function") return undefined
1749
1935
  const merge = (bindings: any) => {
1750
1936
  if (bindings && typeof bindings === "object") Object.assign(scope, bindings)
1751
1937
  }
@@ -1756,14 +1942,18 @@ const runFactoryScript = (code: string, scope: Record<string, any>, effect: (run
1756
1942
  // the props it declared (copying, as destructuring does - $props is the
1757
1943
  // live view for a primitive the parent reassigns later)
1758
1944
  const returned = factory(scope, { $data: scope, $props: scope, $effect: effect, ...instanceHelpers })
1759
- if (returned instanceof Promise) returned.then(merge).catch(logError)
1945
+ if (returned instanceof Promise) merging = returned.then(merge).catch(logError)
1760
1946
  else merge(returned)
1761
1947
  } catch (error) {
1762
1948
  logError(error)
1763
1949
  }
1950
+ return merging
1764
1951
  }
1765
1952
 
1766
- 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))
1767
1957
  if ($__exports.done) invoke() // fully-sync body: factory runs before first render
1768
1958
  }
1769
1959
 
@@ -1982,6 +2172,13 @@ export class Component79 {
1982
2172
  // while its markers sit where its DOM actually is
1983
2173
  hotReplace(src: string | ComponentParts): boolean {
1984
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()
1985
2182
  const marker = this.startMarker
1986
2183
  const rendered = !!(marker && this.content)
1987
2184
 
@@ -2217,7 +2414,7 @@ export class Component79 {
2217
2414
  return
2218
2415
  }
2219
2416
  const { vars, code } = transformSetupScript(script.content)
2220
- declareProps(store, parsePropsPattern(script.attrs[":setup"]))
2417
+ declareProps(store, setupSignature(script))
2221
2418
  // pre-declare script vars on the store so `with` resolves assignments
2222
2419
  // to them (and reads of them) through the reactive proxy
2223
2420
  vars.forEach(name => { if (!(name in store)) (store as any)[name] = undefined })