jq79 0.4.18 → 0.5.0

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.0",
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
@@ -1902,6 +1906,13 @@ export class Component79 {
1902
1906
  // and every render reads it from here: a hot reload re-renders from a data
1903
1907
  // snapshot, which a symbol on the store would not survive
1904
1908
  slots?: SlotMap
1909
+ // the writeback half of :model, same story: the function that assigns into
1910
+ // the parent, set by renderNestedComponent before the first render and
1911
+ // called by this instance's $updateModel. Kept outside the render generation
1912
+ // so it survives re-render and hot reload. Absent means no :model on the tag
1913
+ // (or no tag at all - a root mount), which makes every $updateModel a no-op.
1914
+ // Internal: set by the usage site, not part of the public API
1915
+ modelWriteback?: (name: string | undefined, value: any) => boolean
1905
1916
 
1906
1917
  data: ReactiveDeepData<Record<string, any>> | null = null
1907
1918
 
@@ -2094,7 +2105,16 @@ export class Component79 {
2094
2105
  // event is cancelable so preventDefault() - from either channel - flips
2095
2106
  // the return to false, telling the emitting child "the parent vetoed"
2096
2107
  const marker = this.startMarker
2108
+ // model:update used to be the writeback's event name; it is a direct call
2109
+ // now ($updateModel), so an emit under that name reaches nothing. Said
2110
+ // once per generation rather than per keystroke, and said at all because
2111
+ // the alternative is a child whose edits silently stop arriving
2112
+ let warnedModelUpdate = false
2097
2113
  const $emit = (eventName: string, payload?: any): boolean => {
2114
+ if (eventName === "model:update" && !warnedModelUpdate) {
2115
+ warnedModelUpdate = true
2116
+ console.warn("jq79: $emit('model:update', …) no longer feeds :model - call $updateModel(value) or $updateModel(name, value) instead")
2117
+ }
2098
2118
  const event = new CustomEvent(eventName, { detail: payload, bubbles: true, composed: true, cancelable: true })
2099
2119
  if (marker === this.startMarker) {
2100
2120
  this.emitListeners.get(eventName)?.forEach(listener => listener(event, payload))
@@ -2141,17 +2161,35 @@ export class Component79 {
2141
2161
  const $import = (url: string): Promise<any> =>
2142
2162
  modules && url in modules ? Promise.resolve(modules[url]) : importResource(url)
2143
2163
 
2164
+ // the writeback half of :model, from the child's side: one argument is the
2165
+ // value for the default model (the bare :model), two are a name and a
2166
+ // value. Arity is what tells them apart, so the value is never inspected -
2167
+ // an object with `name`/`value` keys is just a value, which is exactly
2168
+ // what a payload-shaped contract could not promise. Returns whether a
2169
+ // bound model took it; no :model at the usage site is a silent no-op,
2170
+ // since a child may be designed to work bound or unbound
2171
+ const $updateModel = (...args: [value?: any] | [name: string, value: any]): boolean => {
2172
+ const [name, value] = args.length > 1 ? args as [string, any] : [undefined, args[0]]
2173
+ // the same stale-generation guard $emit has: destroy() nulls the marker,
2174
+ // so a closure the old child leaked (a timer, a registered callback)
2175
+ // cannot keep writing a parent that replaced it
2176
+ if (marker !== this.startMarker) return false
2177
+ return this.modelWriteback?.(name, value) ?? false
2178
+ }
2179
+
2144
2180
  // the names a component answers on top of its store: $emit, so an inline
2145
2181
  // 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
2182
+ // (@input="$emit('update', $event.target.value)"), $updateModel, the
2183
+ // writeback a :model binding listens for, and $slots, the static map of
2184
+ // the names the usage site filled, so a wrapper can be dropped when
2185
+ // nothing filled it (<footer :if="$slots.footer">). All reach the
2149
2186
  // template (through templateScope, below) and both script modes (as
2150
- // instance helpers), and a same-named store key shadows either.
2187
+ // instance helpers), and a same-named store key shadows any of them.
2151
2188
  // Null-prototype, for the same reason storeApi is: `key in injected` must
2152
2189
  // not start answering true for toString, constructor and the rest
2153
2190
  const injected: Record<string, any> = Object.assign(Object.create(null), {
2154
2191
  $emit,
2192
+ $updateModel,
2155
2193
  $slots: Object.fromEntries(Object.keys(this.slots ?? {}).map(name => [name, true])),
2156
2194
  })
2157
2195