jq79 0.4.12 → 0.4.13

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/src/jq79.ts CHANGED
@@ -216,6 +216,12 @@ const findComponentKey = (scope: Record<string, any>, tag: string): string | nul
216
216
  return null
217
217
  }
218
218
 
219
+ // how deep a component may nest inside itself before the runtime calls it a
220
+ // cycle. Deeper than any real tree, shallower than the JS stack: a truncated
221
+ // render with an error on the console beats a stack overflow with none
222
+ const MAX_NESTING_DEPTH = 200
223
+ let nestingDepth = 0
224
+
219
225
  // <MyComponent :user :title="'str'"></MyComponent> - renders a child
220
226
  // component instance at this position. Props: `:name="expr"` evaluates expr
221
227
  // in the parent scope (`:name` alone is shorthand for `:name="name"`), plain
@@ -329,9 +335,34 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
329
335
  let currentDef: Component79 | null = null
330
336
  let childFx: EffectScope | null = null
331
337
 
338
+ // a usage site that resolves to no component renders nothing, which is
339
+ // deliberate - `undefined` while an `await import(...)` is in flight has to
340
+ // wait quietly, and the child appears when it lands. Two cases can never
341
+ // resolve, though, and both are wiring mistakes worth naming: a value that
342
+ // isn't a component (and so will never become one by waiting), and a name
343
+ // the component declared as a prop that the parent passed nothing for. Once
344
+ // each, per usage site: an effect re-runs
345
+ const reported = new Set<string>()
346
+ const reportUnresolved = (value: any) => {
347
+ if (value === undefined || value === null) {
348
+ const unfilled: Set<string> | undefined = (scope as any)[UNFILLED_PROPS]
349
+ if (!unfilled?.has(key) || reported.has("unfilled")) return
350
+ reported.add("unfilled")
351
+ console.error(
352
+ `jq79: <${node.tag}> is declared as a prop and the parent passed nothing - nothing renders here. ` +
353
+ `Pass it (:${key}="…"), or drop it from the signature to use the one declared in this file.`
354
+ )
355
+ return
356
+ }
357
+ if (reported.has("type")) return
358
+ reported.add("type")
359
+ console.error(`jq79: <${node.tag}> is ${typeof value}, not a component - nothing renders here`)
360
+ }
361
+
332
362
  fx.effect(() => {
333
363
  const value = evalExpr(key, scope)
334
364
  const nextDef = value instanceof Component79 ? value : null
365
+ if (!nextDef) reportUnresolved(value)
335
366
  if (nextDef === currentDef) return
336
367
 
337
368
  childFx?.dispose()
@@ -349,6 +380,11 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
349
380
  styles: nextDef.styles,
350
381
  modules: nextDef.modules,
351
382
  filename: nextDef.filename,
383
+ // its file's other components, and which of them it is: without the
384
+ // first a child rendered here loses the siblings its definition could
385
+ // see, and without the second hot reload can't tell it what it is
386
+ siblings: nextDef.siblings,
387
+ name: nextDef.name,
352
388
  })
353
389
  // the writeback half of :model - one event, one contract. The name is
354
390
  // normalized like the attribute was (kebab->camel; absent means default),
@@ -395,7 +431,24 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
395
431
  // shadow-rendered child keeps its <style> elements inline, next to the DOM
396
432
  // they style, and the parent's shadow root is what scopes both
397
433
  const holder = document.createDocumentFragment()
398
- ;(shadow ? instance.renderShadow(seed) : instance.render(seed)).mount(holder)
434
+ // rendering a child happens on this same stack, so a component that
435
+ // renders itself recurses as deep as its data does - and a cycle in that
436
+ // data would recurse until the JS stack gave out, ~900 identical frames
437
+ // naming nothing. Cut and named instead, exactly like the effect runner
438
+ // cuts an effect that wakes itself
439
+ if (nestingDepth >= MAX_NESTING_DEPTH) {
440
+ console.error(
441
+ `jq79: <${node.tag}> is ${MAX_NESTING_DEPTH} levels deep inside itself; giving up here. ` +
442
+ "A component that renders itself stops when its data stops - is there a cycle in it?"
443
+ )
444
+ return
445
+ }
446
+ nestingDepth++
447
+ try {
448
+ ;(shadow ? instance.renderShadow(seed) : instance.render(seed)).mount(holder)
449
+ } finally {
450
+ nestingDepth--
451
+ }
399
452
  endAnchor.parentNode!.insertBefore(holder, endAnchor)
