jq79 0.7.3 → 0.7.5

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.
@@ -53,4 +53,8 @@ export type EffectScope = {
53
53
  dispose: () => void;
54
54
  };
55
55
  export declare const createEffectScope: (scope: Record<string, any>, deep?: boolean) => EffectScope;
56
+ export type Computed<T> = ReactiveDeepData<{
57
+ readonly value: T;
58
+ }>;
59
+ export declare const $computed: <T>(get: () => T) => Computed<T>;
56
60
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jq79",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
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
@@ -1,7 +1,7 @@
1
1
 
2
2
  import { $, $$, $create, sanitizeHTML, allowedHosts } from "./dom"
3
3
  import type { AllowUrl, QueryAll, QueryOne } from "./dom"
4
- import { $reactive, $toRaw, untracked, createEffectScope, ALSO_WAKEN_BY, OWNER } from "./reactive"
4
+ import { $reactive, $toRaw, $computed, untracked, createEffectScope, ALSO_WAKEN_BY, OWNER } from "./reactive"
5
5
  import type { ReactiveDeepData, EffectScope } from "./reactive"
6
6
  import { transformSetupScript, transformFactoryScript, parsePropsPattern, parseFactoryProps, type PropDecl } from "./transform"
7
7
  import {
@@ -14,7 +14,8 @@ import {
14
14
 
15
15
  export { $, $$, $create } from "./dom"
16
16
  export type { QueryOne, QueryAll } from "./dom"
17
- export { $reactive, $toRaw } from "./reactive"
17
+ export { $reactive, $toRaw, $computed } from "./reactive"
18
+ export type { Computed } from "./reactive"
18
19
 
19
20
  // the package version, substituted at build time (tsup/vitest `define`, read
20
21
  // from package.json - releases bump it there and nowhere else). The typeof
@@ -3350,8 +3351,10 @@ const importResource = (url: string): Promise<any> =>
3350
3351
  // For the same reason a *root-absolute* specifier is resolved too, not passed
3351
3352
  // through: `/x.js` means the page's root to whoever wrote it, and the page is
3352
3353
  // the only base under which the two branches agree. Bare specifiers ("lodash")
3353
- // are the exception that stays untouched - they belong to the import map or
3354
- // the bundler, and resolving one would quietly turn it into a path.
3354
+ // are the exception that stays untouched here - they belong to the import map
3355
+ // or the bundler, and resolving one against a base would quietly turn it into
3356
+ // a path. A bare *.html* one still reaches the map: fetchComponent asks it,
3357
+ // since fetch() cannot (see resolveBare)
3355
3358
  //
3356
3359
  // The base is absolutized first, the way hotKey does and for the same reason:
3357
3360
  // the filename may itself be relative ("./card.html", from an import() in a
@@ -3901,7 +3904,32 @@ const warnIfStuck = (component: Component79, gates: Promise<void>[]) => {
3901
3904
  Promise.all(gates).then(() => clearTimeout(timer))
3902
3905
  }
3903
3906
 
3904
- const fetchComponent = async (url: string): Promise<Component79> => {
3907
+ // a bare specifier belongs to the import map, and native import() reads one -
3908
+ // fetch() never does, so `fetch("@app/card.html")` asks the page's directory
3909
+ // for a folder named "@app". A bare URL is resolved here through the same map,
3910
+ // with the function import() itself would use, so the component gets the URL
3911
+ // its own relative imports, its precompiled script and its HMR key resolve
3912
+ // against. That includes a relative path written without "./" ("views/x.html"):
3913
+ // it reaches the map too, and changes only if the map names it. One the map
3914
+ // does not name throws, and goes on unchanged, as it did before.
3915
+ //
3916
+ // The map is read as this module sees it: a `scopes` entry applies when it
3917
+ // covers jq79.js, not the component. Guarded because the CJS and classic-script
3918
+ // builds (jq79.cjs, jq79.global.js) have no import.meta, and there bare URLs go
3919
+ // to fetch() as they are
3920
+ const resolveBare = (url: string): string => {
3921
+ if (RESOLVABLE_SPECIFIER_RE.test(url)) return url
3922
+ const resolve = (import.meta as { resolve?: (spec: string) => string }).resolve
3923
+ if (typeof resolve !== "function") return url
3924
+ try {
3925
+ return resolve(url)
3926
+ } catch {
3927
+ return url
3928
+ }
3929
+ }
3930
+
3931
+ const fetchComponent = async (rawUrl: string): Promise<Component79> => {
3932
+ const url = resolveBare(rawUrl)
3905
3933
  // under safeEval()'s worker, the component's functions arrive beside it,
3906
3934
  // and both have to be in before anyone can render it
3907
3935
  const [text] = await Promise.all([
@@ -4054,6 +4082,17 @@ export class Component79 {
4054
4082
  // must not resolve on attach alone - a script awaiting it would wake to an
4055
4083
  // empty component and find nothing to query
4056
4084
  private renderDone = false
4085
+ // what this render generation's scripts asked to run when it is torn down
4086
+ // ($destroyed). Created on the first call, and taken by destroy() before
4087
+ // anything else goes, so a hook still sees the DOM and the store
4088
+ private destroyHooks: (() => void)[] | null = null
4089
+ // this generation's $attached / $detached hooks, and whether it has been
4090
+ // told it is on the page. Only an instance that registered one of them is
4091
+ // in pageWatchers, which is what keeps a page that uses neither from paying
4092
+ // for a walk on every mount (see settleAttached)
4093
+ private attachHooks: (() => void)[] | null = null
4094
+ private detachHooks: (() => void)[] | null = null
4095
+ private onPage = false
4057
4096
  // instance-level listeners for $emit events, registered with on(). Kept
4058
4097
  // outside the render generation so they survive re-render and destroy()
4059
4098
  private emitListeners = new Map<string, Set<EmitListener>>()
@@ -4351,6 +4390,55 @@ export class Component79 {
4351
4390
  this.resolveMounted = resolveMounted
4352
4391
  this.renderDone = false
4353
4392
 
4393
+ // $destroyed(fn) runs fn when this generation is torn down: destroy(), a
4394
+ // re-render, a hot reload, or a parent's :if/:each removing it - never
4395
+ // detach(), which keeps state for a mount() to resume. Registered after
4396
+ // the generation is gone (a script that awaited past its own destroy), it
4397
+ // runs at once: holding it for a destroy that already happened would leak
4398
+ // exactly what it was written to stop. Returns the unregister
4399
+ const $destroyed = (fn: () => void): (() => void) => {
4400
+ if (typeof fn !== "function") throw new TypeError("jq79: $destroyed expects a function")
4401
+ if (marker !== this.startMarker) {
4402
+ runHook(fn, "$destroyed")
4403
+ return () => {}
4404
+ }
4405
+ return addHook((this.destroyHooks ??= []), fn)
4406
+ }
4407
+ // $attached(fn) runs fn every time this generation goes onto the page -
4408
+ // in the document, rendered - the first mount included; $detached(fn)
4409
+ // every time it leaves, and only after an $attached it balances. A nested
4410
+ // component hears its root's mount and detach (see settleAttached and
4411
+ // notifyDetached). `immediate`, on by default, also runs fn now when the
4412
+ // component is on the page already, so "while I'm on the page" holds
4413
+ // wherever the line sits - above `await $mounted()` or below it. A late
4414
+ // $detached runs at once, as a late $destroyed does; a late $attached has
4415
+ // nothing left to attach
4416
+ const $attached = (fn: () => void, { immediate = true }: { immediate?: boolean } = {}): (() => void) => {
4417
+ if (typeof fn !== "function") throw new TypeError("jq79: $attached expects a function")
4418
+ if (marker !== this.startMarker) return () => {}
4419
+ this.watchPage()
4420
+ const off = addHook((this.attachHooks ??= []), fn)
4421
+ if (immediate && this.onPage) runHook(fn, "$attached")
4422
+ return off
4423
+ }
4424
+ const $detached = (fn: () => void): (() => void) => {
4425
+ if (typeof fn !== "function") throw new TypeError("jq79: $detached expects a function")
4426
+ if (marker !== this.startMarker) {
4427
+ runHook(fn, "$detached")
4428
+ return () => {}
4429
+ }
4430
+ this.watchPage()
4431
+ return addHook((this.detachHooks ??= []), fn)
4432
+ }
4433
+ // the library's $computed, disposed with this generation: one made over a
4434
+ // shared store is otherwise held by that store, and kept recomputing, for
4435
+ // as long as the store lives
4436
+ const $instanceComputed = <T>(get: () => T) => {
4437
+ const computed = $computed(get)
4438
+ $destroyed(() => computed.$dispose())
4439
+ return computed
4440
+ }
4441
+
4354
4442
  // $self / $$self mirror $ / $$ but only search this instance's own
4355
4443
  // output: the sibling nodes between its markers. They work detached too
4356
4444
  // (the holding fragment keeps markers and rendered nodes as siblings),
@@ -4451,7 +4539,7 @@ export class Component79 {
4451
4539
  // resolve to nothing at all. In setup mode this composes with `with` -
4452
4540
  // scriptScope's `has` declines any name that is a helper, so the
4453
4541
  // parameter is what the name resolves to
4454
- const instanceHelpers = { $mounted, $self, $$self, ...injected, ...siblingScope }
4542
+ const instanceHelpers = { $mounted, $destroyed, $attached, $detached, $computed: $instanceComputed, $self, $$self, ...injected, ...siblingScope }
4455
4543
  const at: ScriptLocation = { filename: this.filename, index }
4456
4544
  const deferred = ":mounted" in script.attrs
4457
4545
  const factoryCode = transformFactoryScript(script.content)
@@ -4541,6 +4629,8 @@ export class Component79 {
4541
4629
  this.endMarker!.parentNode!.insertBefore(renderNodes(this.template, templateScope, fx, shadow), this.endMarker!)
4542
4630
  this.renderDone = true
4543
4631
  this.settleMounted()
4632
+ // a held render that paints on the page is this component's attach
4633
+ Component79.settleAttached()
4544
4634
  })
4545
4635
  warnIfStuck(this, gates)
4546
4636
  }
@@ -4603,9 +4693,87 @@ export class Component79 {
4603
4693
  root.appendChild(this.content!)
4604
4694
  this.mountRoot = root
4605
4695
  this.settleMounted()
4696
+ Component79.settleAttached()
4606
4697
  return this
4607
4698
  }
4608
4699
 
4700
+ // whether this generation's DOM is in the document, template built - the
4701
+ // one meaning "on the page" has for $attached. Not "attach() was called": a
4702
+ // nested component is mounted into a fragment its parent inserts later, and
4703
+ // a root can be mounted into an element that isn't in the document
4704
+ private isOnPage(): boolean {
4705
+ return this.renderDone && this.startMarker?.isConnected === true
4706
+ }
4707
+
4708
+ // joins pageWatchers on the first $attached/$detached of a generation, with
4709
+ // the state as it is now: a hook registered while on the page must not fire
4710
+ // for an attach that already happened. Off the page, a settle is queued: a
4711
+ // component inserted by its parent's :if, :each or tag is built in a
4712
+ // fragment and put on the page later on this same stack, with no attach()
4713
+ // of its own to say so - one microtask late, never early
4714
+ private watchPage() {
4715
+ if (pageWatchers.has(this)) return
4716
+ pageWatchers.add(this)
4717
+ this.onPage = this.isOnPage()
4718
+ if (!this.onPage) Component79.queueSettle()
4719
+ }
4720
+
4721
+ private static queueSettle() {
4722
+ if (settleQueued) return
4723
+ settleQueued = true
4724
+ queueMicrotask(() => {
4725
+ settleQueued = false
4726
+ Component79.settleAttached()
4727
+ })
4728
+ }
4729
+
4730
+ // tells every watcher that is on the page and hasn't been told, in document
4731
+ // order - a parent before what it rendered, slot content included, because
4732
+ // the DOM is what says where a component is
4733
+ private static settleAttached() {
4734
+ if (pageWatchers.size === 0) return
4735
+ const due = [...pageWatchers].filter(component => !component.onPage && component.isOnPage())
4736
+ Component79.inDocumentOrder(due).forEach(component => {
4737
+ // a hook that ran before this one may have destroyed it
4738
+ if (component.onPage || !pageWatchers.has(component)) return
4739
+ component.onPage = true
4740
+ component.attachHooks?.slice().forEach(fn => runHook(fn, "$attached"))
4741
+ })
4742
+ }
4743
+
4744
+ // tells this component and every watcher whose DOM is under its markers
4745
+ // that they are leaving the page - before anything moves, so the hooks see
4746
+ // the DOM where it was. One whose DOM already left without it (an :if
4747
+ // removes its branch, then destroys what was in it) has only itself to tell
4748
+ private notifyDetached() {
4749
+ if (pageWatchers.size === 0) return
4750
+ let due: Component79[] = []
4751
+ if (this.startMarker?.isConnected) {
4752
+ const top = new Set<Node>()
4753
+ for (let node: Node | null = this.startMarker; node; node = node.nextSibling) {
4754
+ top.add(node)
4755
+ if (node === this.endMarker) break
4756
+ }
4757
+ const under = (node: Node | null): boolean => {
4758
+ for (; node; node = node.parentNode) if (top.has(node)) return true
4759
+ return false
4760
+ }
4761
+ due = Component79.inDocumentOrder([...pageWatchers].filter(component => component.onPage && under(component.startMarker)))
4762
+ } else if (this.onPage) {
4763
+ due = [this]
4764
+ }
4765
+ due.forEach(component => {
4766
+ if (!component.onPage) return
4767
+ component.onPage = false
4768
+ component.detachHooks?.slice().forEach(fn => runHook(fn, "$detached"))
4769
+ })
4770
+ }
4771
+
4772
+ private static inDocumentOrder(components: Component79[]): Component79[] {
4773
+ return components.sort((a, b) =>
4774
+ a.startMarker!.compareDocumentPosition(b.startMarker!) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1)
4775
+ }
4776
+
4609
4777
  // where a shadow-rendered component's styles go: <style> elements ahead of
4610
4778
  // its content, as always - or, under safe mode, sheets adopted by the shadow
4611
4779
  // root. Built on first placement, since only then is there a root; one that
@@ -4640,6 +4808,7 @@ export class Component79 {
4640
4808
  // with any updates that happened while detached already applied
4641
4809
  detach(): this {
4642
4810
  if (!this.mountRoot || !this.content || !this.startMarker || !this.endMarker) return this
4811
+ this.notifyDetached()
4643
4812
 
4644
4813
  // move everything between the markers (inclusive) back into the holding
4645
4814
  // fragment - including nodes :if/:each inserted after mounting
@@ -4656,6 +4825,18 @@ export class Component79 {
4656
4825
  }
4657
4826
 
4658
4827
  destroy(): this {
4828
+ // first, while everything they might read is still there: leaving the
4829
+ // page ($detached, for this component and what it rendered), then going
4830
+ // ($destroyed). Taken before running, so a hook that destroys again (or
4831
+ // re-renders) finds none
4832
+ this.notifyDetached()
4833
+ pageWatchers.delete(this)
4834
+ this.attachHooks = null
4835
+ this.detachHooks = null
4836
+ this.onPage = false
4837
+ const hooks = this.destroyHooks
4838
+ this.destroyHooks = null
4839
+ hooks?.forEach(fn => runHook(fn, "$destroyed"))
4659
4840
  this.detach()
4660
4841
  this.fx?.dispose()
4661
4842
  this.fx = null
@@ -4789,6 +4970,30 @@ export type Component<P = any, E extends string = never, S extends string = neve
4789
4970
 
4790
4971
  export const parseComponent = (component: string): Component79 => new Component79(component)
4791
4972
 
4973
+ // one broken hook must not leave the others' timers running, or the
4974
+ // teardown half done: reported, and the rest still run
4975
+ const runHook = (fn: () => void, name: string) => {
4976
+ try {
4977
+ fn()
4978
+ } catch (error) {
4979
+ console.error(`jq79: error in a ${name} hook`, error)
4980
+ }
4981
+ }
4982
+
4983
+ // registers fn on a generation's hook list; the returned function takes it off
4984
+ const addHook = (hooks: (() => void)[], fn: () => void): (() => void) => {
4985
+ hooks.push(fn)
4986
+ return () => {
4987
+ const at = hooks.indexOf(fn)
4988
+ if (at !== -1) hooks.splice(at, 1)
4989
+ }
4990
+ }
4991
+
4992
+ // the instances with an $attached or $detached hook, in this generation -
4993
+ // the only ones a mount or a detach has to look at (see settleAttached)
4994
+ const pageWatchers = new Set<Component79>()
4995
+ let settleQueued = false
4996
+
4792
4997
  // library helpers injected into setup scripts. They behave like extra
4793
4998
  // globals: a same-named scope property (render data or a top-level
4794
4999
  // declaration) shadows them. Their names, in this order, are also
package/src/precompile.ts CHANGED
@@ -175,9 +175,10 @@ const precompileComponent = (
175
175
 
176
176
  // the helper names a script is compiled with: renderWith's
177
177
  // { ...SETUP_HELPERS, ...instanceHelpers }, where instanceHelpers is
178
- // { $mounted, $self, $$self, ...injected, ...siblingScope } - key order
179
- // included, because the parameters are positional (built as objects, so a
180
- // sibling named like a helper keeps the helper's place, as it does there)
178
+ // { $mounted, $destroyed, $attached, $detached, $computed, $self, $$self,
179
+ // ...injected, ...siblingScope } - key order included, because the
180
+ // parameters are positional (built as objects, so a sibling named like a
181
+ // helper keeps the helper's place, as it does there)
181
182
  //
182
183
  // Reading the signatures is also the first thing renderWith does, and where
183
184
  // it refuses a component - a factory destructuring a ctx name out of props
package/src/reactive.ts CHANGED
@@ -1200,3 +1200,32 @@ class Scope implements EffectScope {
1200
1200
  }
1201
1201
 
1202
1202
  export const createEffectScope = (scope: Record<string, any>, deep = false): EffectScope => new Scope(scope, deep)
1203
+
1204
+ // a derived value: `get`'s result on `.value`, recomputed whenever anything it
1205
+ // read changes - in any store, the way any effect is woken. What comes back is
1206
+ // a store, so it does everything one does (passed as a prop, held in another
1207
+ // store and bridged there, $on("value")), seen through a read-only view: a
1208
+ // write would be overwritten by the next recompute, so it is refused aloud
1209
+ // instead (see RECORD/2026-10-01.computed.md)
1210
+ export type Computed<T> = ReactiveDeepData<{ readonly value: T }>
1211
+
1212
+ export const $computed = <T>(get: () => T): Computed<T> => {
1213
+ const box = $reactive({ value: undefined as T })
1214
+ box.$effect(() => {
1215
+ let value: T
1216
+ try {
1217
+ value = get()
1218
+ } catch (error) {
1219
+ // left to propagate, it would throw out of whatever write woke it -
1220
+ // `cart.items.push(x)` failing for code that has nothing to do with it
1221
+ console.error("jq79: error in $computed, keeping its last value", error)
1222
+ return
1223
+ }
1224
+ box.value = value
1225
+ })
1226
+ const refuse = (_target: object, key: string | symbol): boolean => {
1227
+ console.warn(`jq79: a $computed is read-only - the write to ${String(key)} was ignored`)
1228
+ return true
1229
+ }
1230
+ return new Proxy(box, { set: refuse, deleteProperty: refuse, defineProperty: refuse }) as Computed<T>
1231
+ }
package/src/source.ts CHANGED
@@ -364,12 +364,13 @@ export const declaredPropNames = (scripts: TagBlock[], signature: (script: TagBl
364
364
  // the names every setup and factory script is compiled with, in the order
365
365
  // renderWith passes them: SETUP_HELPERS (whose values live in jq79.ts, beside
366
366
  // the functions they are) and then the per-instance ones it builds -
367
- // $mounted, $self and $$self, then the injected $emit, $updateModel and
368
- // $slots. They are positional parameters, so the order is part of what
367
+ // $mounted, $destroyed, $attached, $detached, $computed, $self and $$self,
368
+ // then the injected
369
+ // $emit, $updateModel and $slots. They are positional parameters, so the order is part of what
369
370
  // precompile has to reproduce; change one side and precompiled scripts stop
370
371
  // matching, which tests/precompile.test.ts and `npm run check:precompile` catch
371
372
  export const SETUP_HELPER_NAMES = ["$", "$$", "$create", "$reactive", "$toRaw", "Component79"]
372
- export const INSTANCE_HELPER_NAMES = ["$mounted", "$self", "$$self", "$emit", "$updateModel", "$slots"]
373
+ export const INSTANCE_HELPER_NAMES = ["$mounted", "$destroyed", "$attached", "$detached", "$computed", "$self", "$$self", "$emit", "$updateModel", "$slots"]
373
374
 
374
375
  // the global a precompiled script leaves its functions on, for the runtime to
375
376
  // drain: (self.__jq79precompiled = self.__jq79precompiled || []).push([params,
package/src/transform.ts CHANGED
@@ -401,7 +401,7 @@ export const transformSetupScript = (src: string): SetupTransform => {
401
401
  // plain lexical module instead of a `with`-scoped setup script: no implicit
402
402
  // reactivity, no `$:` labels - standard JS that editors and type-checkers
403
403
  // understand. The default export is called with the instance context
404
- // ({ $data, $effect, $emit, $mounted, $self, $$self }) and a returned object
404
+ // ({ $data, $effect, $emit, $mounted, $destroyed, $attached, $detached, $computed, $self, $$self }) and a returned object
405
405
  // is merged into the reactive store for the template to use.
406
406
  // Detection is backwards-safe: `export default` is a SyntaxError inside a
407
407
  // setup script, so no previously-working component can change behavior.