jq79 0.4.17 → 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.17",
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
@@ -182,6 +182,12 @@ const wireTagEvent = (instance: Component79, attr: string, expr: string, scope:
182
182
 
183
183
  const kebabToCamel = (name: string) => name.replace(/-(\w)/g, (_, c: string) => c.toUpperCase())
184
184
 
185
+ // the inverse, used only by the pre-parse name rewrite (see expandNameCase):
186
+ // uppercase ASCII letters only, never digits - `:props.0` is a generated
187
+ // attribute name and splitting on digits would mangle it. Round-trips through
188
+ // kebabToCamel, acronyms included: userID -> user-i-d -> userID
189
+ const camelToKebab = (name: string) => name.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)
190
+
185
191
  // the stable boundaries of a rendered chunk. An element is its own handle, but
186
192
  // a fragment (a nested component: two anchors with the instance's DOM between
187
193
  // them) empties itself into the parent on insertion - after that its identity
@@ -289,10 +295,11 @@ type SlotMap = Record<string, SlotRenderer>
289
295
  // nested component is handed
290
296
  const SLOTS = Symbol("jq79.slots")
291
297
 
292
- // <slot>, <slot.header-bar>: the hole and its name. Names are kebab-case where
293
- // written (the HTML parser lowercases tag names and attribute modifiers alike)
294
- // and camelCase where read - <slot.header-bar> is :slot.header-bar is
295
- // $slots.headerBar
298
+ // <slot>, <slot.header-bar>: the hole and its name. Names arrive kebab-case
299
+ // whichever way they were authored (the HTML parser lowercases tag names and
300
+ // attribute modifiers alike, so expandNameCase normalizes camelCase to kebab
301
+ // before parsing) and are camelCase where read - <slot.header-bar> and
302
+ // <slot.headerBar> are :slot.header-bar is $slots.headerBar
296
303
  const isSlotTag = (tag: string): boolean => tag === "slot" || tag.startsWith("slot.")
297
304
 
298
305
  const slotName = (suffix: string): string => (suffix ? kebabToCamel(suffix) : "default")
