jq79 0.7.3 → 0.7.4

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.4",
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
@@ -4054,6 +4055,17 @@ export class Component79 {
4054
4055
  // must not resolve on attach alone - a script awaiting it would wake to an
4055
4056
  // empty component and find nothing to query
4056
4057
  private renderDone = false
4058
+ // what this render generation's scripts asked to run when it is torn down
4059
+ // ($destroyed). Created on the first call, and taken by destroy() before
4060
+ // anything else goes, so a hook still sees the DOM and the store
4061
+ private destroyHooks: (() => void)[] | null = null
4062
+ // this generation's $attached / $detached hooks, and whether it has been
4063
+ // told it is on the page. Only an instance that registered one of them is
4064
+ // in pageWatchers, which is what keeps a page that uses neither from paying
4065
+ // for a walk on every mount (see settleAttached)
4066
+ private attachHooks: (() => void)[] | null = null
4067
+ private detachHooks: (() => void)[] | null = null
4068
+ private onPage = false
4057
4069
  // instance-level listeners for $emit events, registered with on(). Kept
4058
4070
  // outside the render generation so they survive re-render and destroy()
4059
4071
  private emitListeners = new Map<string, Set<EmitListener>>()
@@ -4351,6 +4363,55 @@ export class Component79 {
4351
4363
  this.resolveMounted = resolveMounted
4352
4364
  this.renderDone = false
4353
4365
 
4366
+ // $destroyed(fn) runs fn when this generation is torn down: destroy(), a
4367
+ // re-render, a hot reload, or a parent's :if/:each removing it - never
4368
+ // detach(), which keeps state for a mount() to resume. Registered after
4369
+ // the generation is gone (a script that awaited past its own destroy), it
4370
+ // runs at once: holding it for a destroy that already happened would leak
4371
+ // exactly what it was written to stop. Returns the unregister
4372
+ const $destroyed = (fn: () => void): (() => void) => {
4373
+ if (typeof fn !== "function") throw new TypeError("jq79: $destroyed expects a function")
4374
+ if (marker !== this.startMarker) {
4375
+ runHook(fn, "$destroyed")
4376
+ return () => {}
4377
+ }
4378
+ return addHook((this.destroyHooks ??= []), fn)
4379
+ }
4380
+ // $attached(fn) runs fn every time this generation goes onto the page -
4381
+ // in the document, rendered - the first mount included; $detached(fn)
4382
+ // every time it leaves, and only after an $attached it balances. A nested
4383
+ // component hears its root's mount and detach (see settleAttached and
4384
+ // notifyDetached). `immediate`, on by default, also runs fn now when the
4385
+ // component is on the page already, so "while I'm on the page" holds
4386
+ // wherever the line sits - above `await $mounted()` or below it. A late
4387
+ // $detached runs at once, as a late $destroyed does; a late $attached has
4388
+ // nothing left to attach
4389
+ const $attached = (fn: () => void, { immediate = true }: { immediate?: boolean } = {}): (() => void) => {
4390
+ if (typeof fn !== "function") throw new TypeError("jq79: $attached expects a function")
4391
+ if (marker !== this.startMarker) return () => {}
4392
+ this.watchPage()
4393
+ const off = addHook((this.attachHooks ??= []), fn)
4394
+ if (immediate && this.onPage) runHook(fn, "$attached")
4395
+ return off
4396
+ }
4397
+ const $detached = (fn: () => void): (() => void) => {
4398
+ if (typeof fn !== "function") throw new TypeError("jq79: $detached expects a function")
4399
+ if (marker !== this.startMarker) {
4400
+ runHook(fn, "$detached")
4401
+ return () => {}
4402
+ }
4403
+ this.watchPage()
4404
+ return addHook((this.detachHooks ??= []), fn)
4405
+ }
4406
+ // the library's $computed, disposed with this generation: one made over a
4407
+ // shared store is otherwise held by that store, and kept recomputing, for
4408
+ // as long as the store lives
4409
+ const $instanceComputed = <T>(get: () => T) => {
4410
+ const computed = $computed(get)
4411
+ $destroyed(() => computed.$dispose())
4412
+ return computed
4413
+ }
4414
+
4354
4415
  // $self / $$self mirror $ / $$ but only search this instance's own
4355
4416
  // output: the sibling nodes between its markers. They work detached too
4356
4417
  // (the holding fragment keeps markers and rendered nodes as siblings),
@@ -4451,7 +4512,7 @@ export class Component79 {
4451
4512
  // resolve to nothing at all. In setup mode this composes with `with` -
4452
4513
  // scriptScope's `has` declines any name that is a helper, so the
4453
4514
  // parameter is what the name resolves to
4454
- const instanceHelpers = { $mounted, $self, $$self, ...injected, ...siblingScope }
4515
+ const instanceHelpers = { $mounted, $destroyed, $attached, $detached, $computed: $instanceComputed, $self, $$self, ...injected, ...siblingScope }
4455
4516
  const at: ScriptLocation = { filename: this.filename, index }
4456
4517
  const deferred = ":mounted" in script.attrs
4457
4518
  const factoryCode = transformFactoryScript(script.content)
@@ -4541,6 +4602,8 @@ export class Component79 {
4541
4602
  this.endMarker!.parentNode!.insertBefore(renderNodes(this.template, templateScope, fx, shadow), this.endMarker!)
4542
4603
  this.renderDone = true
4543
4604
  this.settleMounted()
4605
+ // a held render that paints on the page is this component's attach
4606
+ Component79.settleAttached()
4544
4607
  })
4545
4608
  warnIfStuck(this, gates)
4546
4609
  }
@@ -4603,9 +4666,87 @@ export class Component79 {
4603
4666
  root.appendChild(this.content!)
4604
4667
  this.mountRoot = root
4605
4668
  this.settleMounted()
4669
+ Component79.settleAttached()
4606
4670
  return this
4607
4671
  }
4608
4672
 
4673
+ // whether this generation's DOM is in the document, template built - the
4674
+ // one meaning "on the page" has for $attached. Not "attach() was called": a
4675
+ // nested component is mounted into a fragment its parent inserts later, and
4676
+ // a root can be mounted into an element that isn't in the document
4677
+ private isOnPage(): boolean {
4678
+ return this.renderDone && this.startMarker?.isConnected === true
4679
+ }
4680
+
4681
+ // joins pageWatchers on the first $attached/$detached of a generation, with
4682
+ // the state as it is now: a hook registered while on the page must not fire
4683
+ // for an attach that already happened. Off the page, a settle is queued: a
4684
+ // component inserted by its parent's :if, :each or tag is built in a
4685
+ // fragment and put on the page later on this same stack, with no attach()
4686
+ // of its own to say so - one microtask late, never early
4687
+ private watchPage() {
4688
+ if (pageWatchers.has(this)) return
4689
+ pageWatchers.add(this)
4690
+ this.onPage = this.isOnPage()
4691
+ if (!this.onPage) Component79.queueSettle()
4692
+ }
4693
+
4694
+ private static queueSettle() {
4695
+ if (settleQueued) return
4696
+ settleQueued = true
4697
+ queueMicrotask(() => {
4698
+ settleQueued = false
4699
+ Component79.settleAttached()
4700
+ })
4701
+ }
4702
+
4703
+ // tells every watcher that is on the page and hasn't been told, in document
4704
+ // order - a parent before what it rendered, slot content included, because
4705
+ // the DOM is what says where a component is
4706
+ private static settleAttached() {
4707
+ if (pageWatchers.size === 0) return
4708
+ const due = [...pageWatchers].filter(component => !component.onPage && component.isOnPage())
4709
+ Component79.inDocumentOrder(due).forEach(component => {
4710
+ // a hook that ran before this one may have destroyed it
4711
+ if (component.onPage || !pageWatchers.has(component)) return
4712
+ component.onPage = true
4713
+ component.attachHooks?.slice().forEach(fn => runHook(fn, "$attached"))
4714
+ })
4715
+ }
4716
+
4717
+ // tells this component and every watcher whose DOM is under its markers
4718
+ // that they are leaving the page - before anything moves, so the hooks see
4719
+ // the DOM where it was. One whose DOM already left without it (an :if
4720
+ // removes its branch, then destroys what was in it) has only itself to tell
4721
+ private notifyDetached() {
4722
+ if (pageWatchers.size === 0) return
4723
+ let due: Component79[] = []
4724
+ if (this.startMarker?.isConnected) {
4725
+ const top = new Set<Node>()
4726
+ for (let node: Node | null = this.startMarker; node; node = node.nextSibling) {
4727
+ top.add(node)
4728
+ if (node === this.endMarker) break
4729
+ }
4730
+ const under = (node: Node | null): boolean => {
4731
+ for (; node; node = node.parentNode) if (top.has(node)) return true
4732
+ return false
4733
+ }
4734
+ due = Component79.inDocumentOrder([...pageWatchers].filter(component => component.onPage && under(component.startMarker)))
4735
+ } else if (this.onPage) {
4736
+ due = [this]
4737
+ }
4738
+ due.forEach(component => {
4739
+ if (!component.onPage) return
4740
+ component.onPage = false
4741
+ component.detachHooks?.slice().forEach(fn => runHook(fn, "$detached"))
4742
+ })
4743
+ }
4744
+
4745
+ private static inDocumentOrder(components: Component79[]): Component79[] {
4746
+ return components.sort((a, b) =>
4747
+ a.startMarker!.compareDocumentPosition(b.startMarker!) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1)
4748
+ }
4749
+
4609
4750
  // where a shadow-rendered component's styles go: <style> elements ahead of
4610
4751
  // its content, as always - or, under safe mode, sheets adopted by the shadow
4611
4752
  // root. Built on first placement, since only then is there a root; one that
@@ -4640,6 +4781,7 @@ export class Component79 {
4640
4781
  // with any updates that happened while detached already applied
4641
4782
  detach(): this {
4642
4783
  if (!this.mountRoot || !this.content || !this.startMarker || !this.endMarker) return this
4784
+ this.notifyDetached()
4643
4785
 
4644
4786
  // move everything between the markers (inclusive) back into the holding
4645
4787
  // fragment - including nodes :if/:each inserted after mounting
@@ -4656,6 +4798,18 @@ export class Component79 {
4656
4798
  }
4657
4799
 
4658
4800
  destroy(): this {
4801
+ // first, while everything they might read is still there: leaving the
4802
+ // page ($detached, for this component and what it rendered), then going
4803
+ // ($destroyed). Taken before running, so a hook that destroys again (or
4804
+ // re-renders) finds none
4805
+ this.notifyDetached()
4806
+ pageWatchers.delete(this)
4807
+ this.attachHooks = null
4808
+ this.detachHooks = null
4809
+ this.onPage = false
4810
+ const hooks = this.destroyHooks
4811
+ this.destroyHooks = null
4812
+ hooks?.forEach(fn => runHook(fn, "$destroyed"))
4659
4813
  this.detach()
4660
4814
  this.fx?.dispose()
4661
4815
  this.fx = null
@@ -4789,6 +4943,30 @@ export type Component<P = any, E extends string = never, S extends string = neve
4789
4943
 
4790
4944
  export const parseComponent = (component: string): Component79 => new Component79(component)
4791
4945
 
4946
+ // one broken hook must not leave the others' timers running, or the
4947
+ // teardown half done: reported, and the rest still run
4948
+ const runHook = (fn: () => void, name: string) => {
4949
+ try {
4950
+ fn()
4951
+ } catch (error) {
4952
+ console.error(`jq79: error in a ${name} hook`, error)
4953
+ }
4954
+ }
4955
+
4956
+ // registers fn on a generation's hook list; the returned function takes it off
4957
+ const addHook = (hooks: (() => void)[], fn: () => void): (() => void) => {
4958
+ hooks.push(fn)
4959
+ return () => {
4960
+ const at = hooks.indexOf(fn)
4961
+ if (at !== -1) hooks.splice(at, 1)
4962
+ }
4963
+ }
4964
+
4965
+ // the instances with an $attached or $detached hook, in this generation -
4966
+ // the only ones a mount or a detach has to look at (see settleAttached)
4967
+ const pageWatchers = new Set<Component79>()
4968
+ let settleQueued = false
4969
+
4792
4970
  // library helpers injected into setup scripts. They behave like extra
4793
4971
  // globals: a same-named scope property (render data or a top-level
4794
4972
  // 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.