jq79 0.7.4 → 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.4",
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
@@ -3351,8 +3357,10 @@ const importResource = (url: string): Promise<any> =>
3351
3357
  // For the same reason a *root-absolute* specifier is resolved too, not passed
3352
3358
  // through: `/x.js` means the page's root to whoever wrote it, and the page is
3353
3359
  // the only base under which the two branches agree. Bare specifiers ("lodash")
3354
- // are the exception that stays untouched - they belong to the import map or
3355
- // the bundler, and resolving one would quietly turn it into a path.
3360
+ // are the exception that stays untouched here - they belong to the import map
3361
+ // or the bundler, and resolving one against a base would quietly turn it into
3362
+ // a path. A bare *.html* one still reaches the map: fetchComponent asks it,
3363
+ // since fetch() cannot (see resolveBare)
3356
3364
  //
3357
3365
  // The base is absolutized first, the way hotKey does and for the same reason:
3358
3366
  // the filename may itself be relative ("./card.html", from an import() in a
@@ -3715,9 +3723,13 @@ const UNFILLED_PROPS = Symbol("jq79.unfilledProps")
3715
3723
  // awaited $mounted() is exactly the case the count keeps quiet. Read live, so
3716
3724
  // an :if that opens after everything settled is judged against the scripts as
3717
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)
3718
3730
  const PENDING_SCRIPTS = Symbol("jq79.pendingScripts")
3719
3731
 
3720
- type PendingScripts = { count: number }
3732
+ type PendingScripts = { count: number; owner: Component79 }
3721
3733
 
3722
3734
  // default-import interop for factory scripts: real modules expose .default,
3723
3735
  // while importing an .html component resolves to the Component79 itself
@@ -3837,6 +3849,11 @@ export const hotUpdate = (filename: string, src: string): number => {
3837
3849
  // parsed once and shared by every instance - which is already what a
3838
3850
  // definition and the clones :component makes from it do
3839
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
+ })
3840
3857
  // the file is the hot-reload unit, so one reparse serves every component it
3841
3858
  // declares: an instance is handed the parts of the component it *is*, by
3842
3859
  // name. A name that is no longer in the file (a <template> renamed or
@@ -3902,16 +3919,77 @@ const warnIfStuck = (component: Component79, gates: Promise<void>[]) => {
3902
3919
  Promise.all(gates).then(() => clearTimeout(timer))
3903
3920
  }
3904
3921
 
3905
- const fetchComponent = async (url: string): Promise<Component79> => {
3922
+ // a bare specifier belongs to the import map, and native import() reads one -
3923
+ // fetch() never does, so `fetch("@app/card.html")` asks the page's directory
3924
+ // for a folder named "@app". A bare URL is resolved here through the same map,
3925
+ // with the function import() itself would use, so the component gets the URL
3926
+ // its own relative imports, its precompiled script and its HMR key resolve
3927
+ // against. That includes a relative path written without "./" ("views/x.html"):
3928
+ // it reaches the map too, and changes only if the map names it. One the map
3929
+ // does not name throws, and goes on unchanged, as it did before.
3930
+ //
3931
+ // The map is read as this module sees it: a `scopes` entry applies when it
3932
+ // covers jq79.js, not the component. Guarded because the CJS and classic-script
3933
+ // builds (jq79.cjs, jq79.global.js) have no import.meta, and there bare URLs go
3934
+ // to fetch() as they are
3935
+ const resolveBare = (url: string): string => {
3936
+ if (RESOLVABLE_SPECIFIER_RE.test(url)) return url
3937
+ const resolve = (import.meta as { resolve?: (spec: string) => string }).resolve
3938
+ if (typeof resolve !== "function") return url
3939
+ try {
3940
+ return resolve(url)
3941
+ } catch {
3942
+ return url
3943
+ }
3944
+ }
3945
+
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> =>
3906
3965
  // under safeEval()'s worker, the component's functions arrive beside it,
3907
3966
  // and both have to be in before anyone can render it
3908
- const [text] = await Promise.all([
3967
+ Promise.all([
3909
3968
  fetch(url).then(response => {
3910
3969
  if (!response.ok) throw new Error(`failed to fetch component from ${url}: ${response.status}`)
3911
3970
  return response.text()
3912
3971
  }),
3913
3972
  safeEvalWorker?.then(() => loadPrecompiled(url)),
3914
- ])
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)
3915
3993
  // the URL names the component's scripts in devtools, and is where the
3916
3994
  // browser will look for the source when a breakpoint lands in one
3917
3995
  return new Component79(text, { filename: url })
@@ -4055,6 +4133,14 @@ export class Component79 {
4055
4133
  // must not resolve on attach alone - a script awaiting it would wake to an
4056
4134
  // empty component and find nothing to query
4057
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
4058
4144
  // what this render generation's scripts asked to run when it is torn down
4059
4145
  // ($destroyed). Created on the first call, and taken by destroy() before
4060
4146
  // anything else goes, so a hook still sees the DOM and the store
@@ -4244,6 +4330,24 @@ export class Component79 {
4244
4330
  return Promise.all(steps).then(() => undefined)
4245
4331
  }
4246
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
+
4247
4351
  static fetch(url: string): PendingComponent79 {
4248
4352
  if (Array.isArray(url)) throw new TypeError("Component79.fetch takes one URL; use fetchAll for an array")
4249
4353
  return new PendingComponent79(fetchComponent(url))
@@ -4307,7 +4411,7 @@ export class Component79 {
4307
4411
  // renders, so a component nested inside itself pays for it once per level -
4308
4412
  // and the depth guard at MAX_NESTING_DEPTH only beats a RangeError while
4309
4413
  // this function stays small (see the note in docs/development.md)
4310
- Object.defineProperty(raw, PENDING_SCRIPTS, { value: { count: 0 } as PendingScripts })
4414
+ Object.defineProperty(raw, PENDING_SCRIPTS, { value: { count: 0, owner: this } as PendingScripts })
4311
4415
 
4312
4416
  const store = $reactive(raw)
4313
4417
  const fx = createEffectScope(store)
@@ -4362,6 +4466,7 @@ export class Component79 {
4362
4466
  const mounted = new Promise<void>(resolve => { resolveMounted = resolve })
4363
4467
  this.resolveMounted = resolveMounted
4364
4468
  this.renderDone = false
4469
+ this.held = 0
4365
4470
 
4366
4471
  // $destroyed(fn) runs fn when this generation is torn down: destroy(), a
4367
4472
  // re-render, a hot reload, or a parent's :if/:each removing it - never
@@ -4773,8 +4878,27 @@ export class Component79 {
4773
4878
  // whichever lands last calls this. In the ordinary synchronous flow the render
4774
4879
  // is already done and this is the attach; for a component whose first render
4775
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
4776
4884
  private settleMounted() {
4777
- 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
+ }
4778
4902
  }
4779
4903
 
4780
4904
  // detaches from the DOM while keeping all state; a later mount() re-attaches
@@ -4810,6 +4934,14 @@ export class Component79 {
4810
4934
  const hooks = this.destroyHooks
4811
4935
  this.destroyHooks = null
4812
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?.()
4813
4945
  this.detach()
4814
4946
  this.fx?.dispose()
4815
4947
  this.fx = null