@@ -515,8 +522,8 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
515
522
  } else if (attr === ":model" || attr.startsWith(":model.")) {
516
523
  // :model[.name]="expr" - two-way: a prop down plus a writeback listener
517
524
  // (wired below, once the instance exists). The modifier arrives
518
- // lowercased from the HTML parser, so names are declared kebab-case;
519
- // the bare :model binds the name "default"
525
+ // kebab-case whichever way it was authored (expandNameCase rewrote any
526
+ // camelCase before parsing); the bare :model binds the name "default"
520
527
  const name = attr === ":model" ? "default" : kebabToCamel(attr.slice(":model.".length))
521
528
  models[name] = value || (attr === ":model" ? "model" : name)
522
529
  } else if (attr.startsWith(":")) {
@@ -543,6 +550,11 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
543
550
  // the assignment would vanish into the comment and compile as a bare read,
544
551
  // dropping every update without a word
545
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>()
546
558
  Object.entries(models).forEach(([name, expr]) => {
547
559
  const prop = modelProp(name)
548
560
  if (props[prop] !== undefined) {
@@ -552,6 +564,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
552
564
  // an expression that can't be an assignment target is a wiring mistake -
553
565
  // say so now, not on the first update that silently goes nowhere
554
566
  if (compileExpr(assignment(expr), ["$value"]) === null) {
567
+ unassignable.add(name)
555
568
  console.warn(`jq79: ${modelAttr(name)}="${expr}" is not assignable - updates from <${node.tag}> will be dropped`)
556
569
  }
557
570
  })
@@ -633,39 +646,37 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
633
646
  // the content this site wrote inside the tag, before the first render: a
634
647
  // <slot> is resolved while rendering, so the map has to be there by then
635
648
  if (slots) instance.slots = slots
636
- // the writeback half of :model - one event, one contract. The name is
637
- // normalized like the attribute was (kebab->camel; absent means default),
638
- // and everything off-contract warns and does nothing: an event protocol's
639
- // failure mode has to be loud, or a typo'd name is an input that types
640
- // 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
641
656
  if (Object.keys(models).length) {
642
657
  // each mistake is warned once per instance, not once per keystroke: an
643
- // 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
644
659
  // every character typed into it
645
660
  const warned = new Set<string>()
646
- const warnOnce = (key: string, message: string) => {
647
- if (warned.has(key)) return
648
- warned.add(key)
649
- console.warn(message)
650
- }
651
- instance.on("model:update", (_event, payload) => {
652
- if (payload === null || typeof payload !== "object") {
653
- warnOnce("payload", `jq79: model:update expects a { name?, value } payload, got ${payload === null ? "null" : typeof payload}`)
654
- return
655
- }
656
- const name = payload.name == null ? "default" : kebabToCamel(String(payload.name))
661
+ instance.modelWriteback = (rawName, value) => {
662
+ const name = rawName == null ? "default" : kebabToCamel(String(rawName))
657
663
  const expr = models[name]
658
664
  if (expr === undefined) {
659
- warnOnce(name, `jq79: <${node.tag}> has no ${modelAttr(name)} - bound: ${Object.keys(models).map(modelAttr).join(", ")}`)
660
- 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
661
670
  }
662
- // 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
663
673
  // script runs inside the parent's *creation* effect, and the reads a
664
674
  // path assignment makes (`user` in `user.name = $value`) would land
665
675
  // in its deps - donating that effect one wasted (guard-stopped) wake
666
676
  // per later write. An imperative writeback is nobody's dependency
667
- untracked(() => evalExpr(assignment(expr), scope, { $value: payload.value }))
668
- })
677
+ untracked(() => evalExpr(assignment(expr), scope, { $value: value }))
678
+ return true
679
+ }
669
680
  }
670
681
 
671
682
  // @event on the tag listens to this instance's $emit channel (and only
@@ -673,7 +684,12 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
673
684
  // explicit re-emit)
674
685
  events.forEach(([attr, expr]) => wireTagEvent(instance, attr, expr, scope))
675
686
 
676
- const seed = untracked(resolveProps)
687
+ // what this component actually takes, decided by its signature. Applied to
688
+ // every path that writes a prop - the seed here and both sync paths below -
689
+ // or an undeclared name would be filtered on the first render and reappear
690
+ // on the next update
691
+ const declared = declaredPropSet(instance.scripts)
692
+ const seed = pickDeclared(untracked(resolveProps), declared)
677
693
  // mounting into a fragment attaches no shadow root of its own: a
678
694
  // shadow-rendered child keeps its <style> elements inline, next to the DOM
679
695
  // they style, and the parent's shadow root is what scopes both
@@ -709,7 +725,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
709
725
  if (hasSpread) {
710
726
  let written: string[] = []
711
727
  syncFx.effect(() => {
712
- const next = resolveProps()
728
+ const next = pickDeclared(resolveProps(), declared)
713
729
  const nextKeys = Object.keys(next)
714
730
  written.forEach(key => { if (!(key in next)) (instance.data as Record<string, any>)[key] = undefined })
715
731
  nextKeys.forEach(key => { (instance.data as Record<string, any>)[key] = next[key] })
@@ -717,6 +733,7 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
717
733
  })
718
734
  } else {
719
735
  Object.entries(props).forEach(([name, expr]) => {
736
+ if (declared !== null && !declared.has(name)) return
720
737
  syncFx.effect(() => { (instance.data as Record<string, any>)[name] = evalExpr(expr, scope) })
721
738
  })
722
739
  }
@@ -1277,6 +1294,53 @@ const expandPropsSpread = (src: string): string =>
1277
1294
  )
1278
1295
  .join("")
1279
1296
 
1297
+ // a `:`-prefixed attribute name in name position, and a </slot.name> closing
1298
+ // tag. Both quote-aware for the same reason ATTR_SPREAD_RE is: a colon inside
1299
+ // a value (@click="a ? b : c", style="color: red") is not an attribute name
1300
+ const ATTR_NAME_RE = /"[^"]*"|'[^']*'|(^|\s)(:[\w.$-]+)/g
1301
+ const CLOSE_SLOT_RE = /<\/slot\.([\w.$-]+)(\s*)>/gi
1302
+ const SLOT_TAG_RE = /^slot\./i
1303
+
1304
+ // camelCase -> kebab-case for every name the HTML parser would lowercase,
1305
+ // BEFORE it gets the chance: `:firstName` would arrive as `:firstname` and
1306
+ // kebabToCamel (which is what reads these names back out) would have nothing
1307
+ // to un-kebab, so the prop, model or slot would silently land under the wrong
1308
+ // key. Rewriting to `:first-name` here means both spellings converge on the
1309
+ // same camelCase name downstream - the author picks, the runtime doesn't care.
1310
+ //
1311
+ // Runs FIRST among the pre-parse passes, which is what keeps it simple: it
1312
+ // never sees the `:props.<n>` that expandPropsSpread generates, and a
1313
+ // <slot.firstName /> is still one occurrence rather than the open+close pair
1314
+ // expandSelfClosingTags turns it into. Same defenses as the passes after it -
1315
+ // <script>/<style> bodies split out, only start-tag interiors scanned, quoted
1316
+ // values consumed whole.
1317
+ //
1318
+ // Two name positions, not one: attribute names (`:model.firstName`) and the
1319
+ // dotted tag names (`<slot.firstName>`), whose closing halves are rewritten
1320
+ // too or the parser sees a mismatched pair. Component tags are deliberately
1321
+ // left alone - findComponentKey already matches them case-insensitively with
1322
+ // dashes stripped, so <UserCard> needs no help and rewriting it would only
1323
+ // obscure what the author wrote
1324
+ const kebabTagName = (tag: string): string =>
1325
+ SLOT_TAG_RE.test(tag) ? `slot.${camelToKebab(tag.slice("slot.".length))}` : tag
1326
+
1327
+ const expandNameCase = (src: string): string =>
1328
+ src
1329
+ .split(RAW_BLOCK_RE)
1330
+ .map((chunk, i) =>
1331
+ i % 2 === 1
1332
+ ? chunk
1333
+ : chunk
1334
+ .replace(OPEN_TAG_RE, (_match, tag: string, attrs: string) => {
1335
+ const rewritten = attrs.replace(ATTR_NAME_RE, (whole, space: string | undefined, name: string | undefined) =>
1336
+ name === undefined ? whole : `${space}${camelToKebab(name)}`
1337
+ )
1338
+ return `<${kebabTagName(tag)}${rewritten}>`
1339
+ })
1340
+ .replace(CLOSE_SLOT_RE, (_match, suffix: string, space: string) => `</slot.${camelToKebab(suffix)}${space}>`)
1341
+ )
1342
+ .join("")
1343
+
1280
1344
  // <style scoped> support. Every element of the component's own template is
