jq79 0.5.8 → 0.5.9

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.8",
3
+ "version": "0.5.9",
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
@@ -19,6 +19,12 @@ type TemplateNode = {
19
19
  tag: string
20
20
  attrs: Record<string, string>
21
21
  children: (TemplateNode | string)[]
22
+ // the tag as the author capitalized it, present only when they wrote it
23
+ // uppercase-initial - i.e. when they meant a component. `tag` cannot answer
24
+ // this: the HTML parser lowercases it, so the claim is captured before the
25
+ // parse (see stampComponentTag) and lifted off attrs here, where it stops
26
+ // looking like an attribute to every loop downstream
27
+ component?: string
22
28
  }
23
29
 
24
30
  type TagBlock = {
@@ -45,20 +51,29 @@ const elementAttrs = (el: Element): Record<string, string> =>
45
51
  // they are not in the AST at all - which is where slot content is written
46
52
  // (<template :slot.name>), and why a nested <template> used to render as an
47
53
  // empty element whatever was inside it
48
- const elementToAST = (el: Element): TemplateNode => ({
49
- tag: el.tagName.toLowerCase(),
50
- attrs: elementAttrs(el),
51
- children: Array.from((el instanceof HTMLTemplateElement ? el.content : el).childNodes).flatMap((node): (TemplateNode | string)[] => {
52
- if (node.nodeType === Node.TEXT_NODE) {
53
- const text = node.textContent ?? ""
54
- return text ? [text] : []
55
- }
56
- if (node.nodeType === Node.ELEMENT_NODE) {
57
- return [elementToAST(node as Element)]
58
- }
59
- return []
60
- })
61
- })
54
+ const elementToAST = (el: Element): TemplateNode => {
55
+ const attrs = elementAttrs(el)
56
+ // the pre-parse stamp becomes a field and leaves attrs entirely: it is not a
57
+ // prop, not a directive and not an attribute, and every loop that walks attrs
58
+ // would otherwise need to know its name
59
+ const component = attrs[COMPONENT_TAG_ATTR]
60
+ delete attrs[COMPONENT_TAG_ATTR]
61
+ return {
62
+ tag: el.tagName.toLowerCase(),
63
+ attrs,
64
+ ...(component === undefined ? {} : { component }),
65
+ children: Array.from((el instanceof HTMLTemplateElement ? el.content : el).childNodes).flatMap((node): (TemplateNode | string)[] => {
66
+ if (node.nodeType === Node.TEXT_NODE) {
67
+ const text = node.textContent ?? ""
68
+ return text ? [text] : []
69
+ }
70
+ if (node.nodeType === Node.ELEMENT_NODE) {
71
+ return [elementToAST(node as Element)]
72
+ }
73
+ return []
74
+ })
75
+ }
76
+ }
62
77
 
63
78
  // evaluated with `with` (rather than passing scope keys as positional params)
64
79
  // so only the identifiers an expression actually references are read from
@@ -383,6 +398,32 @@ const findComponentKey = (scope: Record<string, any>, tag: string): string | nul
383
398
  return null
384
399
  }
385
400
 
401
+ // every name a tag *could* have resolved to, walking the same chain
402
+ // findComponentKey does. Deduped and sorted, because the chain can hold one
403
+ // name twice (a prop shadowing a sibling) and the order it comes out in is the
404
+ // prototype's, which means nothing to a reader scanning for their typo
405
+ const componentsInScope = (scope: Record<string, any>): string[] => {
406
+ const names = new Set<string>()
407
+ for (let obj: any = scope; obj && obj !== Object.prototype; obj = Object.getPrototypeOf(obj)) {
408
+ for (const key of Object.keys(obj)) if (/^[A-Z]/.test(key)) names.add(key)
409
+ }
410
+ return [...names].sort()
411
+ }
412
+
413
+ // a tag whose name resolves to no component, once nothing can still supply one.
414
+ // The error names what *is* in scope: the mistake is nearly always a typo or a
415
+ // missing import, and both are one glance from the list. "(none)" is its own
416
+ // answer - it says the component has no components at all, which points at the
417
+ // import rather than at the spelling
418
+ const unresolvedComponent = (tag: string, scope: Record<string, any>): Error => {
419
+ const names = componentsInScope(scope)
420
+ return new Error(
421
+ `jq79: <${tag}> is not defined - no component of that name is in scope, and nothing renders here. ` +
422
+ `Import it in a :setup script, declare it as a prop, or add a <template name="${tag}"> to this file. ` +
423
+ `In scope: ${names.length ? names.join(", ") : "(none)"}.`
424
+ )
425
+ }
426
+
386
427
  // how deep a component may nest inside itself before the runtime calls it a
