jq79 0.4.18 → 0.5.1

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.4.18",
3
+ "version": "0.5.1",
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
@@ -550,6 +550,11 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
550
550
  // the assignment would vanish into the comment and compile as a bare read,
551
551
  // dropping every update without a word
552
552
  const assignment = (expr: string) => `${expr}\n= $value`
553
+ // the models whose expression will never take an update, decided here rather
554
+ // than at update time: an assignment that landed and one that was dropped
555
+ // both evaluate to the value assigned, so the result can't tell them apart -
556
+ // which is what $updateModel's return has to report
557
+ const unassignable = new Set<string>()
553
558
  Object.entries(models).forEach(([name, expr]) => {
554
559
  const prop = modelProp(name)
555
560
  if (props[prop] !== undefined) {
@@ -559,6 +564,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
559
564
  // an expression that can't be an assignment target is a wiring mistake -
560
565
  // say so now, not on the first update that silently goes nowhere
561
566
  if (compileExpr(assignment(expr), ["$value"]) === null) {
567
+ unassignable.add(name)
562
568
  console.warn(`jq79: ${modelAttr(name)}="${expr}" is not assignable - updates from <${node.tag}> will be dropped`)
563
569
  }
564
570
  })
@@ -640,39 +646,37 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
640
646
  // the content this site wrote inside the tag, before the first render: a
641
647
  // <slot> is resolved while rendering, so the map has to be there by then
642
648
  if (slots) instance.slots = slots
643
- // the writeback half of :model - one event, one contract. The name is
644
- // normalized like the attribute was (kebab->camel; absent means default),
645
- // and everything off-contract warns and does nothing: an event protocol's
646
- // failure mode has to be loud, or a typo'd name is an input that types
647
- // into the void
649
+ // the writeback half of :model - the function the child's $updateModel
650
+ // calls, handed over before the first render. Not an event: nothing about
651
+ // a parent-child assignment wants a CustomEvent bubbling through the page
652
+ // on every keystroke, and a direct call has no payload shape to get wrong.
653
+ // The name is normalized like the attribute was (kebab->camel; absent
654
+ // means the default model), and a name nothing binds warns: a typo must
655
+ // not be an input that types into the void
648
656
  if (Object.keys(models).length) {
649
657
  // each mistake is warned once per instance, not once per keystroke: an
650
- // input emitting a typo'd name would otherwise flood the console on
658
+ // input updating a typo'd name would otherwise flood the console on
651
659
  // every character typed into it
652
660
  const warned = new Set<string>()
653
- const warnOnce = (key: string, message: string) => {
654
- if (warned.has(key)) return
655
- warned.add(key)
656
- console.warn(message)
657
- }
658
- instance.on("model:update", (_event, payload) => {
659
- if (payload === null || typeof payload !== "object") {
660
- warnOnce("payload", `jq79: model:update expects a { name?, value } payload, got ${payload === null ? "null" : typeof payload}`)
661
- return
662
- }
663
- const name = payload.name == null ? "default" : kebabToCamel(String(payload.name))
661
+ instance.modelWriteback = (rawName, value) => {
662
+ const name = rawName == null ? "default" : kebabToCamel(String(rawName))
664
663
  const expr = models[name]
665
664
  if (expr === undefined) {
666
- warnOnce(name, `jq79: <${node.tag}> has no ${modelAttr(name)} - bound: ${Object.keys(models).map(modelAttr).join(", ")}`)
667
- return
665
+ if (!warned.has(name)) {
666
+ warned.add(name)
667
+ console.warn(`jq79: <${node.tag}> has no ${modelAttr(name)} - bound: ${Object.keys(models).map(modelAttr).join(", ")}`)
668
+ }
669
+ return false
668
670
  }
669
- // untracked, like the tag handlers: a child emitting from its setup
671
+ if (unassignable.has(name)) return false // already warned, at wiring time
672
+ // untracked, like the tag handlers: a child updating from its setup
670
673
  // script runs inside the parent's *creation* effect, and the reads a
671
674
  // path assignment makes (`user` in `user.name = $value`) would land
672
675
  // in its deps - donating that effect one wasted (guard-stopped) wake
673
676
  // per later write. An imperative writeback is nobody's dependency
674
- untracked(() => evalExpr(assignment(expr), scope, { $value: payload.value }))
675
- })
677
+ untracked(() => evalExpr(assignment(expr), scope, { $value: value }))
678
+ return true
679
+ }
676
680
  }
677
681
 
678
682
  // @event on the tag listens to this instance's $emit channel (and only
@@ -685,6 +689,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
685
689
  // or an undeclared name would be filtered on the first render and reappear
686
690
  // on the next update
687
691
  const declared = declaredPropSet(instance.scripts)
692
+ warnUndeclared(node, key, Object.keys(props), declared)
688
693
  const seed = pickDeclared(untracked(resolveProps), declared)
689
694
  // mounting into a fragment attaches no shadow root of its own: a
690
695
  // shadow-rendered child keeps its <style> elements inline, next to the DOM
@@ -815,6 +820,41 @@ const normalizeAllowUrl = (policy: any): AllowUrl => {
815
820
  return () => false
816
821
  }
817
822
 
823
+ // HTML's boolean attributes, verbatim from the spec's list. Presence is the
824
+ // whole message for these: `disabled="false"` and `disabled="0"` both disable,
825
+ // so the value they carry is noise. This is a table of a fact, not of a jq79
826
+ // convention - nobody in this repo decides what belongs in it, which is what
827
+ // earns it a place in a codebase that otherwise has no name tables
828
+ const BOOLEAN_ATTRS = new Set([
829
+ "allowfullscreen", "async", "autofocus", "autoplay", "checked", "controls",
830
+ "default", "defer", "disabled", "formnovalidate", "inert", "ismap",
831
+ "itemscope", "loop", "multiple", "muted", "nomodule", "novalidate", "open",
832
+ "playsinline", "readonly", "required", "reversed", "selected",
833
+ ])
834
+
835
+ // the one value rule, shared by `:attr="expr"` and `:attrs` so the two forms
836
+ // can never disagree:
837
+ //
838
+ // - a boolean attribute is removed by ANY falsy value and set to "" when
839
+ // truthy, so `:disabled="items.length"` enables the button on an empty list
840
+ // (with `value !== false` as the only test, 0 set the attribute and disabled
841
+ // it - the trap renderComponent.test.ts used to pin);
842
+ // - every other attribute is removed only by null/undefined, so `false`, `0`
843
+ // and `""` are written. `aria-expanded="false"` and a `data-` flag mean
844
+ // something that absent cannot say.
845
+ //
846
+ // Asking the DOM which family a name belongs to (`typeof el[name] ===
847
+ // "boolean"`) is deliberately not what this does: jsdom and Chrome disagree on
848
+ // `autofocus` and every `aria-*`, so the tests would pin a semantics the
849
+ // browser doesn't have - and `readonly`/`novalidate`/`ismap` reflect under
850
+ // camelCase property names no kebab->camel pass can produce, failing toward
851
+ // `readonly="false"`, which is read-only
852
+ const applyAttr = (el: Element, name: string, value: any) => {
853
+ const boolean = BOOLEAN_ATTRS.has(name)
854
+ if (boolean ? !value : value == null) el.removeAttribute(name)
855
+ else el.setAttribute(name, boolean ? "" : String(value))
856
+ }
857
+
818
858
  // renders a single element node: static attrs, @event listeners, a reactive
819
859
  // :attrs object, and its content - :text/:html override the element's own
820
860
  // children with a reactive textContent/innerHTML, otherwise children render
@@ -844,7 +884,11 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
844
884
  // imported component after `await`). Watch for the key: the effect tracks
845
885
  // no deps, so it only re-runs on the store's new-key sweep, and swaps the
846
886
  // placeholder element for the component exactly once
847
- if (el instanceof HTMLUnknownElement || node.tag.includes("-")) {
887
+ // dashes included, because findComponentKey matches them case-insensitively
888
+ // with dashes stripped: <drop-area> resolves DropArea, so a dashed tag is a
889
+ // possible component too, not only a custom element
890
+ const mayUpgrade = el instanceof HTMLUnknownElement || node.tag.includes("-")
891
+ if (mayUpgrade) {
848
892
  let upgraded = false
849
893
  fx.effect(() => {
850
894
  if (upgraded) return
@@ -867,10 +911,30 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
867
911
  // the native-element form is parked there). Warn on a real element, but
868
912
  // not on a tag that may still upgrade into a component - the upgrade
869
913
  // re-renders through renderNestedComponent, models and all
870
- if (!(el instanceof HTMLUnknownElement || node.tag.includes("-"))) {
914
+ if (!mayUpgrade) {
871
915
  console.warn(`jq79: ${key} on <${node.tag}> does nothing - :model binds component tags only (for now)`)
872
916
  }
873
- } else if (!isControlAttr(key)) el.setAttribute(key, value)
917
+ } else if (isControlAttr(key)) {
918
+ // a directive of its own, bound further down (or by renderNodes)
919
+ } else if (key.startsWith(":")) {
920
+ // :name="expr" binds that one attribute, reactively - the single-key
921
+ // case :attrs="{ name: expr }" was carrying. `:name` alone is shorthand
922
+ // for `:name="name"`, like props and :model.<name>, and the shorthand
923
+ // reads the camelCase variable while the attribute keeps its written
924
+ // (kebab) name: `:aria-expanded` binds `ariaExpanded`, because
925
+ // `aria-expanded` as an expression is a subtraction.
926
+ //
927
+ // On a tag that may still upgrade this is a *parameter*, not an
928
+ // attribute: leave it written verbatim, as before, so the upgrade's
929
+ // renderNestedComponent still finds it. A component tag has no single
930
+ // root for an attribute to land on anyway (TODOS/2026-07-15.class-directive.md)
931
+ if (mayUpgrade) el.setAttribute(key, value)
932
+ else {
933
+ const name = key.slice(1)
934
+ const expr = value || kebabToCamel(name)
935
+ fx.effect(() => applyAttr(el, name, evalExpr(expr, scope)))
936
+ }
937
+ } else el.setAttribute(key, value)
874
938
  })
875
939
 
876
940
  const bindExpr = node.attrs[":attrs"]
@@ -881,10 +945,7 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
881
945
  boundKeys.forEach(key => el.removeAttribute(key))
882
946
  const bound = evalExpr(bindExpr, scope)
883
947
  boundKeys = bound && typeof bound === "object" ? Object.keys(bound) : []
884
- boundKeys.forEach(key => {
885
- const value = bound[key]
886
- if (value != null && value !== false) el.setAttribute(key, String(value))
887
- })
948
+ boundKeys.forEach(key => applyAttr(el, key, bound[key]))
888
949
  })