1281
1345
  // stamped with data-jq79="<hash>" and the style's selectors are rewritten to
1282
1346
  // require that attribute, so its rules can't reach anything the component
@@ -1367,10 +1431,13 @@ const parseComponentString = (component: string): ComponentParts => {
1367
1431
  // </style>
1368
1432
 
1369
1433
  // parsed as the content of a <template> so leading <script>/<style> tags
1370
- // aren't reparented into <head> by the HTML parser. Both pre-DOM string
1371
- // rewrites run here: `...expr` -> :props.<n>="expr" first (it reads the raw
1372
- // camelCase before the parser can lowercase names), then self-closing tags
1373
- const prepared = expandSelfClosingTags(expandPropsSpread(component))
1434
+ // aren't reparented into <head> by the HTML parser. All three pre-DOM string
1435
+ // rewrites run here, and the order is load-bearing: camelCase names ->
1436
+ // kebab-case first (before `:props.<n>` exists to be mangled and while a
1437
+ // self-closing tag is still one occurrence), then `...expr` -> :props.<n>
1438
+ // (which reads the raw camelCase before the parser can lowercase names),
1439
+ // then self-closing tags
1440
+ const prepared = expandSelfClosingTags(expandPropsSpread(expandNameCase(component)))
1374
1441
  const parsedDOM = new DOMParser().parseFromString(`<template>${prepared}</template>`, "text/html")
1375
1442
  const root = parsedDOM.querySelector("template") as HTMLTemplateElement
1376
1443
 
@@ -1593,6 +1660,36 @@ const declaredPropNames = (scripts: TagBlock[]): Set<string> => {
1593
1660
  return names
1594
1661
  }
1595
1662
 
1663
+ // the same names, but null when NO script declared a signature at all - the
1664
+ // 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
1668
+ const declaredPropSet = (scripts: TagBlock[]): Set<string> | null => {
1669
+ let names: Set<string> | null = null
1670
+ scripts.forEach(script => {
1671
+ const declarations = parseFactoryProps(script.content) ?? parsePropsPattern(script.attrs[":setup"])
1672
+ if (!declarations) return
1673
+ const into = (names ??= new Set())
1674
+ declarations.forEach(({ name }) => into.add(name))
1675
+ })
1676
+ return names
1677
+ }
1678
+
1679
+ // drops the props a component didn't declare, so an undeclared name is simply
1680
+ // absent from its store rather than quietly present: `{{ label }}` renders
1681
+ // empty and `{{ user.name }}` throws on the member access, both at the usage
1682
+ // site that got the name wrong. A null signature keeps everything - see
1683
+ // declaredPropSet. Silent by design: the main source of extra keys is a
1684
+ // `:props` spread of an object wider than the component (`...sdk`), where
1685
+ // taking only the declared few is the point, not a mistake to report
1686
+ const pickDeclared = (props: Record<string, any>, declared: Set<string> | null): Record<string, any> => {
1687
+ if (declared === null) return props
1688
+ const out: Record<string, any> = {}
1689
+ Object.keys(props).forEach(key => { if (declared.has(key)) out[key] = props[key] })
1690
+ return out
1691
+ }
1692
+
1596
1693
  // the sibling components this one resolves by name, or null when there are
1597
1694
  // none left to resolve. They go on the store's *prototype* rather than in it:
1598
1695
  // the component-key scan walks the chain, so <Row> resolves; they stay out of
@@ -1809,6 +1906,13 @@ export class Component79 {
1809
1906
  // and every render reads it from here: a hot reload re-renders from a data
1810
1907
  // snapshot, which a symbol on the store would not survive
1811
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
1812
1916
 
1813
1917
  data: ReactiveDeepData<Record<string, any>> | null = null
1814
1918
 
@@ -2001,7 +2105,16 @@ export class Component79 {
2001
2105
  // event is cancelable so preventDefault() - from either channel - flips
2002
2106
  // the return to false, telling the emitting child "the parent vetoed"
2003
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
2004
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
+ }
2005
2118
  const event = new CustomEvent(eventName, { detail: payload, bubbles: true, composed: true, cancelable: true })
2006
2119
  if (marker === this.startMarker) {
2007
2120
  this.emitListeners.get(eventName)?.forEach(listener => listener(event, payload))
@@ -2048,17 +2161,35 @@ export class Component79 {
2048
2161
  const $import = (url: string): Promise<any> =>
2049
2162
  modules && url in modules ? Promise.resolve(modules[url]) : importResource(url)
2050
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
+
2051
2180
  // the names a component answers on top of its store: $emit, so an inline
2052
2181
  // handler can emit without routing through a setup function
2053
- // (@input="$emit('update', $event.target.value)"), and $slots, the static
2054
- // map of the names the usage site filled, so a wrapper can be dropped when
2055
- // 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
2056
2186
  // template (through templateScope, below) and both script modes (as
2057
- // instance helpers), and a same-named store key shadows either.
2187
+ // instance helpers), and a same-named store key shadows any of them.
2058
2188
  // Null-prototype, for the same reason storeApi is: `key in injected` must
2059
2189
  // not start answering true for toString, constructor and the rest
2060
2190
  const injected: Record<string, any> = Object.assign(Object.create(null), {
2061
2191
  $emit,
2192
+ $updateModel,
2062
2193
  $slots: Object.fromEntries(Object.keys(this.slots ?? {}).map(name => [name, true])),
2063
2194
  })
2064
2195