jq79 0.7.5 → 0.7.6

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.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "Mini reactive component library: single-file .html components, Svelte-style setup scripts, fine-grained proxy reactivity. No compiler, no bundler, no dependencies — works from any static host.",
5
5
  "keywords": [
6
6
  "reactive",
package/src/jq79.ts CHANGED
@@ -1341,6 +1341,12 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
1341
1341
  } finally {
1342
1342
  nestingDepth--
1343
1343
  }
1344
+ // a child whose script holds its render paints later than this template:
1345
+ // its owner's $mounted() waits for it. Absent under renderComponent(),
1346
+ // where no instance owns the template
1347
+ const box: PendingScripts | undefined = (scope as any)[PENDING_SCRIPTS]
1348
+ if (box) instance.holdOwner(box.owner)
1349
+
1344
1350
  // the box holds this instance and nothing else, so appending is the whole
1345
1351
  // of it; without one, the end anchor is the only fixed point there is - and
1346
1352
  // its parentNode is the fragment before this site is inserted and the real
@@ -3717,9 +3723,13 @@ const UNFILLED_PROPS = Symbol("jq79.unfilledProps")
3717
3723
  // awaited $mounted() is exactly the case the count keeps quiet. Read live, so
3718
3724
  // an :if that opens after everything settled is judged against the scripts as
3719
3725
  // they are then, not as they were at the first paint
3726
+ //
3727
+ // It also names the instance whose render generation it belongs to, so a
3728
+ // nested component rendered anywhere in that template - an :each row, slot
3729
+ // content - can tell its owner it hasn't painted yet (see holdOwner)
3720
3730
  const PENDING_SCRIPTS = Symbol("jq79.pendingScripts")
3721
3731
 
3722
- type PendingScripts = { count: number }
3732
+ type PendingScripts = { count: number; owner: Component79 }
3723
3733
 
3724
3734
  // default-import interop for factory scripts: real modules expose .default,
3725
3735
  // while importing an .html component resolves to the Component79 itself
@@ -3839,6 +3849,11 @@ export const hotUpdate = (filename: string, src: string): number => {
3839
3849
  // parsed once and shared by every instance - which is already what a
3840
3850
  // definition and the clones :component makes from it do
3841
3851
  const parts = parseComponentString(src)
3852
+ // the next fetch of this file - a component mounted after the edit - gets the
3853
+ // edit, not the source the cache held from before it
3854
+ componentCache?.forEach((_, url) => {
3855
+ if (hotKey(url) === key) componentCache!.set(url, Promise.resolve(src))
3856
+ })
3842
3857
  // the file is the hot-reload unit, so one reparse serves every component it
3843
3858
  // declares: an instance is handed the parts of the component it *is*, by
3844
3859
  // name. A name that is no longer in the file (a <template> renamed or
@@ -3928,17 +3943,53 @@ const resolveBare = (url: string): string => {
3928
3943
  }
3929
3944
  }
3930
3945
 