889
950
  }
890
951
 
@@ -1642,6 +1703,22 @@ const declareProps = (store: Record<string, any>, props: PropDecl[] | null) => {
1642
1703
  })
1643
1704
  }
1644
1705
 
1706
+ // a setup script's signature. A bare `<script :setup>` is a CLOSED signature -
1707
+ // the same as `<script :setup="{}">`, declaring zero props and taking none -
1708
+ // because the difference between "takes nothing" and "takes anything" should
1709
+ // not be a pair of braces somebody didn't type. Permissive is still reachable,
1710
+ // it just has to be asked for: `<script :setup="_">`, the same `_` convention
1711
+ // factory scripts already use, which parsePropsPattern reads as no signature.
1712
+ //
1713
+ // Only the empty *value* is closed. An absent attribute (a factory <script>
1714
+ // with no :setup at all) stays `null`, so its signature is still read from the
1715
+ // factory's first parameter
1716
+ const setupSignature = (script: TagBlock): PropDecl[] | null => {
1717
+ const pattern = script.attrs[":setup"]
1718
+ if (pattern === undefined) return null
1719
+ return pattern.trim() === "" ? [] : parsePropsPattern(pattern)
1720
+ }
1721
+
1645
1722
  // every prop name a component's scripts declare, across both script modes.
