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/dist/jq79.cjs +16 -16
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.d.ts +5 -0
- package/dist/jq79.global.js +16 -16
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +16 -16
- package/dist/jq79.js.map +1 -1
- package/package.json +1 -1
- package/src/jq79.ts +140 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jq79",
|
|
3
|
-
"version": "0.7.
|
|
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
|
|
3355
|
-
// the bundler, and resolving one would quietly turn it into
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|