3931
- const fetchComponent = async (rawUrl: string): Promise<Component79> => {
3932
- const url = resolveBare(rawUrl)
3946
+ // a component's source by the absolute URL it came from, so a file that a
3947
+ // page fetches, fetchAll()s and import()s from a dozen setup scripts is
3948
+ // downloaded once (Component79.cache). The text, not the component: each
3949
+ // fetch still hands back an instance of its own, as it always has - mounting
3950
+ // one never touches another. A promise, so two requests in flight share one
3951
+ // download; a failed one is dropped, and the next request tries again.
3952
+ // Under safeEval()'s worker the entry settles once the precompiled functions
3953
+ // are in too, so those load once per file as well
3954
+ let componentCache: Map<string, Promise<string>> | null = new Map()
3955
+
3956
+ const cacheKey = (url: string): string => {
3957
+ try {
3958
+ return new URL(url, document.baseURI).href.replace(/#.*$/, "")
3959
+ } catch {
3960
+ return url
3961
+ }
3962
+ }
3963
+
3964
+ const downloadComponent = (url: string): Promise<string> =>
3933
3965
  // under safeEval()'s worker, the component's functions arrive beside it,
3934
3966
  // and both have to be in before anyone can render it
3935
- const [text] = await Promise.all([
3967
+ Promise.all([
3936
3968
  fetch(url).then(response => {
3937
3969
  if (!response.ok) throw new Error(`failed to fetch component from ${url}: ${response.status}`)
3938
3970
  return response.text()
3939
3971
  }),
3940
3972
  safeEvalWorker?.then(() => loadPrecompiled(url)),
3941
- ])
3973
+ ]).then(([text]) => text)
3974
+
3975
+ const componentSource = (url: string): Promise<string> => {
3976
+ if (!componentCache) return downloadComponent(url)
3977
+ const key = cacheKey(url)
3978
+ let text = componentCache.get(key)
3979
+ if (!text) {
3980
+ text = downloadComponent(url)
3981
+ componentCache.set(key, text)
3982
+ const pending = text
3983
+ pending.catch(() => {
3984
+ if (componentCache?.get(key) === pending) componentCache.delete(key)
3985
+ })
3986
+ }
3987
+ return text
3988
+ }
3989
+
3990
+ const fetchComponent = async (rawUrl: string): Promise<Component79> => {
3991
+ const url = resolveBare(rawUrl)
3992
+ const text = await componentSource(url)
3942
3993
  // the URL names the component's scripts in devtools, and is where the
3943
3994
  // browser will look for the source when a breakpoint lands in one
3944
3995
  return new Component79(text, { filename: url })
@@ -4082,6 +4133,14 @@ export class Component79 {
4082
4133
  // must not resolve on attach alone - a script awaiting it would wake to an
4083
4134
  // empty component and find nothing to query
4084
4135
  private renderDone = false
4136
+ // how many components this generation's first render created that haven't
4137
+ // painted yet - a child whose script awaits an import paints after this
4138
+ // template does. $mounted() means the DOM below is there to query, so it
4139
+ // waits for them too (RECORD/2026-10-22.mounted-waits-for-children.md)
4140
+ private held = 0
4141
+ // set by the usage site of a nested instance that hadn't painted when it was
4142
+ // rendered: tells the owner it has, or that it never will (destroyed first)
4143
+ private onPaint: (() => void) | null = null
4085
4144
  // what this render generation's scripts asked to run when it is torn down
4086
4145
  // ($destroyed). Created on the first call, and taken by destroy() before
4087
4146
  // anything else goes, so a hook still sees the DOM and the store
@@ -4271,6 +4330,24 @@ export class Component79 {
4271
4330
  return Promise.all(steps).then(() => undefined)
4272
4331
  }
4273
4332
 
4333
+ // whether fetch(), fetchAll() and a setup script's import() of an .html
4334
+ // download a file once and reuse its source after that. On by default:
4335
+ //
4336
+ // Component79.cache() // true - is it on?
4337
+ // Component79.cache(false) // off, and what it held is dropped
4338
+ // Component79.clearCache() // stays on, starts empty
4339
+ //
4340
+ // Returns the setting as it stands after the call
4341
+ static cache(enabled?: boolean): boolean {
4342
+ if (enabled === false) componentCache = null
4343
+ else if (enabled === true) componentCache ??= new Map()
4344
+ return !!componentCache
4345
+ }
4346
+
4347
+ static clearCache(): void {
4348
+ componentCache?.clear()
4349
+ }
4350
+
4274
4351
  static fetch(url: string): PendingComponent79 {
4275
4352
  if (Array.isArray(url)) throw new TypeError("Component79.fetch takes one URL; use fetchAll for an array")
4276
4353
  return new PendingComponent79(fetchComponent(url))
@@ -4334,7 +4411,7 @@ export class Component79 {
4334
4411
  // renders, so a component nested inside itself pays for it once per level -
4335
4412
  // and the depth guard at MAX_NESTING_DEPTH only beats a RangeError while
4336
4413
  // this function stays small (see the note in docs/development.md)
4337
- Object.defineProperty(raw, PENDING_SCRIPTS, { value: { count: 0 } as PendingScripts })
4414
+ Object.defineProperty(raw, PENDING_SCRIPTS, { value: { count: 0, owner: this } as PendingScripts })
4338
4415
 
4339
4416
  const store = $reactive(raw)
4340
4417
  const fx = createEffectScope(store)
@@ -4389,6 +4466,7 @@ export class Component79 {
4389
4466
  const mounted = new Promise<void>(resolve => { resolveMounted = resolve })
4390
4467
  this.resolveMounted = resolveMounted
4391
4468
  this.renderDone = false
4469
+ this.held = 0
4392
4470
 
4393
4471
  // $destroyed(fn) runs fn when this generation is torn down: destroy(), a
4394
4472
  // re-render, a hot reload, or a parent's :if/:each removing it - never
@@ -4800,8 +4878,27 @@ export class Component79 {
4800
4878
  // whichever lands last calls this. In the ordinary synchronous flow the render
4801
4879
  // is already done and this is the attach; for a component whose first render
4802
4880
  // a script held back, it is the other way round
4881
+ //
4882
+ // "Rendered" includes the components the template created: one whose own
4883
+ // render a script held back counts in `held` until it paints
4803
4884
  private settleMounted() {
4804
- if (this.renderDone && this.mountRoot) this.resolveMounted?.()
4885
+ if (!this.renderDone || this.held > 0) return
4886
+ const onPaint = this.onPaint
4887
+ this.onPaint = null
4888
+ onPaint?.()
4889
+ if (this.mountRoot) this.resolveMounted?.()
4890
+ }
4891
+
4892
+ // Internal: called by the usage site right after rendering this instance
4893
+ // into `owner`'s template. One that has painted already - every synchronous
4894
+ // component - costs nothing more
4895
+ holdOwner(owner: Component79) {
4896
+ if (this.renderDone && this.held === 0) return
4897
+ owner.held++
4898
+ this.onPaint = () => {
4899
+ owner.held--
4900
+ owner.settleMounted()
4901
+ }
4805
4902
  }
4806
4903
 
4807
4904
  // detaches from the DOM while keeping all state; a later mount() re-attaches
@@ -4837,6 +4934,14 @@ export class Component79 {
4837
4934
  const hooks = this.destroyHooks
4838
4935
  this.destroyHooks = null
4839
4936
  hooks?.forEach(fn => runHook(fn, "$destroyed"))
4937
+ // before the children go: each one destroyed unpainted releases this
4938
+ // instance, and that must not resolve a generation that is going away
4939
+ this.resolveMounted = null
4940
+ // and one destroyed before it painted releases its owner, which would
4941
+ // otherwise wait for a paint that is never coming
4942
+ const onPaint = this.onPaint
4943
+ this.onPaint = null
4944
+ onPaint?.()
4840
4945
  this.detach()
4841
4946
  this.fx?.dispose()
4842
4947
  this.fx = null