jq79 0.4.11 → 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
@@ -96,13 +96,14 @@ const interpolate = (template: string, scope: Record<string, any>): string =>
96
96
  template.replace(/{{\s*([\s\S]+?)\s*}}/g, (_, expr) => evalExpr(expr, scope) ?? "")
97
97
 
98
98
 
99
- const CONTROL_ATTRS = new Set([":attrs", ":class", ":value", ":checked", ":selected", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html", ":html.allowed"])
99
+ const CONTROL_ATTRS = new Set([":attrs", ":class", ":value", ":checked", ":selected", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html", ":html.allowed", ":props"])
100
100
 
101
101
  // a control attribute is one the static-attr loop and nested-component prop
102
102
  // collection must skip. The set holds the fixed names; `:class.<name>` (the
103
- // single-flag shorthand) is open-ended, so it's matched by prefix - it can't be
104
- // enumerated into the set
105
- const isControlAttr = (attr: string): boolean => CONTROL_ATTRS.has(attr) || attr.startsWith(":class.")
103
+ // single-flag shorthand) and `:props.<n>` (one spread among several) are
104
+ // open-ended, so they're matched by prefix - they can't be enumerated into the set
105
+ const isControlAttr = (attr: string): boolean =>
106
+ CONTROL_ATTRS.has(attr) || attr.startsWith(":class.") || attr.startsWith(":props.")
106
107
  // `item in items`, `item, i in items`, `(value, key) in props` - the second
107
108
  // binding is the array index or the object key, parens optional (Vue-style).
108
109
  // The list expression can span lines, so it matches [\s\S] rather than `.`
@@ -215,6 +216,12 @@ const findComponentKey = (scope: Record<string, any>, tag: string): string | nul
215
216
  return null
216
217
  }
217
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
+
218
225
  // <MyComponent :user :title="'str'"></MyComponent> - renders a child
219
226
  // component instance at this position. Props: `:name="expr"` evaluates expr
220
227
  // in the parent scope (`:name` alone is shorthand for `:name="name"`), plain
@@ -241,10 +248,24 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
241
248
  const props: Record<string, string> = {} // prop name -> expression in parent scope
242
249
  const models: Record<string, string> = {} // model name -> assignable expression in parent scope
243
250
  const events: Array<[string, string]> = [] // @attr (modifiers included) -> handler expression
251
+ // named props AND spreads in source order - what a :props merge folds over so
252
+ // precedence follows the JS object-spread rule (later wins). `name` absent
253
+ // marks a spread: the whole object's properties, not one binding
254
+ const sources: Array<{ name?: string; expr: string }> = []
255
+ let hasSpread = false
244
256
  Object.entries(node.attrs).forEach(([attr, value]) => {
245
257
  // the parent's scope stamp is stamped on every template element, this tag
246
258
  // included - it's not a prop, and the child renders under its own scope
247
- if (attr === SCOPE_ATTR || isControlAttr(attr)) return
259
+ if (attr === SCOPE_ATTR) return
260
+ if (attr === ":props" || attr.startsWith(":props.")) {
261
+ // :props="obj" spreads obj's own properties as props; :props.<n> is one
262
+ // spread among several (the `...obj` sugar rewrites to it - see
263
+ // expandPropsSpread), the suffix only keeping the attribute names distinct
264
+ hasSpread = true
265
+ sources.push({ expr: value })
266
+ return
267
+ }
268
+ if (isControlAttr(attr)) return
248
269
  if (attr.startsWith("@")) {
249
270
  events.push([attr, value])
250
271
  } else if (attr === ":model" || attr.startsWith(":model.")) {
@@ -257,8 +278,12 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
257
278
  } else if (attr.startsWith(":")) {
258
279
  const name = kebabToCamel(attr.slice(1))
259
280
  props[name] = value || name
281
+ sources.push({ name, expr: value || name })
260
282
  } else {
261
- props[kebabToCamel(attr)] = JSON.stringify(value)
283
+ const name = kebabToCamel(attr)
284
+ const expr = JSON.stringify(value)
285
+ props[name] = expr
286
+ sources.push({ name, expr })
262
287
  }
263
288
  })
264
289
 
@@ -287,13 +312,57 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
287
312
  }
288
313
  })
289
314
 