387
428
  // cycle. Deeper than any real tree, shallower than the JS stack: a truncated
388
429
  // render with an error on the console beats a stack overflow with none
@@ -1025,6 +1066,32 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1025
1066
 
1026
1067
  const el = document.createElement(node.tag)
1027
1068
 
1069
+ // <UserCrad /> - written as a component (node.component), resolving to no
1070
+ // component, and not an element either. Nothing else on the page can supply
1071
+ // the name once every script has settled, so this renders no markup, no
1072
+ // styles, no children and no script, forever, and says so by throwing rather
1073
+ // than leaving a hole where a region of the page was meant to be.
1074
+ //
1075
+ // All three conditions carry weight. Without the capitalization <lable> and
1076
+ // <svg> would be fatal (createElement builds SVG names in the HTML namespace,
1077
+ // so an <svg> is an HTMLUnknownElement too); without the element check <DIV>
1078
+ // would be, though it renders a perfectly good div; and without the pending
1079
+ // count a factory that awaits $mounted() before returning its components
1080
+ // could never render one, which is exactly what the watcher below is for.
1081
+ //
1082
+ // An *absent* count is a fourth case, and it is not zero: renderComponent()
1083
+ // renders a template against a store somebody else owns and assembles, so
1084
+ // nothing there has finished and nothing says a key can't still be written
1085
+ // in. The claim being tested is a component's claim about its own scripts,
1086
+ // and where none was made the tag waits for the upgrade, as it always has.
1087
+ //
1088
+ // Written without a local for the count because renderNode is on the stack
1089
+ // for the whole of the subtree below it, so a slot here is a slot per level
1090
+ // of a component nested inside itself - see renderWith
1091
+ if (node.component && el instanceof HTMLUnknownElement && ((scope as any)[PENDING_SCRIPTS] as PendingScripts | undefined)?.count === 0) {
1092
+ throw unresolvedComponent(node.component, scope)
1093
+ }
1094
+
1028
1095
  // a tag that isn't standard HTML but has no matching scope key *yet* may be
1029
1096
  // a component that arrives later (e.g. an async factory script exposing an
1030
1097
  // imported component after `await`). Watch for the key: the effect tracks
@@ -1527,6 +1594,36 @@ const SLOT_TAG_RE = /^slot\./i
1527
1594
  const kebabTagName = (tag: string): string =>
1528
1595
  SLOT_TAG_RE.test(tag) ? `slot.${camelToKebab(tag.slice("slot.".length))}` : tag
1529
1596
 
1597
+ // the same pass records what it declined to rewrite. An uppercase-initial tag
1598
+ // is a claim about a component: HTML's own elements are matched
1599
+ // case-insensitively but nobody writes <DIV> by accident, and a custom element
1600
+ // may not be spelled that way at all. So <UserCard> is a name the author
1601
+ // expected to resolve - which is what lets renderNode throw when it doesn't
1602
+ // (see unresolvedComponent).
1603
+ //
1604
+ // Carried in a *value* rather than left in the tag name, because the value is
1605
+ // the one place the HTML parser preserves case - the same move expandPropsSpread
1606
+ // makes for `...userData`, and for the same reason. elementToAST lifts it
1607
+ // straight off attrs into a field, so no attribute loop downstream ever sees
1608
+ // it - and since that lift is unconditional, the name has to be one no author
1609
+ // would write: a plain `:component` would eat the prop of that name off
1610
+ // <Card :component="Widget" />
1611
+ const COMPONENT_TAG_ATTR = ":jq79-component"
1612
+ const COMPONENT_TAG_RE = /^[A-Z]/
1613
+
1614
+ // appends the stamp inside the tag, *before* a self-closing slash: this pass
1615
+ // runs first and expandSelfClosingTags still has to recognize the `/>` that
1616
+ // OPEN_TAG_RE swept into the attributes. A slash inside a quoted value can't be
1617
+ // mistaken for it - only a trailing one is matched
1618
+ const TRAILING_SLASH_RE = /\/\s*$/
1619
+
1620
+ const stampComponentTag = (tag: string, attrs: string): string => {
1621
+ if (!COMPONENT_TAG_RE.test(tag)) return attrs
1622
+ const stamp = ` ${COMPONENT_TAG_ATTR}="${tag}"`
1623
+ const slash = TRAILING_SLASH_RE.exec(attrs)
1624
+ return slash ? `${attrs.slice(0, slash.index)}${stamp}${slash[0]}` : `${attrs}${stamp}`
1625
+ }
1626
+
1530
1627
  const expandNameCase = (src: string): string =>