1646
1723
  // Read before the store exists, because what a component declares decides
1647
1724
  // which of its file's sibling components it can still see: declaring a name
@@ -1650,7 +1727,7 @@ const declareProps = (store: Record<string, any>, props: PropDecl[] | null) => {
1650
1727
  const declaredPropNames = (scripts: TagBlock[]): Set<string> => {
1651
1728
  const names = new Set<string>()
1652
1729
  scripts.forEach(script => {
1653
- const declarations = parseFactoryProps(script.content) ?? parsePropsPattern(script.attrs[":setup"])
1730
+ const declarations = parseFactoryProps(script.content) ?? setupSignature(script)
1654
1731
  declarations?.forEach(({ name }) => names.add(name))
1655
1732
  })
1656
1733
  return names
@@ -1658,13 +1735,13 @@ const declaredPropNames = (scripts: TagBlock[]): Set<string> => {
1658
1735
 
1659
1736
  // the same names, but null when NO script declared a signature at all - the
1660
1737
  // distinction declareProps already keeps, and the only one that can decide
1661
- // whether to filter what a parent passes: `<script :setup>` declares nothing
1662
- // and stays permissive, `<script :setup="{}">` is a closed signature that
1663
- // declares zero props and takes none
1738
+ // whether to filter what a parent passes. `<script :setup>` and
1739
+ // `<script :setup="{}">` are both closed signatures that take nothing (see
1740
+ // setupSignature); `<script :setup="_">` is the permissive one
1664
1741
  const declaredPropSet = (scripts: TagBlock[]): Set<string> | null => {
1665
1742
  let names: Set<string> | null = null
1666
1743
  scripts.forEach(script => {
1667
- const declarations = parseFactoryProps(script.content) ?? parsePropsPattern(script.attrs[":setup"])
1744
+ const declarations = parseFactoryProps(script.content) ?? setupSignature(script)
1668
1745
  if (!declarations) return
1669
1746
  const into = (names ??= new Set())
1670
1747
  declarations.forEach(({ name }) => into.add(name))
@@ -1686,6 +1763,30 @@ const pickDeclared = (props: Record<string, any>, declared: Set<string> | null):
1686
1763
  return out
1687
1764
  }
1688
1765
 
1766
+ // names already reported by warnUndeclared, keyed by the template node - which
1767
+ // is the usage site itself, built once and shared by every instance it ever
1768
+ // renders. So a :each over 200 rows says it once, not once per row, and a
1769
+ // definition swap doesn't repeat what the last one already said
1770
+ const undeclaredWarned = new WeakMap<TemplateNode, Set<string>>()
1771
+
1772
+ // a parameter the child's signature doesn't declare is dropped by pickDeclared
1773
+ // and never reaches its store - `{{ bar }}` renders empty at the other end of
1774
+ // the file. Written parameters only: this is handed the named ones (`:bar`,
1775
+ // and the prop each :model binds), never a `:props` spread's keys, because a
1776
+ // spread of an object wider than the component is the documented, intended use
1777
+ // and taking only the declared few is its point - see pickDeclared. A
1778
+ // component with no signature at all declares nothing to compare against
1779
+ const warnUndeclared = (node: TemplateNode, name: string, written: string[], declared: Set<string> | null) => {
1780
+ if (declared === null) return
1781
+ const said = undeclaredWarned.get(node) ?? new Set<string>()
1782
+ undeclaredWarned.set(node, said)
1783
+ written.forEach(prop => {
1784
+ if (declared.has(prop) || said.has(prop)) return
1785
+ said.add(prop)
1786
+ console.warn(`jq79: :${prop} is not declared by <${name}> - add it to the :setup signature, or drop it`)
1787
+ })
1788
+ }
1789
+
1689
1790
  // the sibling components this one resolves by name, or null when there are
1690
1791
  // none left to resolve. They go on the store's *prototype* rather than in it:
1691
1792
  // the component-key scan walks the chain, so <Row> resolves; they stay out of
@@ -1902,6 +2003,13 @@ export class Component79 {
1902
2003
  // and every render reads it from here: a hot reload re-renders from a data
1903
2004
  // snapshot, which a symbol on the store would not survive
1904
2005
  slots?: SlotMap
2006
+ // the writeback half of :model, same story: the function that assigns into
2007
+ // the parent, set by renderNestedComponent before the first render and
2008
+ // called by this instance's $updateModel. Kept outside the render generation
2009
+ // so it survives re-render and hot reload. Absent means no :model on the tag
2010
+ // (or no tag at all - a root mount), which makes every $updateModel a no-op.
2011
+ // Internal: set by the usage site, not part of the public API
2012
+ modelWriteback?: (name: string | undefined, value: any) => boolean
1905
2013
 
1906
2014
  data: ReactiveDeepData<Record<string, any>> | null = null
1907
2015
 
@@ -2094,7 +2202,16 @@ export class Component79 {
2094
2202
  // event is cancelable so preventDefault() - from either channel - flips
2095
2203
  // the return to false, telling the emitting child "the parent vetoed"
2096
2204
  const marker = this.startMarker
2205
+ // model:update used to be the writeback's event name; it is a direct call
2206
+ // now ($updateModel), so an emit under that name reaches nothing. Said
2207
+ // once per generation rather than per keystroke, and said at all because
2208
+ // the alternative is a child whose edits silently stop arriving
2209
+ let warnedModelUpdate = false
2097
2210
  const $emit = (eventName: string, payload?: any): boolean => {
2211
+ if (eventName === "model:update" && !warnedModelUpdate) {
2212
+ warnedModelUpdate = true
2213
+ console.warn("jq79: $emit('model:update', …) no longer feeds :model - call $updateModel(value) or $updateModel(name, value) instead")
2214
+ }
2098
2215
  const event = new CustomEvent(eventName, { detail: payload, bubbles: true, composed: true, cancelable: true })
2099
2216
  if (marker === this.startMarker) {
2100
2217
  this.emitListeners.get(eventName)?.forEach(listener => listener(event, payload))
@@ -2141,17 +2258,35 @@ export class Component79 {
2141
2258
  const $import = (url: string): Promise<any> =>
2142
2259
  modules && url in modules ? Promise.resolve(modules[url]) : importResource(url)
2143
2260
 
2261
+ // the writeback half of :model, from the child's side: one argument is the
2262
+ // value for the default model (the bare :model), two are a name and a
2263
+ // value. Arity is what tells them apart, so the value is never inspected -
2264
+ // an object with `name`/`value` keys is just a value, which is exactly
2265
+ // what a payload-shaped contract could not promise. Returns whether a
2266
+ // bound model took it; no :model at the usage site is a silent no-op,
2267
+ // since a child may be designed to work bound or unbound
2268
+ const $updateModel = (...args: [value?: any] | [name: string, value: any]): boolean => {
2269
+ const [name, value] = args.length > 1 ? args as [string, any] : [undefined, args[0]]
2270
+ // the same stale-generation guard $emit has: destroy() nulls the marker,
2271
+ // so a closure the old child leaked (a timer, a registered callback)
2272
+ // cannot keep writing a parent that replaced it
2273
+ if (marker !== this.startMarker) return false
2274
+ return this.modelWriteback?.(name, value) ?? false
2275
+ }
2276
+
2144
2277
  // the names a component answers on top of its store: $emit, so an inline
2145
2278
  // handler can emit without routing through a setup function
2146
- // (@input="$emit('update', $event.target.value)"), and $slots, the static
2147
- // map of the names the usage site filled, so a wrapper can be dropped when
2148
- // nothing filled it (<footer :if="$slots.footer">). Both reach the
2279
+ // (@input="$emit('update', $event.target.value)"), $updateModel, the
2280
+ // writeback a :model binding listens for, and $slots, the static map of
2281
+ // the names the usage site filled, so a wrapper can be dropped when
2282
+ // nothing filled it (<footer :if="$slots.footer">). All reach the
2149
2283
  // template (through templateScope, below) and both script modes (as
2150
- // instance helpers), and a same-named store key shadows either.
2284
+ // instance helpers), and a same-named store key shadows any of them.
2151
2285
  // Null-prototype, for the same reason storeApi is: `key in injected` must
2152
2286
  // not start answering true for toString, constructor and the rest
2153
2287
  const injected: Record<string, any> = Object.assign(Object.create(null), {
2154
2288
  $emit,
2289
+ $updateModel,
2155
2290
  $slots: Object.fromEntries(Object.keys(this.slots ?? {}).map(name => [name, true])),
2156
2291
  })
2157
2292
 
@@ -2179,7 +2314,7 @@ export class Component79 {
2179
2314
  return
2180
2315
  }
2181
2316
  const { vars, code } = transformSetupScript(script.content)
2182
- declareProps(store, parsePropsPattern(script.attrs[":setup"]))
2317
+ declareProps(store, setupSignature(script))
2183
2318
  // pre-declare script vars on the store so `with` resolves assignments
2184
2319
  // to them (and reads of them) through the reactive proxy
2185
2320
  vars.forEach(name => { if (!(name in store)) (store as any)[name] = undefined })