315
+ // the full prop set the child gets, resolved in source order: each named prop
316
+ // sets one key, each spread merges an object's own properties, later sources
317
+ // overwriting earlier (the JS object-spread rule). :model bindings apply last,
318
+ // so they win - the same precedence the collision warning above promises. A
319
+ // spread expression that isn't an object contributes nothing (fail closed,
320
+ // like :with), so an `await`-pending object spreads once it resolves
321
+ const resolveProps = (): Record<string, any> => {
322
+ const out: Record<string, any> = {}
323
+ sources.forEach(({ name, expr }) => {
324
+ if (name !== undefined) out[name] = evalExpr(expr, scope)
325
+ else {
326
+ const obj = evalExpr(expr, scope)
327
+ if (obj !== null && typeof obj === "object") Object.assign(out, obj)
328
+ }
329
+ })
330
+ Object.entries(models).forEach(([name, expr]) => { out[modelProp(name)] = evalExpr(expr, scope) })
331
+ return out
332
+ }
333
+
290
334
  let current: Component79 | null = null
291
335
  let currentDef: Component79 | null = null
292
336
  let childFx: EffectScope | null = null
293
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
+
294
362
  fx.effect(() => {
295
363
  const value = evalExpr(key, scope)
296
364
  const nextDef = value instanceof Component79 ? value : null
365
+ if (!nextDef) reportUnresolved(value)
297
366
  if (nextDef === currentDef) return
298
367
 
299
368
  childFx?.dispose()
@@ -311,6 +380,11 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
311
380
  styles: nextDef.styles,
312
381
  modules: nextDef.modules,
313
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,
314
388
  })
315
389
  // the writeback half of :model - one event, one contract. The name is
316
390
  // normalized like the attribute was (kebab->camel; absent means default),
@@ -352,20 +426,53 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
352
426
  // explicit re-emit)
353
427
  events.forEach(([attr, expr]) => wireTagEvent(instance, attr, expr, scope))
354
428
 
355
- const seed = untracked(() =>
356
- Object.fromEntries(Object.entries(props).map(([name, expr]) => [name, evalExpr(expr, scope)]))
357
- )
429
+ const seed = untracked(resolveProps)
358
430
  // mounting into a fragment attaches no shadow root of its own: a
359
431
  // shadow-rendered child keeps its <style> elements inline, next to the DOM
360
432
  // they style, and the parent's shadow root is what scopes both
361
433
  const holder = document.createDocumentFragment()
362
- ;(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
+ }
363
452
  endAnchor.parentNode!.insertBefore(holder, endAnchor)
364
453
 
365
454
  const syncFx = createEffectScope(scope)
366
- Object.entries(props).forEach(([name, expr]) => {
367
- syncFx.effect(() => { (instance.data as Record<string, any>)[name] = evalExpr(expr, scope) })
368
- })
455
+ // without a spread the prop set is fixed and known: one effect per prop, so
456
+ // a change to one prop re-syncs only that prop. A spread's key set is
457
+ // dynamic and its precedence is positional, so it can't be resolved a key at
458
+ // a time across independent effects (whichever re-ran last would win) - one
459
+ // effect re-merges everything in order and writes the diff, clearing keys a
460
+ // spread has dropped since last run. Named props are always in the merge, so
461
+ // they're never cleared; the extra cost is confined to spread-using tags
462
+ if (hasSpread) {
463
+ let written: string[] = []
464
+ syncFx.effect(() => {
465
+ const next = resolveProps()
466
+ const nextKeys = Object.keys(next)
467
+ written.forEach(key => { if (!(key in next)) (instance.data as Record<string, any>)[key] = undefined })
468
+ nextKeys.forEach(key => { (instance.data as Record<string, any>)[key] = next[key] })
469
+ written = nextKeys
470
+ })
471
+ } else {
472
+ Object.entries(props).forEach(([name, expr]) => {
473
+ syncFx.effect(() => { (instance.data as Record<string, any>)[name] = evalExpr(expr, scope) })
474
+ })
475
+ }
369
476
 
370
477
  childFx = syncFx
371
478
  current = instance
@@ -835,6 +942,15 @@ type ComponentParts = {
835
942
  // where this component came from (a URL for fetch(), a path for the vite
836
943
  // plugin). Names the setup scripts in devtools - see scriptSourceUrl
837
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
838
954
  }
839
955
 
