kerfjs 5.0.0-beta.68 → 5.0.0-beta.69
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/CHANGELOG.md +31 -0
- package/README.md +1 -1
- package/ai/manifest.json +1 -1
- package/dist/scope.d.ts +24 -5
- package/dist/scope.js +37 -1
- package/dist/scope.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
- **The pre-push check no longer adds timing metadata to Hot Sheet tickets.**
|
|
10
|
+
It still runs the repository gate or skips an identical verified clean tree;
|
|
11
|
+
historical timing notes remain readable through `ticket:timing summary`.
|
|
12
|
+
|
|
13
|
+
- **Wrapping catalog resource groups align with the toolbar control band.** A
|
|
14
|
+
single row centers in the band, while multiple rows start at its top.
|
|
15
|
+
|
|
16
|
+
- **UI browser checks cover the current catalog specimens.** Select, toolbar,
|
|
17
|
+
tab, and control group browser assertions now address the expanded demos;
|
|
18
|
+
a focused toolbar search field restores its default surface color when no
|
|
19
|
+
background override is set.
|
|
20
|
+
|
|
21
|
+
- **Pinned tabs cover the full leading edge of their scroll strip.** The
|
|
22
|
+
backing now includes the strip border as well as its padding, preventing a
|
|
23
|
+
sliver of a scrolling tab label from showing beside the pinned tab.
|
|
24
|
+
|
|
25
|
+
- **Catalog resource links remain readable at phone widths.** The resource
|
|
26
|
+
group wraps whole actions onto another row instead of clipping the Guidance
|
|
27
|
+
label at the right edge; `ToolbarControlGroup overflow="wrap"` is available
|
|
28
|
+
for the same independent-link pattern.
|
|
29
|
+
|
|
30
|
+
- **The UI analyzer resolves application component imports through TypeScript
|
|
31
|
+
path aliases.** `KUI-L022` now catches a styled hook class when an imported
|
|
32
|
+
cataloged component uses an exact or wildcard `paths` mapping; Doctor uses
|
|
33
|
+
the same resolution.
|
|
34
|
+
|
|
35
|
+
- **TabBar accepts standalone Web Awesome actions in `trailing` and `end`.**
|
|
36
|
+
A single `wa-button` can sit beside the scrolling tabs or at the far edge
|
|
37
|
+
without ToolbarControlGroup chrome; the composition rule recognizes the
|
|
38
|
+
cataloged custom element in these zones.
|
|
39
|
+
|
|
9
40
|
- **FieldLabel matches form-control labels over field previews.** The
|
|
10
41
|
`@kerfjs/ui/text` export gives a read-only field the same label typography
|
|
11
42
|
and inset as a themed Web Awesome input, with an id for accessible grouping.
|
package/README.md
CHANGED
|
@@ -243,7 +243,7 @@ Nothing is self-hosted — `kerfjs` is on npm, so every ESM CDN (esm.sh, jsDeliv
|
|
|
243
243
|
|
|
244
244
|
### Batteries when you need them: the companion subpaths
|
|
245
245
|
|
|
246
|
-
The core stays tiny because the patterns every real app rebuilds live in optional, tree-shakeable subpaths — a modal you'd otherwise hand-roll (`kerfjs/overlay`), an async-state container with the stale-response race already solved (`kerfjs/async`), a debounce that composes inside the reactive graph (`kerfjs/timing`),
|
|
246
|
+
The core stays tiny because the patterns every real app rebuilds live in optional, tree-shakeable subpaths — a modal you'd otherwise hand-roll (`kerfjs/overlay`), an async-state container with the stale-response race already solved (`kerfjs/async`), a debounce that composes inside the reactive graph (`kerfjs/timing`), and disposer groups with or without a DOM owner (`kerfjs/scope`). The largest is `kerfjs/list` — a keyed list that mounts each row individually (so a signal one row reads updates just that row) and virtualizes a long viewport, with fixed, app-declared, or measured row heights:
|
|
247
247
|
|
|
248
248
|
```ts
|
|
249
249
|
import { bindList, observeRowHeights } from 'kerfjs/list';
|
package/ai/manifest.json
CHANGED
package/dist/scope.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import '@preact/signals-core';
|
|
|
5
5
|
import './bindings-CYwoJpQb.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* `kerfjs/scope` —
|
|
8
|
+
* `kerfjs/scope` — group disposers with or without a DOM owner.
|
|
9
9
|
*
|
|
10
10
|
* kerf hands out disposers (`mount()` / `effect()` / `delegate()` all return
|
|
11
11
|
* `() => void`), but nothing scopes them to a subtree's lifetime — so an
|
|
@@ -13,7 +13,7 @@ import './bindings-CYwoJpQb.js';
|
|
|
13
13
|
* effects, listeners, and observers. Every such app hand-rolls the same
|
|
14
14
|
* `WeakMap<Element, disposers[]>` swept on removal. This subpath blesses it.
|
|
15
15
|
*
|
|
16
|
-
* import { disposeScope, disposeSubtree, observeRemovals } from 'kerfjs/scope';
|
|
16
|
+
* import { createScope, disposeScope, disposeSubtree, observeRemovals } from 'kerfjs/scope';
|
|
17
17
|
*
|
|
18
18
|
* const s = disposeScope(card);
|
|
19
19
|
* s.mount(card, renderCard); // mounts AND registers its disposer
|
|
@@ -27,10 +27,29 @@ import './bindings-CYwoJpQb.js';
|
|
|
27
27
|
* Or install one observer and let removals auto-dispose:
|
|
28
28
|
* observeRemovals(document.body);
|
|
29
29
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* Use `createScope()` for a reusable group spanning several event targets.
|
|
31
|
+
* Its `signal` also removes native listeners when the group is disposed.
|
|
32
|
+
*
|
|
33
|
+
* No module-level mutable state: element scopes live in a `WeakMap` (GC-tied),
|
|
34
|
+
* standalone scopes use closure-local state, and `disposeSubtree` walks the DOM.
|
|
32
35
|
*/
|
|
33
36
|
|
|
37
|
+
/** A reusable disposer group with no DOM owner. */
|
|
38
|
+
interface DisposerScope {
|
|
39
|
+
/** Register a disposer in the current generation and return it. */
|
|
40
|
+
add(dispose: () => void): () => void;
|
|
41
|
+
/** Run the current generation newest first, then abort its signal. */
|
|
42
|
+
dispose(): void;
|
|
43
|
+
/** Signal for native listeners in the current generation. */
|
|
44
|
+
readonly signal: AbortSignal;
|
|
45
|
+
/** Number of registered disposers in the current generation. */
|
|
46
|
+
readonly size: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Create an element-free scope for listeners and other disposers spanning
|
|
50
|
+
* several targets. After disposal, the same scope accepts a new generation.
|
|
51
|
+
*/
|
|
52
|
+
declare function createScope(): DisposerScope;
|
|
34
53
|
/** A per-element teardown scope. Calling `disposeScope(el)` again returns the SAME scope. */
|
|
35
54
|
interface Scope {
|
|
36
55
|
/** Register any `() => void` disposer (a `mount`/`effect`/`delegate` return, a listener remover, …). Returns it. */
|
|
@@ -73,4 +92,4 @@ declare function disposeSubtree(root: Element): void;
|
|
|
73
92
|
*/
|
|
74
93
|
declare function observeRemovals(root: Element): () => void;
|
|
75
94
|
|
|
76
|
-
export { type Scope, disposeScope, disposeSubtree, observeRemovals };
|
|
95
|
+
export { type DisposerScope, type Scope, createScope, disposeScope, disposeSubtree, observeRemovals };
|
package/dist/scope.js
CHANGED
|
@@ -8,6 +8,42 @@ import { effect } from './chunk-IHMDBBUJ.js';
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
// src/scope.ts
|
|
11
|
+
function createScope() {
|
|
12
|
+
let disposers = [];
|
|
13
|
+
let controller = new AbortController();
|
|
14
|
+
let active = true;
|
|
15
|
+
let disposing = false;
|
|
16
|
+
return {
|
|
17
|
+
add(dispose) {
|
|
18
|
+
disposers.push(dispose);
|
|
19
|
+
active = true;
|
|
20
|
+
return dispose;
|
|
21
|
+
},
|
|
22
|
+
get signal() {
|
|
23
|
+
active = true;
|
|
24
|
+
return controller.signal;
|
|
25
|
+
},
|
|
26
|
+
get size() {
|
|
27
|
+
return disposers.length;
|
|
28
|
+
},
|
|
29
|
+
dispose() {
|
|
30
|
+
if (!active || disposing) return;
|
|
31
|
+
active = false;
|
|
32
|
+
disposing = true;
|
|
33
|
+
const pending = disposers;
|
|
34
|
+
const oldController = controller;
|
|
35
|
+
disposers = [];
|
|
36
|
+
controller = new AbortController();
|
|
37
|
+
try {
|
|
38
|
+
for (let index = pending.length - 1; index >= 0; index--)
|
|
39
|
+
runBestEffort(pending[index]);
|
|
40
|
+
} finally {
|
|
41
|
+
oldController.abort();
|
|
42
|
+
disposing = false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
11
47
|
var scopes = /* @__PURE__ */ new WeakMap();
|
|
12
48
|
function runBestEffort(dispose) {
|
|
13
49
|
try {
|
|
@@ -61,6 +97,6 @@ function observeRemovals(root) {
|
|
|
61
97
|
return () => observer.disconnect();
|
|
62
98
|
}
|
|
63
99
|
|
|
64
|
-
export { disposeScope, disposeSubtree, observeRemovals };
|
|
100
|
+
export { createScope, disposeScope, disposeSubtree, observeRemovals };
|
|
65
101
|
|
|
66
102
|
//# sourceMappingURL=scope.js.map
|
package/dist/scope.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/scope.ts"],"names":[],"mappings":";;;;;;;;;;AAgEA,IAAM,MAAA,uBAAa,OAAA,EAA6B;AAGhD,SAAS,cAAc,OAAA,EAA2B;AAChD,EAAA,IAAI;AACF,IAAA,OAAA,EAAQ;AAAA,EACV,CAAA,CAAA,MAAQ;AAAA,EAER;AACF;AAQO,SAAS,aAAa,EAAA,EAAoB;AAC/C,EAAA,MAAM,QAAA,GAAW,MAAA,CAAO,GAAA,CAAI,EAAE,CAAA;AAC9B,EAAA,IAAI,QAAA,KAAa,MAAA,EAAW,OAAO,QAAA,CAAS,KAAA;AAE5C,EAAA,MAAM,YAA+B,EAAC;AAItC,EAAA,IAAI,QAAA,GAAW,KAAA;AACf,EAAA,MAAM,QAAA,GAAW,CAAC,OAAA,KAAsC;AACtD,IAAA,IAAI,QAAA,gBAAwB,OAAO,CAAA;AAAA,SAC9B,SAAA,CAAU,KAAK,OAAO,CAAA;AAC3B,IAAA,OAAO,OAAA;AAAA,EACT,CAAA;AACA,EAAA,MAAM,KAAA,GAAe;AAAA,IACnB,GAAA,EAAK,QAAA;AAAA,IACL,KAAA,EAAO,CAAC,MAAA,EAAQ,MAAA,KAAW,SAAS,KAAA,CAAM,MAAA,EAAQ,MAAM,CAAC,CAAA;AAAA,IACzD,QAAQ,CAAC,EAAA,KAAO,QAAA,CAAS,MAAA,CAAO,EAAE,CAAC,CAAA;AAAA,IACnC,QAAA,EAAU,CAAC,IAAA,EAAM,IAAA,EAAM,UAAU,OAAA,EAAS,OAAA,KACxC,QAAA,CAAS,QAAA,CAAS,IAAA,EAAM,IAAA,EAAM,QAAA,EAAU,OAAA,EAAS,OAAO,CAAC,CAAA;AAAA,IAC3D,OAAA,GAAU;AACR,MAAA,IAAI,QAAA,EAAU;AACd,MAAA,QAAA,GAAW,IAAA;AACX,MAAA,MAAA,CAAO,OAAO,EAAE,CAAA;AAChB,MAAA,KAAA,MAAW,KAAK,SAAA,CAAU,MAAA,CAAO,CAAC,CAAA,gBAAiB,CAAC,CAAA;AAAA,IACtD;AAAA,GACF;AACA,EAAA,MAAA,CAAO,GAAA,CAAI,EAAA,EAAI,EAAE,KAAA,EAAO,WAAW,CAAA;AACnC,EAAA,OAAO,KAAA;AACT;AAOO,SAAS,eAAe,IAAA,EAAqB;AAGlD,EAAA,MAAM,GAAA,GAAM,MAAA,CAAO,GAAA,CAAI,IAAI,CAAA;AAC3B,EAAA,IAAI,GAAA,KAAQ,MAAA,EAAW,GAAA,CAAI,KAAA,CAAM,OAAA,EAAQ;AACzC,EAAA,KAAA,MAAW,EAAA,IAAM,IAAA,CAAK,gBAAA,CAAiB,GAAG,CAAA,EAAG;AAC3C,IAAA,MAAM,KAAA,GAAQ,MAAA,CAAO,GAAA,CAAI,EAAE,CAAA;AAC3B,IAAA,IAAI,KAAA,KAAU,MAAA,EAAW,KAAA,CAAM,KAAA,CAAM,OAAA,EAAQ;AAAA,EAC/C;AACF;AASO,SAAS,gBAAgB,IAAA,EAA2B;AACzD,EAAA,MAAM,QAAA,GAAW,IAAI,gBAAA,CAAiB,CAAC,OAAA,KAAY;AACjD,IAAA,KAAA,MAAW,UAAU,OAAA,EAAS;AAC5B,MAAA,KAAA,MAAW,IAAA,IAAQ,OAAO,YAAA,EAAc;AACtC,QAAA,IAAI,IAAA,YAAgB,OAAA,IAAW,CAAC,IAAA,CAAK,SAAS,IAAI,CAAA;AAChD,UAAA,cAAA,CAAe,IAAI,CAAA;AAAA,MACvB;AAAA,IACF;AAAA,EACF,CAAC,CAAA;AACD,EAAA,QAAA,CAAS,QAAQ,IAAA,EAAM,EAAE,WAAW,IAAA,EAAM,OAAA,EAAS,MAAM,CAAA;AACzD,EAAA,OAAO,MAAM,SAAS,UAAA,EAAW;AACnC","file":"scope.js","sourcesContent":["/**\n * `kerfjs/scope` — tie a set of disposers to a DOM element's lifetime.\n *\n * kerf hands out disposers (`mount()` / `effect()` / `delegate()` all return\n * `() => void`), but nothing scopes them to a subtree's lifetime — so an\n * append-heavy app (a feed, a list of cards) leaks detached-but-subscribed\n * effects, listeners, and observers. Every such app hand-rolls the same\n * `WeakMap<Element, disposers[]>` swept on removal. This subpath blesses it.\n *\n * import { disposeScope, disposeSubtree, observeRemovals } from 'kerfjs/scope';\n *\n * const s = disposeScope(card);\n * s.mount(card, renderCard); // mounts AND registers its disposer\n * s.effect(() => syncCard(card));\n * s.delegate(card, 'click', '.del', del);\n * s.add(() => observer.disconnect()); // any () => void disposer\n * // …when the card goes away:\n * disposeSubtree(feed); // runs every scope in feed (incl. feed)\n * feed.remove();\n *\n * Or install one observer and let removals auto-dispose:\n * observeRemovals(document.body);\n *\n * No module-level mutable state: scopes live in a `WeakMap` (GC-tied, keyed by\n * element), and `disposeSubtree` finds them by walking the subtree.\n */\nimport { delegate, type DelegateOptions } from './delegate.js';\nimport { mount, type MountResult } from './mount.js';\nimport { effect } from './reactive.js';\n\n/** A per-element teardown scope. Calling `disposeScope(el)` again returns the SAME scope. */\nexport interface Scope {\n /** Register any `() => void` disposer (a `mount`/`effect`/`delegate` return, a listener remover, …). Returns it. */\n add(dispose: () => void): () => void;\n /** `mount()` into `el` and register its disposer in one step. Returns the disposer. */\n mount(el: HTMLElement, render: () => MountResult): () => void;\n /** `effect(fn)` and register its disposer in one step. Returns the disposer. */\n effect(fn: () => void | (() => void)): () => void;\n /** `delegate(...)` and register its disposer in one step. Returns the disposer. */\n delegate<T extends Element = Element>(\n root: HTMLElement,\n type: string,\n selector: string,\n handler: (event: Event, target: T) => void,\n options?: DelegateOptions,\n ): () => void;\n /**\n * Run every registered disposer (best-effort — a throwing one won't strand the\n * rest). Idempotent. The handle is then dead: a later `add` / `mount` /\n * `effect` / `delegate` on it (including one made by a disposer while this\n * runs) tears down at once — `add(fn)` calls `fn` immediately — so nothing\n * leaks into a scope that will never run again. `disposeScope(el)` hands out\n * a fresh scope.\n */\n dispose(): void;\n}\n\ninterface ScopeState {\n scope: Scope;\n disposers: Array<() => void>;\n}\n\n// GC-tied cache keyed by element — const + WeakMap, so it is exempt from the\n// \"no module-level mutable state\" rule (like bindings.ts:insertedTextNodes).\nconst scopes = new WeakMap<Element, ScopeState>();\n\n/** Run a disposer; a throwing one must not strand the rest. */\nfunction runBestEffort(dispose: () => void): void {\n try {\n dispose();\n } catch {\n /* best-effort */\n }\n}\n\n/**\n * Get (or create) the teardown {@link Scope} for `el`. Repeated calls for the\n * same element return the same scope, so disparate code paths can register into\n * one place. After `dispose()`, a later `disposeScope(el)` starts fresh, and\n * registrations on the old (disposed) handle tear down immediately.\n */\nexport function disposeScope(el: Element): Scope {\n const existing = scopes.get(el);\n if (existing !== undefined) return existing.scope;\n\n const disposers: Array<() => void> = [];\n // Once disposed (including DURING dispose(), from a disposer), the handle is\n // dead: a registration tears down at once instead of landing in an orphaned\n // array nothing will ever run — a leak (KF-DFGD1R).\n let disposed = false;\n const register = (dispose: () => void): (() => void) => {\n if (disposed) runBestEffort(dispose);\n else disposers.push(dispose);\n return dispose;\n };\n const scope: Scope = {\n add: register,\n mount: (target, render) => register(mount(target, render)),\n effect: (fn) => register(effect(fn)),\n delegate: (root, type, selector, handler, options) =>\n register(delegate(root, type, selector, handler, options)),\n dispose() {\n if (disposed) return;\n disposed = true;\n scopes.delete(el);\n for (const d of disposers.splice(0)) runBestEffort(d);\n },\n };\n scopes.set(el, { scope, disposers });\n return scope;\n}\n\n/**\n * Dispose every scope within `root` (including `root`'s own), then leave the DOM\n * to you. Call it right before removing a subtree. Finds scopes by walking the\n * subtree against the `WeakMap` — no marker attributes are added to your DOM.\n */\nexport function disposeSubtree(root: Element): void {\n // Static NodeList snapshot — safe to dispose (which deletes WeakMap entries)\n // while iterating. Root first, then descendants in document order.\n const own = scopes.get(root);\n if (own !== undefined) own.scope.dispose();\n for (const el of root.querySelectorAll('*')) {\n const state = scopes.get(el);\n if (state !== undefined) state.scope.dispose();\n }\n}\n\n/**\n * Install a `MutationObserver` on `root` that auto-disposes a node's scope when\n * that node (or an ancestor) is permanently removed from the subtree. A node\n * moved or reordered within `root` remains live. One observer covers the whole\n * tree. Returns a disconnect function. Note: `MutationObserver` fires\n * asynchronously, so final containment and disposal run after the mutation.\n */\nexport function observeRemovals(root: Element): () => void {\n const observer = new MutationObserver((records) => {\n for (const record of records) {\n for (const node of record.removedNodes) {\n if (node instanceof Element && !root.contains(node))\n disposeSubtree(node);\n }\n }\n });\n observer.observe(root, { childList: true, subtree: true });\n return () => observer.disconnect();\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/scope.ts"],"names":[],"mappings":"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","file":"scope.js","sourcesContent":["/**\n * `kerfjs/scope` — group disposers with or without a DOM owner.\n *\n * kerf hands out disposers (`mount()` / `effect()` / `delegate()` all return\n * `() => void`), but nothing scopes them to a subtree's lifetime — so an\n * append-heavy app (a feed, a list of cards) leaks detached-but-subscribed\n * effects, listeners, and observers. Every such app hand-rolls the same\n * `WeakMap<Element, disposers[]>` swept on removal. This subpath blesses it.\n *\n * import { createScope, disposeScope, disposeSubtree, observeRemovals } from 'kerfjs/scope';\n *\n * const s = disposeScope(card);\n * s.mount(card, renderCard); // mounts AND registers its disposer\n * s.effect(() => syncCard(card));\n * s.delegate(card, 'click', '.del', del);\n * s.add(() => observer.disconnect()); // any () => void disposer\n * // …when the card goes away:\n * disposeSubtree(feed); // runs every scope in feed (incl. feed)\n * feed.remove();\n *\n * Or install one observer and let removals auto-dispose:\n * observeRemovals(document.body);\n *\n * Use `createScope()` for a reusable group spanning several event targets.\n * Its `signal` also removes native listeners when the group is disposed.\n *\n * No module-level mutable state: element scopes live in a `WeakMap` (GC-tied),\n * standalone scopes use closure-local state, and `disposeSubtree` walks the DOM.\n */\nimport { delegate, type DelegateOptions } from './delegate.js';\nimport { mount, type MountResult } from './mount.js';\nimport { effect } from './reactive.js';\n\n/** A reusable disposer group with no DOM owner. */\nexport interface DisposerScope {\n /** Register a disposer in the current generation and return it. */\n add(dispose: () => void): () => void;\n /** Run the current generation newest first, then abort its signal. */\n dispose(): void;\n /** Signal for native listeners in the current generation. */\n readonly signal: AbortSignal;\n /** Number of registered disposers in the current generation. */\n readonly size: number;\n}\n\n/**\n * Create an element-free scope for listeners and other disposers spanning\n * several targets. After disposal, the same scope accepts a new generation.\n */\nexport function createScope(): DisposerScope {\n let disposers: Array<() => void> = [];\n let controller = new AbortController();\n let active = true;\n let disposing = false;\n return {\n add(dispose) {\n disposers.push(dispose);\n active = true;\n return dispose;\n },\n get signal() {\n active = true;\n return controller.signal;\n },\n get size() {\n return disposers.length;\n },\n dispose() {\n if (!active || disposing) return;\n active = false;\n disposing = true;\n const pending = disposers;\n const oldController = controller;\n disposers = [];\n controller = new AbortController();\n try {\n for (let index = pending.length - 1; index >= 0; index--)\n runBestEffort(pending[index]);\n } finally {\n oldController.abort();\n disposing = false;\n }\n },\n };\n}\n\n/** A per-element teardown scope. Calling `disposeScope(el)` again returns the SAME scope. */\nexport interface Scope {\n /** Register any `() => void` disposer (a `mount`/`effect`/`delegate` return, a listener remover, …). Returns it. */\n add(dispose: () => void): () => void;\n /** `mount()` into `el` and register its disposer in one step. Returns the disposer. */\n mount(el: HTMLElement, render: () => MountResult): () => void;\n /** `effect(fn)` and register its disposer in one step. Returns the disposer. */\n effect(fn: () => void | (() => void)): () => void;\n /** `delegate(...)` and register its disposer in one step. Returns the disposer. */\n delegate<T extends Element = Element>(\n root: HTMLElement,\n type: string,\n selector: string,\n handler: (event: Event, target: T) => void,\n options?: DelegateOptions,\n ): () => void;\n /**\n * Run every registered disposer (best-effort — a throwing one won't strand the\n * rest). Idempotent. The handle is then dead: a later `add` / `mount` /\n * `effect` / `delegate` on it (including one made by a disposer while this\n * runs) tears down at once — `add(fn)` calls `fn` immediately — so nothing\n * leaks into a scope that will never run again. `disposeScope(el)` hands out\n * a fresh scope.\n */\n dispose(): void;\n}\n\ninterface ScopeState {\n scope: Scope;\n disposers: Array<() => void>;\n}\n\n// GC-tied cache keyed by element — const + WeakMap, so it is exempt from the\n// \"no module-level mutable state\" rule (like bindings.ts:insertedTextNodes).\nconst scopes = new WeakMap<Element, ScopeState>();\n\n/** Run a disposer; a throwing one must not strand the rest. */\nfunction runBestEffort(dispose: () => void): void {\n try {\n dispose();\n } catch {\n /* best-effort */\n }\n}\n\n/**\n * Get (or create) the teardown {@link Scope} for `el`. Repeated calls for the\n * same element return the same scope, so disparate code paths can register into\n * one place. After `dispose()`, a later `disposeScope(el)` starts fresh, and\n * registrations on the old (disposed) handle tear down immediately.\n */\nexport function disposeScope(el: Element): Scope {\n const existing = scopes.get(el);\n if (existing !== undefined) return existing.scope;\n\n const disposers: Array<() => void> = [];\n // Once disposed (including DURING dispose(), from a disposer), the handle is\n // dead: a registration tears down at once instead of landing in an orphaned\n // array nothing will ever run — a leak (KF-DFGD1R).\n let disposed = false;\n const register = (dispose: () => void): (() => void) => {\n if (disposed) runBestEffort(dispose);\n else disposers.push(dispose);\n return dispose;\n };\n const scope: Scope = {\n add: register,\n mount: (target, render) => register(mount(target, render)),\n effect: (fn) => register(effect(fn)),\n delegate: (root, type, selector, handler, options) =>\n register(delegate(root, type, selector, handler, options)),\n dispose() {\n if (disposed) return;\n disposed = true;\n scopes.delete(el);\n for (const d of disposers.splice(0)) runBestEffort(d);\n },\n };\n scopes.set(el, { scope, disposers });\n return scope;\n}\n\n/**\n * Dispose every scope within `root` (including `root`'s own), then leave the DOM\n * to you. Call it right before removing a subtree. Finds scopes by walking the\n * subtree against the `WeakMap` — no marker attributes are added to your DOM.\n */\nexport function disposeSubtree(root: Element): void {\n // Static NodeList snapshot — safe to dispose (which deletes WeakMap entries)\n // while iterating. Root first, then descendants in document order.\n const own = scopes.get(root);\n if (own !== undefined) own.scope.dispose();\n for (const el of root.querySelectorAll('*')) {\n const state = scopes.get(el);\n if (state !== undefined) state.scope.dispose();\n }\n}\n\n/**\n * Install a `MutationObserver` on `root` that auto-disposes a node's scope when\n * that node (or an ancestor) is permanently removed from the subtree. A node\n * moved or reordered within `root` remains live. One observer covers the whole\n * tree. Returns a disconnect function. Note: `MutationObserver` fires\n * asynchronously, so final containment and disposal run after the mutation.\n */\nexport function observeRemovals(root: Element): () => void {\n const observer = new MutationObserver((records) => {\n for (const record of records) {\n for (const node of record.removedNodes) {\n if (node instanceof Element && !root.contains(node))\n disposeSubtree(node);\n }\n }\n });\n observer.observe(root, { childList: true, subtree: true });\n return () => observer.disconnect();\n}\n"]}
|
package/package.json
CHANGED