1531
1628
  src
1532
1629
  .split(RAW_BLOCK_RE)
@@ -1538,7 +1635,7 @@ const expandNameCase = (src: string): string =>
1538
1635
  const rewritten = attrs.replace(ATTR_NAME_RE, (whole, space: string | undefined, name: string | undefined) =>
1539
1636
  name === undefined ? whole : `${space}${camelToKebab(name)}`
1540
1637
  )
1541
- return `<${kebabTagName(tag)}${rewritten}>`
1638
+ return `<${kebabTagName(tag)}${stampComponentTag(tag, rewritten)}>`
1542
1639
  })
1543
1640
  .replace(CLOSE_SLOT_RE, (_match, suffix: string, space: string) => `</slot.${camelToKebab(suffix)}${space}>`)
1544
1641
  )
@@ -2049,6 +2146,21 @@ const siblingsInScope = (
2049
2146
  // it too) without ever showing up as data
2050
2147
  const UNFILLED_PROPS = Symbol("jq79.unfilledProps")
2051
2148
 
2149
+ // how many of this render generation's scripts have yet to settle, as a live
2150
+ // box rather than a snapshot. Rides the scope chain like UNFILLED_PROPS, and
2151
+ // for one reader: a <Tag> naming no component in scope is only a mistake once
2152
+ // nothing is left that could still supply the name.
2153
+ //
2154
+ // The count is not the render gate. A script that called $mounted() released
2155
+ // the template and is still running - that is the whole point of the call - so
2156
+ // at paint time this can be non-zero, and a name arriving from a factory that
2157
+ // awaited $mounted() is exactly the case the count keeps quiet. Read live, so
2158
+ // an :if that opens after everything settled is judged against the scripts as
2159
+ // they are then, not as they were at the first paint
2160
+ const PENDING_SCRIPTS = Symbol("jq79.pendingScripts")
2161
+
2162
+ type PendingScripts = { count: number }
2163
+
2052
2164
  // default-import interop for factory scripts: real modules expose .default,
2053
2165
  // while importing an .html component resolves to the Component79 itself
2054
2166
  const interopDefault = (mod: any) => (mod && mod.default !== undefined ? mod.default : mod)
@@ -2466,6 +2578,14 @@ export class Component79 {
2466
2578
  // (`<footer :if="$slots.footer">`). Filled at the usage site, so it can
2467
2579
  // only change when the tag itself re-renders - which builds a new instance
2468
2580
  if (this.slots) Object.defineProperty(raw, SLOTS, { value: this.slots })
2581
+ // in place before the store wraps it, because the scripts that increment it
2582
+ // run against the store and the template reads it back through the same
2583
+ // scope chain. Read back out of `raw` where it is needed rather than kept
2584
+ // in a local: this frame is on the stack for the whole of the subtree it
2585
+ // renders, so a component nested inside itself pays for it once per level -
2586
+ // and the depth guard at MAX_NESTING_DEPTH only beats a RangeError while
2587
+ // this function stays small (see the note in docs/development.md)
2588
+ Object.defineProperty(raw, PENDING_SCRIPTS, { value: { count: 0 } as PendingScripts })
2469
2589
 
2470
2590
  const store = $reactive(raw)
2471
2591
  const fx = createEffectScope(store)
@@ -2654,8 +2774,21 @@ export class Component79 {
2654
2774
  // a script that threw has nothing left to contribute, so its rejection
2655
2775
  // releases the gate exactly as completion does - the error is already
2656
2776
  // reported by the runner, and holding the template hostage to it would
2657
- // turn one broken script into a blank component
2658
- run.settled.then(release, release)
2777
+ // turn one broken script into a blank component. It also stops counting
2778
+ // as a source of names, for that same reason.
2779
+ //
2780
+ // A script that finished on this stack is counted at zero rather than
2781
+ // incremented and decremented a microtask later: its names are on the
2782
+ // store already, and the promise it settles through does not resolve
2783
+ // until after the synchronous paint - which is every paint, for the
2784
+ // components that have no await in them at all
2785
+ if (run.sync) run.settled.then(release, release)
2786
+ else {
2787
+ const pending: PendingScripts = (raw as any)[PENDING_SCRIPTS]
2788
+ pending.count++
2789
+ const settle = () => { pending.count--; release() }
2790
+ run.settled.then(settle, settle)
2791
+ }
2659
2792
  if (!run.sync && !open) allSync = false
2660
2793
  })
2661
2794