840
956
  const VOID_ELEMENTS = new Set([
@@ -864,6 +980,41 @@ const expandSelfClosingTags = (src: string): string =>
864
980
  )
865
981
  .join("")
866
982
 
983
+ // a start tag with its attributes, quote-aware so a ">" inside a value doesn't
984
+ // end it early; and a single spread attribute in name position (preceded by
985
+ // start-or-whitespace), its expression an identifier or member path
986
+ const OPEN_TAG_RE = /<([A-Za-z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g
987
+ const ATTR_SPREAD_RE = /"[^"]*"|'[^']*'|(^|\s)\.\.\.([A-Za-z_$][\w$.]*)/g
988
+
989
+ // `...expr` as an attribute is sugar for :props="expr" (spread an object's
990
+ // properties as props - see renderNestedComponent). Rewritten BEFORE DOM
991
+ // parsing, into a value-based :props.<n>, because the HTML parser lowercases
992
+ // attribute *names*: with the expression in the name, `...userData` would arrive
993
+ // as `...userdata` and resolve to nothing. Moving it into a value - which the
994
+ // parser leaves untouched - keeps camelCase intact. Same pre-parse string move
995
+ // as expandSelfClosingTags, with the same defenses against rewriting code that
996
+ // only looks like a spread: <script>/<style> bodies are split out (a JS `...rest`
997
+ // there is not an attribute), only a start tag's interior is scanned (text
998
+ // between tags is safe), and quoted values are consumed whole so a genuine JS
999
+ // spread in a value (@click="f(...args)", :x="{ ...a }") is skipped. The <n>
1000
+ // suffix (per tag) only keeps several spreads' attribute names distinct. A call
1001
+ // (`...getProps()`) stops at the paren and is left alone - use :props="expr()"
1002
+ const expandPropsSpread = (src: string): string =>
1003
+ src
1004
+ .split(RAW_BLOCK_RE)
1005
+ .map((chunk, i) =>
1006
+ i % 2 === 1
1007
+ ? chunk
1008
+ : chunk.replace(OPEN_TAG_RE, (_match, tag: string, attrs: string) => {
1009
+ let n = 0
1010
+ const rewritten = attrs.replace(ATTR_SPREAD_RE, (whole, space: string | undefined, expr: string | undefined) =>
1011
+ expr === undefined ? whole : `${space}:props.${n++}="${expr}"`
1012
+ )
1013
+ return `<${tag}${rewritten}>`
1014
+ })
1015
+ )
1016
+ .join("")
1017
+
867
1018
  // <style scoped> support. Every element of the component's own template is
868
1019
  // stamped with data-jq79="<hash>" and the style's selectors are rewritten to
869
1020
  // require that attribute, so its rules can't reach anything the component
@@ -926,9 +1077,16 @@ const scopeCss = (css: string, scope: string): string => {
926
1077
  return Array.from(sheet.cssRules).map(rule => rule.cssText).join("\n")
927
1078
  }
928
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
+
929
1086
  // converts a string of HTML into an AST representation of the component:
930
1087
  // - template: the non-script/style top-level elements, as TemplateNodes
931
1088
  // - scripts/styles: { attrs, content } blocks in source order
1089
+ // - siblings: the components its top-level <template name="..."> declared
932
1090
  const parseComponentString = (component: string): ComponentParts => {
933
1091
  // example
934
1092
  // <script :setup="{ fname, lname }">
@@ -947,15 +1105,71 @@ const parseComponentString = (component: string): ComponentParts => {
947
1105
  // </style>
948
1106
 
949
1107
  // parsed as the content of a <template> so leading <script>/<style> tags
950
- // aren't reparented into <head> by the HTML parser
951
- const parsedDOM = new DOMParser().parseFromString(`<template>${expandSelfClosingTags(component)}</template>`, "text/html")
1108
+ // aren't reparented into <head> by the HTML parser. Both pre-DOM string
1109
+ // rewrites run here: `...expr` -> :props.<n>="expr" first (it reads the raw
1110
+ // camelCase before the parser can lowercase names), then self-closing tags
1111
+ const prepared = expandSelfClosingTags(expandPropsSpread(component))
1112
+ const parsedDOM = new DOMParser().parseFromString(`<template>${prepared}</template>`, "text/html")
952
1113
  const root = parsedDOM.querySelector("template") as HTMLTemplateElement
953
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 => {
954
1168
  const scripts: TagBlock[] = []
955
1169
  const styles: TagBlock[] = []
956
1170
  const template: TemplateNode[] = []
957
1171
 
958
- Array.from(root.content.children).forEach(el => {
1172
+ elements.forEach(el => {
959
1173
  const block: TagBlock = { attrs: elementAttrs(el), content: el.textContent ?? "" }
960
1174
 
961
1175
  if (el.tagName === "SCRIPT") scripts.push(block)
@@ -982,7 +1196,7 @@ const parseComponentString = (component: string): ComponentParts => {
982
1196
  // in something that isn't CSS yet would only garble what devtools shows
983
1197
  const isScoped = (style: TagBlock) => "scoped" in style.attrs && !("lang" in style.attrs)
984
1198
  if (styles.some(isScoped)) {
985
- const scope = scopeHash(component)
1199
+ const scope = scopeHash(hashSource)
986
1200
  stampScope(template, scope)
987
1201
  styles.forEach(style => {
988
1202
  if (isScoped(style)) style.scoped = scopeCss(style.content, scope)
@@ -1101,6 +1315,50 @@ const declareProps = (store: Record<string, any>, props: PropDecl[] | null) => {
1101
1315
  })
1102
1316
  }
1103
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
+
1104
1362
  // default-import interop for factory scripts: real modules expose .default,
1105
1363
  // while importing an .html component resolves to the Component79 itself
1106
1364
  const interopDefault = (mod: any) => (mod && mod.default !== undefined ? mod.default : mod)
@@ -1205,6 +1463,13 @@ export const hotUpdate = (filename: string, src: string): number => {
1205
1463
  // parsed once and shared by every instance - which is already what a
1206
1464
  // definition and the clones :component makes from it do
1207
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
1208
1473
 
1209
1474
  let rerendered = 0
1210
1475
  for (const [name, refs] of hotRegistry) {
@@ -1215,11 +1480,16 @@ export const hotUpdate = (filename: string, src: string): number => {
1215
1480
  refs.delete(ref) // collected since the last update
1216
1481
  continue
1217
1482
  }
1218
- 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++
1219
1489
  }
1220
1490
  if (!refs.size) hotRegistry.delete(name)
1221
1491
  }
1222
- return rerendered
1492
+ return orphaned ? 0 : rerendered
1223
1493
  }
1224
1494
 
1225
1495
  // starts tracking instances, so hotUpdate can find them. jq79/dev's client
@@ -1232,6 +1502,14 @@ export const enableHotReload = (): void => {
1232
1502
 
1233
1503
  type EmitListener = (event: CustomEvent, payload: any) => void
1234
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
+
1235
1513
  // a parsed single-file component. Typical lifecycle:
1236
1514
  //
1237
1515
  // const jq79 = new Component79(src) // or await Component79.fetch(url)
@@ -1249,6 +1527,14 @@ export class Component79 {
1249
1527
  modules?: Record<string, any>
1250
1528
  // the component's origin, used to name its scripts in devtools
1251
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
1252
1538
 
1253
1539
  data: ReactiveDeepData<Record<string, any>> | null = null
1254
1540
 
@@ -1280,9 +1566,29 @@ export class Component79 {
1280
1566
  this.styles = parts.styles
1281
1567
  this.modules = options.modules ?? (typeof src === "string" ? undefined : src.modules)
1282
1568
  this.filename = options.filename ?? (typeof src === "string" ? undefined : src.filename)
1569
+ this.siblings = parts.siblings
1570
+ this.name = parts.name
1571
+ this.adoptSiblings()
1283
1572
  hotRegister(this) // a no-op unless the page enabled hot reload
1284
1573
  }
1285
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
+
1286
1592
  // swaps this component's parsed parts for `src`'s and, if it is on the page,
1287
1593
  // re-renders it where it stands - seeded with a snapshot of its data, so
1288
1594
  // props and store values survive (the setup script runs again, so whatever it
@@ -1318,6 +1624,11 @@ export class Component79 {
1318
1624
  this.template = parts.template
1319
1625
  this.scripts = parts.scripts
1320
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()
1321
1632
  if (!rendered) return false // a definition: its clones re-render themselves
1322
1633
 
1323
1634
  this.renderWith(data, shadow)
@@ -1332,12 +1643,13 @@ export class Component79 {
1332
1643
  return true
1333
1644
  }
1334
1645
 
1335
- static async fetch(url: string): Promise<Component79> {
1336
- const response = await fetch(url)
1337
- if (!response.ok) throw new Error(`failed to fetch component from ${url}: ${response.status}`)
1338
- // the URL names the component's scripts in devtools, and is where the
1339
- // browser will look for the source when a breakpoint lands in one
1340
- 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)
1341
1653
  }
1342
1654
 
1343
1655
  // subscribes to this instance's $emit events, on top of the DOM CustomEvent
@@ -1368,7 +1680,18 @@ export class Component79 {
1368
1680
  private renderWith(data: Record<string, any>, shadow: boolean): this {
1369
1681
  this.destroy()
1370
1682
 
1371
- 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)
1372
1695
  const fx = createEffectScope(store)
1373
1696
  this.data = store
1374
1697
  this.fx = fx
@@ -1444,7 +1767,13 @@ export class Component79 {
1444
1767
  const defer = (code: string) => `await $mounted();${code}`
1445
1768
 
1446
1769
  this.scripts.forEach((script, index) => {
1447
- 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 }
1448
1777
  const at: ScriptLocation = { filename: this.filename, index }
1449
1778
  const factoryCode = transformFactoryScript(script.content)
1450
1779
  if (factoryCode !== null) {