400
453
 
401
454
  const syncFx = createEffectScope(scope)
@@ -889,6 +942,15 @@ type ComponentParts = {
889
942
  // where this component came from (a URL for fetch(), a path for the vite
890
943
  // plugin). Names the setup scripts in devtools - see scriptSourceUrl
891
944
  filename?: string
945
+ // the components the file's <template name="..."> blocks declared, by name.
946
+ // Every component parsed out of one file holds this same map - itself
947
+ // included - which is what makes a sibling usable without an import, and
948
+ // what lets a <template name="TreeNode"> render a <TreeNode>
949
+ siblings?: Record<string, Component79>
950
+ // which of the file's components this is: a template's name, or undefined
951
+ // for the file's own. The file is the hot-reload unit, so a reparse hands
952
+ // each live instance the parts belonging to the component it is
953
+ name?: string
892
954
  }
893
955
 
894
956
  const VOID_ELEMENTS = new Set([
@@ -1015,9 +1077,16 @@ const scopeCss = (css: string, scope: string): string => {
1015
1077
  return Array.from(sheet.cssRules).map(rule => rule.cssText).join("\n")
1016
1078
  }
1017
1079
 
1080
+ // a component name has to be PascalCase to be usable: findComponentKey only
1081
+ // ever considers capitalized scope keys, so a lowercase name would declare a
1082
+ // component no tag could reference. It is also what keeps the named exports
1083
+ // from colliding with a definition's own fields, which are all lowercase
1084
+ const COMPONENT_NAME_RE = /^[A-Z][A-Za-z0-9]*$/
1085
+
1018
1086
  // converts a string of HTML into an AST representation of the component:
1019
1087
  // - template: the non-script/style top-level elements, as TemplateNodes
1020
1088
  // - scripts/styles: { attrs, content } blocks in source order
1089
+ // - siblings: the components its top-level <template name="..."> declared
1021
1090
  const parseComponentString = (component: string): ComponentParts => {
1022
1091
  // example
1023
1092
  // <script :setup="{ fname, lname }">
@@ -1043,11 +1112,64 @@ const parseComponentString = (component: string): ComponentParts => {
1043
1112
  const parsedDOM = new DOMParser().parseFromString(`<template>${prepared}</template>`, "text/html")
1044
1113
  const root = parsedDOM.querySelector("template") as HTMLTemplateElement
1045
1114
 
1115
+ // a top-level <template> declares another component of this file; everything
1116
+ // else is this one's own
1117
+ const own: Element[] = []
1118
+ const declarations: HTMLTemplateElement[] = []
1119
+ Array.from(root.content.children).forEach(el => {
1120
+ if (el.tagName === "TEMPLATE") declarations.push(el as HTMLTemplateElement)
1121
+ else own.push(el)
1122
+ })
1123
+
1124
+ // the file's own component hashes the whole file: every component in it
1125
+ // re-renders on any edit anyway (the file is the hot-reload unit), so a
1126
+ // stamp that changes when a sibling is edited costs nothing, and scopeHash
1127
+ // gets to keep hashing the source it was handed
1128
+ const parts = componentPartsFrom(own, component)
1129
+
1130
+ // one map, shared by reference: it is filled below, after each definition
1131
+ // has already been handed it, so every component of the file sees all the
1132
+ // others *and itself* - which is what makes a recursive component possible
1133
+ const siblings: Record<string, Component79> = {}
1134
+ declarations.forEach(el => {
1135
+ const name = el.getAttribute("name")
1136
+ // ignored rather than fatal, like every other malformed thing here: a bad
1137
+ // save mid-typing must not take the page down, least of all under HMR
1138
+ if (name === null) {
1139
+ console.warn("jq79: a top-level <template> without a name declares nothing and was ignored")
1140
+ return
1141
+ }
1142
+ if (!COMPONENT_NAME_RE.test(name)) {
1143
+ console.warn(
1144
+ `jq79: <template name="${name}"> was ignored - a component name has to be PascalCase, ` +
1145
+ "or no tag could ever reference it (only capitalized names resolve as components)"
1146
+ )
1147
+ return
1148
+ }
1149
+ if (name in siblings) {
1150
+ console.warn(`jq79: two <template name="${name}"> in one file; the second was ignored`)
1151
+ return
1152
+ }
1153
+ // its own source is its own scope: a named template is a shadow root
1154
+ // inside a shadow root, so the file's scoped rules stop at its boundary
1155
+ // and its own stop there too
1156
+ siblings[name] = new Component79({ ...componentPartsFrom(Array.from(el.content.children), el.innerHTML), siblings, name })
1157
+ })
1158
+ if (Object.keys(siblings).length) parts.siblings = siblings
1159
+
1160
+ return parts
1161
+ }
1162
+
1163
+ // the script/style/markup split of one component's top-level elements, with
1164
+ // <style scoped> resolved against the source those elements came from - the
1165
+ // whole file for its own component, a <template>'s contents for a named one,
1166
+ // so the two get different stamps and neither can style the other
1167
+ const componentPartsFrom = (elements: Element[], hashSource: string): ComponentParts => {
1046
1168
  const scripts: TagBlock[] = []
1047
1169
  const styles: TagBlock[] = []
1048
1170
  const template: TemplateNode[] = []
1049
1171
 
1050
- Array.from(root.content.children).forEach(el => {
1172
+ elements.forEach(el => {
1051
1173
  const block: TagBlock = { attrs: elementAttrs(el), content: el.textContent ?? "" }
1052
1174
 
1053
1175
  if (el.tagName === "SCRIPT") scripts.push(block)
@@ -1074,7 +1196,7 @@ const parseComponentString = (component: string): ComponentParts => {
1074
1196
  // in something that isn't CSS yet would only garble what devtools shows
1075
1197
  const isScoped = (style: TagBlock) => "scoped" in style.attrs && !("lang" in style.attrs)
1076
1198
  if (styles.some(isScoped)) {
1077
- const scope = scopeHash(component)
1199
+ const scope = scopeHash(hashSource)
1078
1200
  stampScope(template, scope)
1079
1201
  styles.forEach(style => {
1080
1202
  if (isScoped(style)) style.scoped = scopeCss(style.content, scope)
@@ -1193,6 +1315,50 @@ const declareProps = (store: Record<string, any>, props: PropDecl[] | null) => {
1193
1315
  })
1194
1316
  }
1195
1317
 
1318
+ // every prop name a component's scripts declare, across both script modes.
1319
+ // Read before the store exists, because what a component declares decides
1320
+ // which of its file's sibling components it can still see: declaring a name
1321
+ // says it comes from the parent, so the file's own definition of that name is
1322
+ // deliberately not in this component's scope
1323
+ const declaredPropNames = (scripts: TagBlock[]): Set<string> => {
1324
+ const names = new Set<string>()
1325
+ scripts.forEach(script => {
1326
+ const declarations = parseFactoryProps(script.content) ?? parsePropsPattern(script.attrs[":setup"])
1327
+ declarations?.forEach(({ name }) => names.add(name))
1328
+ })
1329
+ return names
1330
+ }
1331
+
1332
+ // the sibling components this one resolves by name, or null when there are
1333
+ // none left to resolve. They go on the store's *prototype* rather than in it:
1334
+ // the component-key scan walks the chain, so <Row> resolves; they stay out of
1335
+ // the data, so Object.keys, snapshots and spreads never see them; and an own
1336
+ // key shadows a prototype one, so a prop the parent did pass wins for free
1337
+ const siblingsInScope = (
1338
+ siblings: Record<string, Component79> | undefined,
1339
+ declared: Set<string>
1340
+ ): Record<string, Component79> | null => {
1341
+ if (!siblings) return null
1342
+ // null-prototype, for the same reason storeApi is: `key in scope` must not
1343
+ // start answering true for toString, constructor and the rest
1344
+ const inScope: Record<string, Component79> = Object.create(null)
1345
+ let any = false
1346
+ Object.entries(siblings).forEach(([name, component]) => {
1347
+ if (declared.has(name)) return
1348
+ inScope[name] = component
1349
+ any = true
1350
+ })
1351
+ return any ? inScope : null
1352
+ }
1353
+
1354
+ // names a component declared as props and the parent passed nothing for. Such
1355
+ // a name can never become a component later - there is no binding on the tag
1356
+ // to update it - so a <Tag> reading one is a wiring mistake that can be named
1357
+ // on sight, unlike the `undefined` of an import still in flight. Symbol-keyed
1358
+ // and non-enumerable: it rides the scope chain (so an :each item scope finds
1359
+ // it too) without ever showing up as data
1360
+ const UNFILLED_PROPS = Symbol("jq79.unfilledProps")
1361
+
1196
1362
  // default-import interop for factory scripts: real modules expose .default,
1197
1363
  // while importing an .html component resolves to the Component79 itself
1198
1364
  const interopDefault = (mod: any) => (mod && mod.default !== undefined ? mod.default : mod)
@@ -1297,6 +1463,13 @@ export const hotUpdate = (filename: string, src: string): number => {
1297
1463
  // parsed once and shared by every instance - which is already what a
1298
1464
  // definition and the clones :component makes from it do
1299
1465
  const parts = parseComponentString(src)
1466
+ // the file is the hot-reload unit, so one reparse serves every component it
1467
+ // declares: an instance is handed the parts of the component it *is*, by
1468
+ // name. A name that is no longer in the file (a <template> renamed or
1469
+ // deleted) has no parts to be given, and only a reload can fix the page
1470
+ let orphaned = false
1471
+ const partsFor = (instance: Component79): ComponentParts | null =>
1472
+ instance.name === undefined ? parts : parts.siblings?.[instance.name] ?? null
1300
1473
 
1301
1474
  let rerendered = 0
1302
1475
  for (const [name, refs] of hotRegistry) {
@@ -1307,11 +1480,16 @@ export const hotUpdate = (filename: string, src: string): number => {
1307
1480
  refs.delete(ref) // collected since the last update
1308
1481
  continue
1309
1482
  }
1310
- if (instance.hotReplace(parts)) rerendered++
1483
+ const next = partsFor(instance)
1484
+ if (!next) {
1485
+ orphaned = true
1486
+ continue
1487
+ }
1488
+ if (instance.hotReplace(next)) rerendered++
1311
1489
  }
1312
1490
  if (!refs.size) hotRegistry.delete(name)
1313
1491
  }
1314
- return rerendered
1492
+ return orphaned ? 0 : rerendered
1315
1493
  }
1316
1494
 
1317
1495
  // starts tracking instances, so hotUpdate can find them. jq79/dev's client
@@ -1324,6 +1502,14 @@ export const enableHotReload = (): void => {
1324
1502
 
1325
1503
  type EmitListener = (event: CustomEvent, payload: any) => void
1326
1504
 
1505
+ const fetchComponent = async (url: string): Promise<Component79> => {
1506
+ const response = await fetch(url)
1507
+ if (!response.ok) throw new Error(`failed to fetch component from ${url}: ${response.status}`)
1508
+ // the URL names the component's scripts in devtools, and is where the
1509
+ // browser will look for the source when a breakpoint lands in one
1510
+ return new Component79(await response.text(), { filename: url })
1511
+ }
1512
+
1327
1513
  // a parsed single-file component. Typical lifecycle:
1328
1514
  //
1329
1515
  // const jq79 = new Component79(src) // or await Component79.fetch(url)
@@ -1341,6 +1527,14 @@ export class Component79 {
1341
1527
  modules?: Record<string, any>
1342
1528
  // the component's origin, used to name its scripts in devtools
1343
1529
  filename?: string
1530
+ // the other components declared in the same file, by name (see
1531
+ // ComponentParts.siblings). They are also this definition's own properties,
1532
+ // so `const { Row } = await Component79.fetch(url)` reaches them
1533
+ siblings?: Record<string, Component79>
1534
+ // this component's name inside its file, for the components a <template>
1535
+ // declared; the file's own component has none - it is the default, and a
1536
+ // default is named by whoever imports it
1537
+ name?: string
1344
1538
 
1345
1539
  data: ReactiveDeepData<Record<string, any>> | null = null
1346
1540
 
@@ -1372,9 +1566,29 @@ export class Component79 {
1372
1566
  this.styles = parts.styles
1373
1567
  this.modules = options.modules ?? (typeof src === "string" ? undefined : src.modules)
1374
1568
  this.filename = options.filename ?? (typeof src === "string" ? undefined : src.filename)
1569
+ this.siblings = parts.siblings
1570
+ this.name = parts.name
1571
+ this.adoptSiblings()
1375
1572
  hotRegister(this) // a no-op unless the page enabled hot reload
1376
1573
  }
1377
1574
 
1575
+ // the parser builds a file's sibling definitions before anyone has told it
1576
+ // where the file came from, so whoever holds the parse hands its origin down
1577
+ // - and keeps doing it after a hot reload, which parses the file afresh.
1578
+ // Without it a reloaded child would have no filename, and an instance with
1579
+ // no filename is not tracked: the next edit would never reach it
1580
+ private adoptSiblings() {
1581
+ if (!this.siblings) return
1582
+ Object.entries(this.siblings).forEach(([name, sibling]) => {
1583
+ sibling.filename ??= this.filename
1584
+ sibling.modules ??= this.modules
1585
+ // the file's own component also *is* the file: its named components hang
1586
+ // off it as properties, which is what `const { Row } = …` reads (and
1587
+ // what the bundler re-exports by name)
1588
+ if (!this.name) (this as any)[name] = sibling
1589
+ })
1590
+ }
1591
+
1378
1592
  // swaps this component's parsed parts for `src`'s and, if it is on the page,
1379
1593
  // re-renders it where it stands - seeded with a snapshot of its data, so
1380
1594
  // props and store values survive (the setup script runs again, so whatever it
@@ -1410,6 +1624,11 @@ export class Component79 {
1410
1624
  this.template = parts.template
1411
1625
  this.scripts = parts.scripts
1412
1626
  this.styles = parts.styles
1627
+ // the file's other components as they are now: the next render resolves
1628
+ // <Row> against these, so a parent picks up an edited child even when the
1629
+ // child's own instances are patched separately
1630
+ this.siblings = parts.siblings
1631
+ this.adoptSiblings()
1413
1632
  if (!rendered) return false // a definition: its clones re-render themselves
1414
1633
 
1415
1634
  this.renderWith(data, shadow)
@@ -1424,12 +1643,13 @@ export class Component79 {
1424
1643
  return true
1425
1644
  }
1426
1645
 
1427
- static async fetch(url: string): Promise<Component79> {
1428
- const response = await fetch(url)
1429
- if (!response.ok) throw new Error(`failed to fetch component from ${url}: ${response.status}`)
1430
- // the URL names the component's scripts in devtools, and is where the
1431
- // browser will look for the source when a breakpoint lands in one
1432
- return new Component79(await response.text(), { filename: url })
1646
+ static fetch(url: string): Promise<Component79>
1647
+ static fetch(urls: string[]): Promise<Component79[]>
1648
+ // an array of URLs fetches them all at once and resolves to the components in
1649
+ // the same order, so one await destructures them - and, like Promise.all, the
1650
+ // first failure rejects the whole thing
1651
+ static fetch(urls: string | string[]): Promise<Component79 | Component79[]> {
1652
+ return Array.isArray(urls) ? Promise.all(urls.map(fetchComponent)) : fetchComponent(urls)
1433
1653
  }
1434
1654
 
1435
1655
  // subscribes to this instance's $emit events, on top of the DOM CustomEvent
@@ -1460,7 +1680,18 @@ export class Component79 {
1460
1680
  private renderWith(data: Record<string, any>, shadow: boolean): this {
1461
1681
  this.destroy()
1462
1682
 
1463
- const store = $reactive({ ...data })
1683
+ // what this component can see of its file's other components, and which of
1684
+ // its declared props arrived empty - both decided by the signature, before
1685
+ // the store exists (see siblingsInScope / UNFILLED_PROPS)
1686
+ const declared = declaredPropNames(this.scripts)
1687
+ const siblingScope = siblingsInScope(this.siblings, declared)
1688
+ const raw: Record<string, any> = siblingScope
1689
+ ? Object.assign(Object.create(siblingScope), data)
1690
+ : { ...data }
1691
+ const unfilled = new Set([...declared].filter(name => !(name in data)))
1692
+ if (unfilled.size) Object.defineProperty(raw, UNFILLED_PROPS, { value: unfilled })
1693
+
1694
+ const store = $reactive(raw)
1464
1695
  const fx = createEffectScope(store)
1465
1696
  this.data = store
1466
1697
  this.fx = fx
@@ -1536,7 +1767,13 @@ export class Component79 {
1536
1767
  const defer = (code: string) => `await $mounted();${code}`
1537
1768
 
1538
1769
  this.scripts.forEach((script, index) => {
1539
- const instanceHelpers = { $emit, $mounted, $self, $$self }
1770
+ // the file's other components are passed as parameters of the compiled
1771
+ // script, not just left on the store's prototype: a factory script runs
1772
+ // as plain lexical JS with no `with`, so a bare `Row` in one would
1773
+ // resolve to nothing at all. In setup mode this composes with `with` -
1774
+ // scriptScope's `has` declines any name that is a helper, so the
1775
+ // parameter is what the name resolves to
1776
+ const instanceHelpers = { $emit, $mounted, $self, $$self, ...siblingScope }
1540
1777
  const at: ScriptLocation = { filename: this.filename, index }
1541
1778
  const factoryCode = transformFactoryScript(script.content)
1542
1779
  if (factoryCode !== null) {