From 501a5d029a1cccedc9ee677a2ae7a98970e7db45 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 24 Aug 2026 13:00:37 +0200 Subject: [PATCH] refactor(v4): rename $destroy()/destroyed() to $unmount()/unmounted() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v4 has no destroyed state: `#isMounted` is one boolean with two values, and the doc comment on `$destroy()` existed only to explain that destroy does not destroy. `$mount()`/`$unmount()` and `mounted()`/`unmounted()` are symmetric pairs where `$mount()`/`$destroy()` was not. Vue 3 made the same rename. Scope is `packages/v4` and `packages/eslint-plugin`. v3 keeps `$destroy()` and `destroyed()`; `packages/js-toolkit` and `packages/docs` are untouched. - `Base`: `$destroy()`, the `destroyed()` hook, `#destroyCallbacks` and the guard message. Both doc comments rewritten to state the semantics plainly instead of arguing against the old name. - `registry`: `destroyPair`/`destroyWithin`/`destroyRemovedSubtrees`, and the withdrawn-declaration comment now names its two steps — leave the mount cycle, then drop the identity. - `events`: `js-toolkit:component:destroyed` is now `js-toolkit:component:unmounted`, the only wire-format change. v4 is unreleased. - `MountStrategyHooks` is `{ mount, unmount }`: the hook drives `$unmount()`. - The service mixin, six migration components, every spec, bench and demo. Kept: `smoothTo().destroy()` and the storage handle's `destroy()`, which tear down standalone objects with no mount cycle to leave; and the `DESTROYED_EVENT` absence assertion in `exports.spec.ts`, about a name that never shipped. Adds `no-destroy-lifecycle` to the eslint plugin's `v4` config, autofixing both names. It is its own rule rather than an entry in `no-deprecated-properties`, which only flags a member expression whose object is `this` — and the common call is `instance.$destroy()`. `V4_REMOVED` and `V4_REMOVED_METHODS` now point `$terminate` and `terminated` at the names v4 has. Recorded as gap 48 in migration/REPORT.md. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NnepYhqjPMcFCSb43PoHRM --- packages/eslint-plugin/README.md | 16 ++ packages/eslint-plugin/src/index.ts | 3 + packages/eslint-plugin/src/rules/index.ts | 1 + .../rules/no-deprecated-properties.spec.ts | 2 +- .../src/rules/no-deprecated-properties.ts | 4 +- .../src/rules/no-destroy-lifecycle.spec.ts | 142 ++++++++++++++++++ .../src/rules/no-destroy-lifecycle.ts | 129 ++++++++++++++++ packages/eslint-plugin/src/utils/ast.ts | 4 + packages/v4/DESIGN.md | 72 ++++----- packages/v4/RATIONALE.md | 26 ++-- packages/v4/demo/components/Reveal.ts | 4 +- packages/v4/demo/index.html | 2 +- packages/v4/migration/Action/Action.spec.ts | 4 +- packages/v4/migration/Action/ActionEvent.ts | 2 +- .../Carousel/AbstractCarouselComponent.ts | 2 +- .../ClickOutside/ClickOutside.spec.ts | 4 +- packages/v4/migration/Cursor/Cursor.ts | 2 +- packages/v4/migration/Data/DataBind.spec.ts | 2 +- packages/v4/migration/Dialog/Dialog.spec.ts | 6 +- packages/v4/migration/Dialog/Dialog.ts | 6 +- .../v4/migration/Draggable/Draggable.spec.ts | 2 +- packages/v4/migration/InView/InView.ts | 2 +- packages/v4/migration/InView/InViewOnce.ts | 2 +- .../migration/LazyInclude/LazyInclude.spec.ts | 2 +- .../v4/migration/LazyInclude/LazyInclude.ts | 2 +- packages/v4/migration/Menu/Menu.ts | 3 +- .../v4/migration/Prefetch/Prefetch.spec.ts | 4 +- packages/v4/migration/REPORT.md | 10 ++ .../AbstractScrollAnimation.ts | 6 +- .../ScrollAnimation/ScrollAnimation.spec.ts | 4 +- .../ScrollAnimation/withScrolledInView.ts | 12 +- .../v4/migration/Slider/SliderDrag.spec.ts | 2 +- packages/v4/migration/Slider/SliderItem.ts | 2 +- packages/v4/migration/Sticky/Sticky.ts | 2 +- packages/v4/migration/Timer/Timer.spec.ts | 4 +- packages/v4/migration/Timer/Timer.ts | 2 +- packages/v4/migration/Toaster/Toast.ts | 4 +- packages/v4/migration/Toaster/Toaster.ts | 2 +- packages/v4/migration/Track/Track.spec.ts | 18 +-- .../v4/migration/Transition/withTransition.ts | 2 +- packages/v4/src/Base.spec.ts | 84 +++++------ packages/v4/src/Base.ts | 56 ++++--- packages/v4/src/autoload.spec.ts | 6 +- packages/v4/src/children-watchers.ts | 20 +-- packages/v4/src/coexistence.spec.ts | 16 +- packages/v4/src/context.spec.ts | 10 +- packages/v4/src/decorators.spec.ts | 12 +- packages/v4/src/dom-mutations.spec.ts | 8 +- packages/v4/src/duplicate-runtime.spec.ts | 20 +-- packages/v4/src/events.spec.ts | 4 +- packages/v4/src/events.ts | 2 +- packages/v4/src/group.spec.ts | 2 +- packages/v4/src/group.ts | 2 +- packages/v4/src/instances.spec.ts | 10 +- packages/v4/src/mount-at-scale.bench.ts | 2 +- packages/v4/src/mount-strategies.spec.ts | 10 +- packages/v4/src/mount-strategies.ts | 8 +- packages/v4/src/mount-strategies.unit.spec.ts | 26 ++-- packages/v4/src/mount.bench.ts | 14 +- packages/v4/src/protocol-symbols.ts | 2 +- packages/v4/src/registry.spec.ts | 8 +- packages/v4/src/registry.ts | 32 ++-- packages/v4/src/responsive-components.spec.ts | 10 +- packages/v4/src/responsive-options.spec.ts | 22 +-- packages/v4/src/services/in-view.spec.ts | 22 +-- packages/v4/src/services/key.spec.ts | 14 +- packages/v4/src/services/mixin.spec.ts | 68 ++++----- packages/v4/src/services/mixin.ts | 10 +- packages/v4/src/services/mutation.spec.ts | 16 +- packages/v4/src/services/pointer.spec.ts | 6 +- .../v4/src/services/scroll-progress.spec.ts | 24 +-- packages/v4/src/services/toggle.spec.ts | 10 +- packages/v4/src/swap.spec.ts | 8 +- packages/v4/src/utils/scroll-lock.ts | 2 +- packages/v4/test/package-browser-consumer.js | 2 +- packages/v4/test/runtime-harness.js | 44 +++--- 76 files changed, 723 insertions(+), 409 deletions(-) create mode 100644 packages/eslint-plugin/src/rules/no-destroy-lifecycle.spec.ts create mode 100644 packages/eslint-plugin/src/rules/no-destroy-lifecycle.ts diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index b6d1b7ebc..3a39a27ff 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -78,6 +78,7 @@ The same set in `.oxlintrc.json`: "js-toolkit/prefer-instance-scheduler": "warn", "js-toolkit/option-default-factory": "error", "js-toolkit/no-conflicting-negated-option": "error", + "js-toolkit/no-destroy-lifecycle": "error", "js-toolkit/no-deprecated-properties": ["error", { "version": "v4" }], "js-toolkit/no-dispatch-event": "warn", "js-toolkit/no-shadow-dom": "error", @@ -201,6 +202,21 @@ These rules describe v4 only, and ship in `configs.v4` rather than in `configs.r | `js-toolkit/option-default-factory` | Disallows a literal object or array as an option `default` — every instance would share it. Core warns at runtime, but only once a component mounts. | error | | | `js-toolkit/no-conflicting-negated-option` | Disallows declaring both a boolean option `x` and an option named `noX`, which would make `data-option-no-x` mean two things. Core deliberately does not check this on every mount. | error | | | `js-toolkit/no-deprecated-properties` | With `{ "version": "v4" }`: reports `$parent`, `$root`, `$children`, `$update`, `$warn`, `$log`, `$terminate`, `$services.enable()`/`.disable()`, `updated()`, `terminated()`, `config.emits`. | error | | +| `js-toolkit/no-destroy-lifecycle` | Renames `$destroy()` to `$unmount()` and the `destroyed()` hook to `unmounted()` — the v4 names. See below. | error | 🔧 | + +#### `no-destroy-lifecycle` + +v4 has no destroyed state. `$mount()` and `$unmount()` are one boolean with two values, and the method that used to be called `$destroy()` documented itself as the reversible inverse of `$mount()`. `$mount()`/`$unmount()` and `mounted()`/`unmounted()` are symmetric pairs where `$mount()`/`$destroy()` was not. Vue 3 made the same rename to `beforeDestroy`/`destroyed`. + +The rename is not an entry in `no-deprecated-properties`, for three reasons. That rule only flags a member expression whose object is `this`, deliberately, so that `someLibrary.$parent` is not noise — but most `$destroy()` calls are `instance.$destroy()`, so folding the rename in would miss the common case, and lifting the guard for one entry would lift it for all of them. That rule is also not fixable and should not become so: `$parent` → `$closest()` is not a safe textual rewrite, while a rename is exactly the case a fixer fits. And the two rules say different things — `no-deprecated-properties` reports v3 names v4 dropped, whereas `$destroy` is v4's own name, renamed while v4 is unreleased. + +The two halves are asymmetric, because the two names are. + +`$destroy` is flagged on **any** receiver — `this.$destroy()`, `instance.$destroy()`, `super.$destroy()` — and on a `$destroy()` method definition, which is what a service mixin overriding the framework's teardown writes. The `$` prefix is the toolkit's own namespace, and the rule ships in `configs.v4` only; that opt-in is the guard against rewriting an unrelated library's API. + +`destroyed` has no prefix to lean on — `emitter.destroyed`, `record.destroyed` and a plain `destroyed` boolean are ordinary code — so that half is narrowed to the two places where it can only be the hook: a non-static `destroyed()` method definition in a class that reads as a v4 component, and a `super.destroyed()` call inside one. A bare `foo.destroyed()` is left alone. + +Computed access (`instance['$destroy']()`) is not flagged: it is rare, and the fixer would have to guess the quote style of a string it did not write. #### `no-write-in-read-phase` diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index d0282aed7..58e11191a 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -35,6 +35,7 @@ import { preferInstanceScheduler, optionDefaultFactory, noConflictingNegatedOption, + noDestroyLifecycle, } from './rules/index.ts'; const PLUGIN_NAME = 'js-toolkit'; @@ -75,6 +76,7 @@ const rules = { 'prefer-instance-scheduler': preferInstanceScheduler, 'option-default-factory': optionDefaultFactory, 'no-conflicting-negated-option': noConflictingNegatedOption, + 'no-destroy-lifecycle': noDestroyLifecycle, }; const recommendedRules: Record = { @@ -124,6 +126,7 @@ const v4Rules: Record = { [`${PLUGIN_NAME}/prefer-instance-scheduler`]: 'warn', [`${PLUGIN_NAME}/option-default-factory`]: 'error', [`${PLUGIN_NAME}/no-conflicting-negated-option`]: 'error', + [`${PLUGIN_NAME}/no-destroy-lifecycle`]: 'error', [`${PLUGIN_NAME}/no-deprecated-properties`]: ['error', { version: 'v4' }], [`${PLUGIN_NAME}/no-dispatch-event`]: 'warn', [`${PLUGIN_NAME}/no-shadow-dom`]: 'error', diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 24c91c754..bc28bc474 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -33,3 +33,4 @@ export { noOptionsAssignment } from './no-options-assignment.ts'; export { preferInstanceScheduler } from './prefer-instance-scheduler.ts'; export { optionDefaultFactory } from './option-default-factory.ts'; export { noConflictingNegatedOption } from './no-conflicting-negated-option.ts'; +export { noDestroyLifecycle } from './no-destroy-lifecycle.ts'; diff --git a/packages/eslint-plugin/src/rules/no-deprecated-properties.spec.ts b/packages/eslint-plugin/src/rules/no-deprecated-properties.spec.ts index 45f2005fa..d970e1f04 100644 --- a/packages/eslint-plugin/src/rules/no-deprecated-properties.spec.ts +++ b/packages/eslint-plugin/src/rules/no-deprecated-properties.spec.ts @@ -50,7 +50,7 @@ describe('no-deprecated-properties', () => { { code: `class Slider extends Base { mounted() { this.$watchChildren(Item, () => {}); } - destroyed() {} + unmounted() {} static config = { name: 'Slider' }; }`, options: v4, diff --git a/packages/eslint-plugin/src/rules/no-deprecated-properties.ts b/packages/eslint-plugin/src/rules/no-deprecated-properties.ts index 4a7893866..ae92688da 100644 --- a/packages/eslint-plugin/src/rules/no-deprecated-properties.ts +++ b/packages/eslint-plugin/src/rules/no-deprecated-properties.ts @@ -29,13 +29,13 @@ const V4_REMOVED = new Map([ // the component name and element, rather than writing to the console. See // `$error` for the counterpart that carries a cause. ['$log', 'console.log()'], - ['$terminate', '$destroy()'], + ['$terminate', '$unmount()'], ]); /** Methods v4 no longer calls. Defining one is dead code. */ const V4_REMOVED_METHODS = new Map([ ['updated', '$watchChildren(), or an optionChanged() hook'], - ['terminated', 'destroyed()'], + ['terminated', 'unmounted()'], ]); /** `$services` survives, but its two switches do not. */ diff --git a/packages/eslint-plugin/src/rules/no-destroy-lifecycle.spec.ts b/packages/eslint-plugin/src/rules/no-destroy-lifecycle.spec.ts new file mode 100644 index 000000000..103c94d51 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-destroy-lifecycle.spec.ts @@ -0,0 +1,142 @@ +import { describe, it } from 'vitest'; +import { tester } from '../utils/rule-tester.ts'; +import { noDestroyLifecycle } from './no-destroy-lifecycle.ts'; + +describe('no-destroy-lifecycle', () => { + it('passes and fails correctly', () => { + tester.run('no-destroy-lifecycle', noDestroyLifecycle as any, { + valid: [ + // The v4 names themselves. + `class Slider extends Base { + static config = { name: 'Slider' }; + unmounted() {} + }`, + `class Slider extends Base { + static config = { name: 'Slider' }; + onClick() { this.$unmount(); } + }`, + `instance.$unmount();`, + // `destroyed` outside a component is an ordinary name. + `class Store { destroyed() {} }`, + `class Store extends Error { destroyed() {} }`, + `emitter.destroyed();`, + `if (record.destroyed) { retry(); }`, + `const { destroyed } = state;`, + // A static member is not the hook. + `class Slider extends Base { + static config = { name: 'Slider' }; + static destroyed() {} + }`, + // Computed access is left alone: the fixer would have to guess the + // quote style of a string it did not write. + `instance['$destroy']();`, + ], + invalid: [ + // `this.$destroy()` — the shape `no-deprecated-properties` could reach. + { + code: `class Slider extends Base { + static config = { name: 'Slider' }; + onClick() { this.$destroy(); } +}`, + errors: [{ messageId: 'renamedMethod' }], + output: `class Slider extends Base { + static config = { name: 'Slider' }; + onClick() { this.$unmount(); } +}`, + }, + // `instance.$destroy()` — the shape it could not, and the common one. + { + code: `const slider = new Slider(el); +slider.$destroy();`, + errors: [{ messageId: 'renamedMethod' }], + output: `const slider = new Slider(el); +slider.$unmount();`, + }, + // Any receiver, including one reached through a chain. + { + code: `el[INSTANCES].get(name).$destroy();`, + errors: [{ messageId: 'renamedMethod' }], + output: `el[INSTANCES].get(name).$unmount();`, + }, + // The hook definition. + { + code: `class Dialog extends Base { + static config = { name: 'Dialog' }; + destroyed() { + this.close(); + } +}`, + errors: [{ messageId: 'renamedHook' }], + output: `class Dialog extends Base { + static config = { name: 'Dialog' }; + unmounted() { + this.close(); + } +}`, + }, + // The hook definition plus `super.destroyed()`, which + // `withScrolledInView` writes. + { + code: `class Animation extends withScroll(Base) { + static config = { name: 'Animation' }; + destroyed() { + super.destroyed(); + this.snap(); + } +}`, + errors: [{ messageId: 'renamedHook' }, { messageId: 'renamedHook' }], + output: `class Animation extends withScroll(Base) { + static config = { name: 'Animation' }; + unmounted() { + super.unmounted(); + this.snap(); + } +}`, + }, + // `super.destroyed()` from the already-renamed hook. + { + code: `class Animation extends Base { + static config = { name: 'Animation' }; + unmounted() { super.destroyed(); } +}`, + errors: [{ messageId: 'renamedHook' }], + output: `class Animation extends Base { + static config = { name: 'Animation' }; + unmounted() { super.unmounted(); } +}`, + }, + // A service mixin overrides the framework's own teardown. + { + code: `const withThing = (BaseClass) => + class extends BaseClass { + $destroy() { + this.$services.scrolled.stop(); + return super.$destroy(); + } + };`, + errors: [{ messageId: 'renamedMethod' }, { messageId: 'renamedMethod' }], + output: `const withThing = (BaseClass) => + class extends BaseClass { + $unmount() { + this.$services.scrolled.stop(); + return super.$unmount(); + } + };`, + }, + // A component recognised through the framework surface rather than a + // `static config`. + { + code: `class Item extends AbstractItem { + mounted() { this.$el.hidden = false; } + destroyed() {} +}`, + errors: [{ messageId: 'renamedHook' }], + output: `class Item extends AbstractItem { + mounted() { this.$el.hidden = false; } + unmounted() {} +}`, + }, + ], + }); + }); +}); diff --git a/packages/eslint-plugin/src/rules/no-destroy-lifecycle.ts b/packages/eslint-plugin/src/rules/no-destroy-lifecycle.ts new file mode 100644 index 000000000..88862467a --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-destroy-lifecycle.ts @@ -0,0 +1,129 @@ +import { + createRule, + findEnclosingClass, + getAncestors, + getKeyName, + isComponentClass, + type Node, + type RuleContext, +} from '../utils/ast.ts'; + +/** + * v4 renamed `$destroy()` to `$unmount()` and `destroyed()` to `unmounted()`. + * + * There is no destroyed state in v4: `#isMounted` is one boolean with two + * values, and the method documented itself as "the reversible inverse of + * `$mount()`". `$mount()`/`$unmount()` and `mounted()`/`unmounted()` are + * symmetric pairs where `$mount()`/`$destroy()` was not, which is the same + * rename Vue 3 made to `beforeDestroy`/`destroyed`. + * + * ## Why this is its own rule and not an entry in `no-deprecated-properties` + * + * Three reasons, and the first is the one that decides it. + * + * **`no-deprecated-properties` only flags a member expression whose object is + * `this`.** That guard is deliberate — `$parent` and `$root` are ordinary + * enough words that flagging `someLibrary.$parent` would be noise. But most + * `$destroy()` calls are `instance.$destroy()`: from a registry, from a test, + * from application code holding an instance. Folding the rename in would + * therefore miss the common case, and lifting the guard for one entry would + * lift it for every entry, which is exactly the false positive that guard + * exists to prevent. + * + * **`no-deprecated-properties` is not fixable, and should not become so.** + * `$parent` → `$closest()` and `$children` → `$watchChildren()` are not safe + * textual rewrites; they change the shape of the call and often the logic + * around it. A rename is the one case where a fixer is exactly right, and a + * rule that is fixable for one of its eleven reports is worse than two rules. + * + * **The two rules say different things.** `no-deprecated-properties` reports + * v3 names that v4 dropped. `$destroy` was never a v3-only name: it is v4's + * own, renamed while v4 is unreleased. This rule's whole lifetime is that + * rename window, and it is meant to be deleted, not grown. + * + * ## What is flagged, and what is not + * + * The two halves are deliberately asymmetric, because the two names are. + * + * `$destroy` is flagged on **any** receiver — `this.$destroy()`, + * `instance.$destroy()`, `super.$destroy()` — and on a `$destroy()` method + * definition, which is the shape a service mixin overriding the framework's + * teardown writes. The `$` prefix is the toolkit's own namespace, and the rule + * ships in `configs.v4` only, which a project turns on when it is on v4. That + * opt-in is the guard against rewriting an unrelated library's API. + * + * `destroyed` has no such prefix — `emitter.destroyed`, `record.destroyed` + * and a `destroyed` boolean are all ordinary code — so that half is narrowed + * to the two places where it can only mean the hook: a non-static + * `destroyed()` method definition in a class that reads as a v4 component, + * and a `super.destroyed()` call inside one. A plain `foo.destroyed()` call is + * left alone. + * + * Computed access (`instance['$destroy']()`) is not flagged. It is rare, and a + * fixer would have to guess the quote style of a string it did not write. + */ +export const noDestroyLifecycle = createRule({ + meta: { + type: 'problem', + fixable: 'code', + docs: { + description: + 'Require the v4 names `$unmount()` and `unmounted()` over `$destroy()` and `destroyed()`', + }, + messages: { + renamedMethod: '`$destroy()` is named `$unmount()` in v4.', + renamedHook: 'The `destroyed()` hook is named `unmounted()` in v4.', + }, + }, + createOnce(context: RuleContext) { + function inComponent(node: Node): boolean { + const enclosing = findEnclosingClass(getAncestors(node, context)); + return Boolean(enclosing) && isComponentClass(enclosing as Node); + } + + function rename(target: Node, messageId: string, to: string) { + context.report({ + node: target, + messageId, + fix: (fixer: any) => fixer.replaceText(target, to), + }); + } + + return { + MemberExpression(node: Node) { + if (node.computed) return; + + const name = node.property?.name; + + if (name === '$destroy') { + rename(node.property, 'renamedMethod', '$unmount'); + return; + } + + // `super.destroyed()` chains the hook of a mixin or an abstract + // component, so the receiver settles what a bare name cannot. + if (name === 'destroyed' && node.object?.type === 'Super' && inComponent(node)) { + rename(node.property, 'renamedHook', 'unmounted'); + } + }, + + MethodDefinition(node: Node) { + if (node.computed || !node.key) return; + + const name = getKeyName(node); + + // A mixin overrides the framework's own teardown, which is why this is + // not restricted to a class that reads as a component. + if (name === '$destroy') { + rename(node.key, 'renamedMethod', '$unmount'); + return; + } + + if (name !== 'destroyed' || node.static === true) return; + if (!inComponent(node)) return; + + rename(node.key, 'renamedHook', 'unmounted'); + }, + }; + }, +}); diff --git a/packages/eslint-plugin/src/utils/ast.ts b/packages/eslint-plugin/src/utils/ast.ts index 0796bd270..e58bd45c8 100644 --- a/packages/eslint-plugin/src/utils/ast.ts +++ b/packages/eslint-plugin/src/utils/ast.ts @@ -245,6 +245,10 @@ const COMPONENT_MEMBERS = new Set([ '$read', '$write', '$mount', + '$unmount', + // v4 renamed `$destroy()` to `$unmount()`. The old name stays in this set + // while unmigrated code exists, so that `no-destroy-lifecycle` still reads a + // class whose only framework signal is the call it is there to rewrite. '$destroy', '$services', ]); diff --git a/packages/v4/DESIGN.md b/packages/v4/DESIGN.md index c30ea141e..13807508a 100644 --- a/packages/v4/DESIGN.md +++ b/packages/v4/DESIGN.md @@ -8,13 +8,13 @@ This file states what v4 does. [RATIONALE.md](./RATIONALE.md) states why, which **The registry is the framework. The DOM is the component tree.** -An instance exists because its element is in the document and its class is registered. Nothing else creates or destroys an instance. Parent and child are DOM ancestry only, not ownership. Components find each other through queries and events. +An instance exists because its element is in the document and its class is registered. Nothing else creates or unmounts an instance. Parent and child are DOM ancestry only, not ownership. Components find each other through queries and events. Five objectives structure the design: 1. Components are independent. 2. One registry. -3. The DOM drives mount and destroy. +3. The DOM drives mount and unmount. 4. Parents listen to child events. 5. Children announce their existence to parents. @@ -38,14 +38,14 @@ Two notions stay separate: | Notion | What it is | Effect | | ---------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| **disconnected** | The element left the document. | The registry calls `$destroy()`. The instance stays on its element. A re-inserted element mounts the same instance again. | -| **destroy** | The reversible opposite of mount. | Unbinds the listeners of the cycle, runs the `mounted()` cleanups, cancels the scheduled tasks, calls `destroyed()`, announces the change. | +| **disconnected** | The element left the document. | The registry calls `$unmount()`. The instance stays on its element. A re-inserted element mounts the same instance again. | +| **unmount** | The reversible opposite of mount. | Unbinds the listeners of the cycle, runs the `mounted()` cleanups, cancels the scheduled tasks, calls `unmounted()`, announces the change. | -**`mount` and `destroy` are the whole lifecycle.** There is no third, permanent notion: a component never declares that its work is over, and nothing marks an instance as never mountable again. +**`mount` and `unmount` are the whole lifecycle.** There is no third, permanent notion: a component never declares that its work is over, and nothing marks an instance as never mountable again. -**A withdrawn declaration is a registry action, not a lifecycle state.** When the element stops declaring the component — the token leaves `data-component`, or a responsive declaration stops matching — the registry destroys the instance and drops it from the element, so declaring the name again builds a new one. That is the registry rearranging its own bookkeeping, and the instance only ever sees `$destroy()`. +**A withdrawn declaration is a registry action, not a lifecycle state.** When the element stops declaring the component — the token leaves `data-component`, or a responsive declaration stops matching — the registry unmounts the instance and drops it from the element, so declaring the name again builds a new one. That is the registry rearranging its own bookkeeping, and the instance only ever sees `$unmount()`. -So "do this once per element" is **instance state, not a lifecycle decision**. `$destroy()` leaves the instance on its element, so a plain field survives every move, re-insertion and `swap()` that preserves the element: +So "do this once per element" is **instance state, not a lifecycle decision**. `$unmount()` leaves the instance on its element, so a plain field survives every move, re-insertion and `swap()` that preserves the element: ```js mounted() { @@ -57,27 +57,27 @@ mounted() { What a field does not survive is an element that is genuinely **replaced**, which is exactly when the work should run again. -A parent that destroys does not destroy its children. +Unmounting a parent does not unmount its children. -A move gives one removal record and one addition record. The instance is destroyed and then mounted again. The identity stays the same and the state of the cycle starts again. This is the behaviour of `disconnectedCallback` and `connectedCallback` for custom elements. +A move gives one removal record and one addition record. The instance is unmounted and then mounted again. The identity stays the same and the state of the cycle starts again. This is the behaviour of `disconnectedCallback` and `connectedCallback` for custom elements. ### `mounted()` returns its cleanup -`mounted()` can return a function, or an array of functions, sync or async. The functions run on the next `$destroy()`. +`mounted()` can return a function, or an array of functions, sync or async. The functions run on the next `$unmount()`. ```js class TodoCount extends Base { async mounted() { const signal = await this.$inject(CountContext); - return signal.subscribe((count) => { … }); // released on destroy + return signal.subscribe((count) => { … }); // released on unmount } } ``` -- If an async `mounted()` resolves after the destroy, the cleanup runs immediately. -- Cleanups returned by `mounted()` are destroy-scoped. A pending `$inject()` request is destroy-scoped too. +- If an async `mounted()` resolves after the unmount, the cleanup runs immediately. +- Cleanups returned by `mounted()` are unmount-scoped. A pending `$inject()` request is unmount-scoped too. - Registrations made in the constructor are instance-scoped: `$provide` and `$watchChildren` are never released, and both die with the instance or with its element. A component whose declaration is withdrawn therefore keeps providing context until its element goes. -- `destroyed()` stays available for the cases that the returned cleanup does not fit. +- `unmounted()` stays available for the cases that the returned cleanup does not fit. ### `config.components` @@ -119,7 +119,7 @@ Extend a class that you cannot edit in expression position: `registerComponent(c ### The typed surface -Every instance has a readonly `$id` with the form `-`. The name comes from the resolved config. The sequence increases once for each constructed instance. The id exists before the field initializers of derived classes run, and it does not change through destroy and mount cycles. Core never copies it to a DOM `id`. +Every instance has a readonly `$id` with the form `-`. The name comes from the resolved config. The sequence increases once for each constructed instance. The id exists before the field initializers of derived classes run, and it does not change through unmount and mount cycles. Core never copies it to a DOM `id`. **`$el`, `$id`, `$options` and `$refs` are fixed properties of the instance**, not fields it happens to hold. They are defined non-writable in the constructor, so an assignment throws in a module rather than replacing what every other part of the framework reads: the element the instance is bound to, the id derived from its name, and the two live views over its markup. `readonly` states it for a reader with a build step; the property descriptor states it for everyone else. They stay enumerable, so an instance still reads as one. @@ -211,7 +211,7 @@ optionTargetChanged({ value, previousValue, initial }) { - The hook runs before `mounted()` on each mount cycle. - Several writes in one mutation batch give one change, from the first old raw value to the final DOM value. -- The previous cleanup runs before an update. Every active cleanup runs on `$destroy()`. A new mount starts each effect again with `initial: true`. +- The previous cleanup runs before an update. Every active cleanup runs on `$unmount()`. A new mount starts each effect again with `initial: true`. - Removal of the attribute applies the declared default. - A component without the convention pays no setup cost and reads its options directly. @@ -237,7 +237,7 @@ options: { - **The separator is a colon**, because an option name in kebab case can contain a dash. - **The value is derived on read.** Nothing is stored and nothing is written. `$options` is read-only. - **A crossing reports through `optionChanged()`**, with the payload of an attribute change. A change is a change of the resolved raw value: a crossing to the same resolved value announces nothing, and a write to `data-option-columns:s` while the viewport is at `l` announces nothing. -- **A `matchMedia` subscription opens only for a component that declares `optionChanged()`.** `$destroy()` releases it. A page that only reads options holds no listener. +- **A `matchMedia` subscription opens only for a component that declares `optionChanged()`.** `$unmount()` releases it. A page that only reads options holds no listener. - `setBreakpoints()` is the single source of breakpoint truth. It rebuilds the scoped attribute names and the slice of them that the observer filters for. - The active breakpoint name is memoised for the length of one task, through `utils/memo.js`. `setBreakpoints()` and the `change` handler of a running service clear it at once. - The breakpoint service is part of the core graph on every page. @@ -311,7 +311,7 @@ The plain `data-component` token set is always active. One responsive token set - At the active breakpoint the registry walks from the widest active suffix down and takes the first attribute that is present. - That value is the complete responsive set. A wider value replaces every lower value; it does not merge with it. An empty value is a stop: `data-component:s="TabletFeature" data-component:l=""` runs `TabletFeature` at `s` and `m`, and removes it at `l`. - The effective declaration is the union of the unconditional set and the selected responsive set, without duplicates. -- A crossing compares that effective set against the current state of the element. A shared name keeps its controller and its instance. A name that is no longer declared is destroyed and dropped from the element; a crossing back gives a new identity. A new name enters the normal pipeline, so mount strategies, `data-mount`, lazy entries and lifecycle events keep their meaning. An inactive lazy declaration imports nothing. +- A crossing compares that effective set against the current state of the element. A shared name keeps its controller and its instance. A name that is no longer declared is unmounted and dropped from the element; a crossing back gives a new identity. A new name enters the normal pipeline, so mount strategies, `data-mount`, lazy entries and lifecycle events keep their meaning. An inactive lazy declaration imports nothing. - The document observer registers the exact `data-component:` names and replaces that slice of the filter after `setBreakpoints()`. - Connected elements with a scoped declaration share one reference-counted `useBreakpoint()` subscription. A page with plain declarations opens none. - Breakpoint work runs through the background lane, so `whenDOMSettled()` includes the teardown, import and mount work of a crossing. @@ -335,7 +335,7 @@ The accepted values are exactly `eager`, `visible`, `visible:`, `visible:`. - **The two prefixes are reserved**, and they match before children and refs. `onWindowResize` binds to `window` even in a component whose `config.components` holds a `Window`. To reach a child with that name, use `@on('Window', 'resize')`. This rule is about method names only. `onClick` and `onDocumentClick` are different names and both can exist on one component; a click on the element fires both. - **Payload: `{ event, target }`**, where `target` is the global that the handler names. There is no `payload` and no `index`. @@ -543,7 +543,7 @@ See [RATIONALE.md — 4. Parents listen to child events](./RATIONALE.md#4-parent ### Layer 1 — bubbling lifecycle announcements -Every instance dispatches a framework event from `EVENTS.component` on mount and on destroy, with the instance in the payload. The mount event bubbles from the element. The destroy event dispatches from `document`, because the element can already be detached. Any ancestor can follow its descendants with no declaration. An instance that is scheduled but not mounted announces nothing. +Every instance dispatches a framework event from `EVENTS.component` on mount and on unmount, with the instance in the payload. The mount event bubbles from the element. The unmount event dispatches from `document`, because the element can already be detached. Any ancestor can follow its descendants with no declaration. An instance that is scheduled but not mounted announces nothing. ### Layer 2 — `$watchChildren()` @@ -563,8 +563,8 @@ class Slider extends Base { - The initial sweep is deferred to a microtask, because `$watchChildren` is usually called in a field initializer. The announcement listeners attach at once, so nothing is missed. An internal `Set` removes duplicates. - The string overload looks up the exact `config.name`. - The constructor overload walks the descendant elements in document order and reads their instance maps. It keeps the instances where `instance instanceof ComponentClass`, excludes the watching instance, and removes duplicates. -- No global instance registry is added. The subscription stays active through destroy and mount cycles, for the whole life of the watching instance. -- Destroyed instances announce from `document`, so one lazy, realm-shared listener serves every watcher and the document holds nothing but weak references to them. A listener per watcher would make every watching component immortal, since the document outlives the page's components. +- No global instance registry is added. The subscription stays active through unmount and mount cycles, for the whole life of the watching instance. +- Unmounted instances announce from `document`, so one lazy, realm-shared listener serves every watcher and the document holds nothing but weak references to them. A listener per watcher would make every watching component immortal, since the document outlives the page's components. ### The page-wide lookup — `getInstances()` @@ -577,7 +577,7 @@ getInstances(el); // everything mounted on one element It derives the answer from the DOM. It keeps no registry of instances. - A matching element with no instance is skipped. -- The filter is `$isMounted`, so a destroyed instance is never returned. +- The filter is `$isMounted`, so an unmounted instance is never returned. - `root` is a `ParentNode` and the call is `querySelectorAll`, so an element root searches its descendants and never matches itself. - `selectorFor(name)` on `/utils` is the one place that writes the name-to-selector contract. @@ -610,7 +610,7 @@ api = this.$provide(SliderContext, { | `$inject(key)` | a promise, awaited in `mounted()` | it never settles: a missing provider means "not yet". | | `$injectSync(key)` | the value, synchronously | `undefined`: the caller falls back or does nothing. | -The pending request of the async form is destroy-scoped. A new mount runs `mounted()` again and asks again. The `@inject` field decorator asks once, at construction; a consumer that can wait through several cycles calls `$inject()` from `mounted()`. +The pending request of the async form is unmount-scoped. A new mount runs `mounted()` again and asks again. The `@inject` field decorator asks once, at construction; a consumer that can wait through several cycles calls `$inject()` from `mounted()`. The mechanics follow the WICG context protocol. The consumer dispatches the bubbling, module-private `js-toolkit:context:request` event with a key, a callback and a subscription marker. It is not part of public `EVENTS`. The nearest mounted provider answers. `provideContext()` replays the requests that have no first answer yet. `injectContext()` and `$inject()` are one-shot. @@ -628,7 +628,7 @@ A root provider cannot be disposed and it outlives the instance that asked first #### `subscribeContext()` -`subscribeContext(el, key, onProvide)` gives the subscription behaviour of the WICG protocol. The required callback runs synchronously for each answer and receives the value and the same unsubscribe function that the helper returns. A component calls it from `mounted()` and returns the unsubscribe function, which gives it a destroy-scoped lifetime. +`subscribeContext(el, key, onProvide)` gives the subscription behaviour of the WICG protocol. The required callback runs synchronously for each answer and receives the value and the same unsubscribe function that the helper returns. A component calls it from `mounted()` and returns the unsubscribe function, which gives it an unmount-scoped lifetime. - **The trigger is the mount announcement**, not a broadcast from the provider. The optional `context-subscription.ts` module keeps one listener on the document, attached on the first subscription and never at import time. It runs after `mounted()`. - Two `contains()` calls bound the cost per mount: the new provider must contain the consumer, and it must sit inside the provider that answers it now. A mount that changes nothing checks nothing. @@ -687,7 +687,7 @@ No engine ships stage-3 decorators, so **every decorator is a thin wrapper over | `@on(target, type)` / `@on(type)` | the `on` names | The target is a name or a value. | | `@provide(key)` / `@inject(key)` | `$provide()` / `$inject()` | The shape of Lit's `@provide` and `@consume`. | | `@children(nameOrClass, callbacks)` | `$watchChildren()` | Exact name or constructor and subclasses. Callbacks are bound to the instance. | -| `@read` / `@write` | `$read()` / `$write()` | Runs the method body in that phase, cancelled on destroy. | +| `@read` / `@write` | `$read()` / `$write()` | Runs the method body in that phase, cancelled on unmount. | **`@component` and a `static config` on one class merge, and the decorator is applied last.** They merge in a class initializer, which runs after the fields and inside the class definition, so `registerComponent()` on the next line reads the finished config. The rules are the rules of `resolveConfig()`: refs union, `options` and `components` merge entry by entry, a declared value overrides. A key that both sides declare differently is reported as `component.config-conflict`. @@ -698,7 +698,7 @@ No engine ships stage-3 decorators, so **every decorator is a thin wrapper over - **A lazy child needs the string form.** `@on('Child', 'open')` imports nothing. A thunk is not a target; the overloads and the runtime refuse it. - **A name is a child or a ref**, resolved children-first, so the handler is typed as `DelegatedEvent` or `RefEvent`. - **A ref is named as it is declared**: `@on('dots[]', 'click')` for `config.refs: ['dots[]']`. The rule is one rule: the declaration spelling refers to the entry, and the property spelling is used where a name is derived from it. A mismatched `@on('dots', 'click')` gives a warning at bind time when the other spelling is declared. A name that matches nothing stays silent. -- **A global target goes through `bindGlobal()`**, the binding that `onWindow` uses: bubble phase, one listener per mount cycle, removed by `$destroy()`. +- **A global target goes through `bindGlobal()`**, the binding that `onWindow` uses: bubble phase, one listener per mount cycle, removed by `$unmount()`. - **Any other `EventTarget` is refused**, by the overloads and by a `TypeError`. A decorator is evaluated once, at class definition, so an arbitrary target can only be a module-scope value. **`@read` and `@write` are leaf-method sugar: the phase belongs to the call site.** A phase decorator returns a wrapper around the method it decorates, and that wrapper is a property of **that class**. A subclass which overrides the method defines its own, undecorated, and `this.method()` resolves to it — so the base's scheduling disappears and the body runs in whatever phase the caller was in. Decorate a method nobody overrides. A **template method** — a base which schedules work its subclasses implement — schedules at the call site instead: @@ -740,7 +740,7 @@ between frames, on its own turns - **No thrashing.** A `read` scheduled from a `write` runs in the next frame. A `write` scheduled from a `read` runs in the same frame. - **Bounded phases.** Each queue array is swapped for an empty one when its phase starts, so a task scheduled into the running phase lands in the batch of the next frame. The `write` batch is taken after the reads run. - **Task handles.** Scheduling returns a cancelable handle whose promise resolves with the return value of the task: `const box = await scheduler.read(() => el.getBoundingClientRect())`. -- **Instance ownership.** `this.$read(fn)` and `this.$write(fn)` tie tasks to the instance. Destroy cancels the pending tasks of that instance. +- **Instance ownership.** `this.$read(fn)` and `this.$write(fn)` tie tasks to the instance. Unmount cancels the pending tasks of that instance. - **The background lane runs outside the frame.** It posts its own turns through `scheduler.postTask({ priority: 'background' })`, and falls back to a `MessageChannel` message. Each turn runs a 5 ms slice measured from the start of the drain, then gives the thread back and posts the next turn. Background work alone never requests an animation frame. `whenIdle()` counts background tasks and resolves at the end of a background drain as well as at the end of a flush. - **Clamped tick delta.** `TickProps.delta` is clamped to `[1, 40]` ms, and the first tick after the loop wakes reports `1000/60`. `TickProps.time` stays the raw rAF timestamp. - **Error isolation.** One try/catch per task. A task that throws is reported and dropped. The flush continues and the scheduler never deadlocks. @@ -782,8 +782,8 @@ A service is a shared source of props that components subscribe to: `ticked`, `s - **One instance per target and per service options**, keyed in a `WeakMap` by `perTarget()`. `useDrag()` keys its axis, inertia, damping and threshold. `useInView()` keys every `IntersectionObserverInit` field and gives object roots a stable weak identity. `useScrollProgress()` keys its resolved offset. Nothing groups observers across targets. - **The options are read by meaning, not by spelling.** `perTarget()` sorts object keys at every depth and drops the keys that hold `undefined`. Arrays keep their order. Only what the platform owns needs a `keyOf` of its own: `useInView()` gives its root a weak id, and `useMutation()` keeps `resolveInit()` for the DOM contract. - **The options of a mixin are not the options of the service.** `target`, `manual` and `immediate` describe the subscription. They are removed before `use()` is called and they are absent from its `Options` type, so `use: (target, options) => useDrag(target, options)` is correct. -- **A mixin binds per mount cycle.** `withRaf`, `withScroll`, `withResize`, `withScrollProgress`, `withPointer`, `withDrag`, `withInView`, `withMutation` and `withKey` override `$mount()` and `$destroy()`, subscribe the `ticked`, `scrolled`, `resized`, `scrolledInView`, `moved`, `dragged`, `intersected`, `mutated` or `keyed` method of the component, and return the unsubscribe function as a cleanup. `Base` knows nothing about services. The mixin is the primitive, because it needs no build step; `@withScroll()` is the decorator sugar. `withInView` observes a component that is already mounted; it does not replace the `visible` or `in-view` mount strategy. -- **A mixin never occupies a lifecycle hook.** `mounted()` and `destroyed()` belong to the component author, so nothing has to be chained: a class which mixes a service in and writes its own `mounted()` without `super.mounted()` still subscribes. The framework's own `$mount()`/`$destroy()` pair carries the subscription instead. The subscription therefore starts once the whole of `mounted()` has run — including an `immediate` first delivery, which reaches a component that is fully set up — and is released before `destroyed()`, exactly where the mount cleanup used to release it. `$destroy()` releases unconditionally, so a manual subscription started outside a mount cycle is released too. +- **A mixin binds per mount cycle.** `withRaf`, `withScroll`, `withResize`, `withScrollProgress`, `withPointer`, `withDrag`, `withInView`, `withMutation` and `withKey` override `$mount()` and `$unmount()`, subscribe the `ticked`, `scrolled`, `resized`, `scrolledInView`, `moved`, `dragged`, `intersected`, `mutated` or `keyed` method of the component, and return the unsubscribe function as a cleanup. `Base` knows nothing about services. The mixin is the primitive, because it needs no build step; `@withScroll()` is the decorator sugar. `withInView` observes a component that is already mounted; it does not replace the `visible` or `in-view` mount strategy. +- **A mixin never occupies a lifecycle hook.** `mounted()` and `unmounted()` belong to the component author, so nothing has to be chained: a class which mixes a service in and writes its own `mounted()` without `super.mounted()` still subscribes. The framework's own `$mount()`/`$unmount()` pair carries the subscription instead. The subscription therefore starts once the whole of `mounted()` has run — including an `immediate` first delivery, which reaches a component that is fully set up — and is released before `unmounted()`, exactly where the mount cleanup used to release it. `$unmount()` releases unconditionally, so a manual subscription started outside a mount cycle is released too. - **A mixin written outside core still chains.** The rule is about what the mixin overrides, not about who wrote it: a userland mixin which puts its work in `mounted()` needs its subclasses to call `super.mounted()`, and the way not to need that is to override `$mount()`. - **One method name per mixin, and it is the name of the service.** There is no `hook` option. Any other target is an explicit subscription in `mounted()`: @@ -884,7 +884,7 @@ lockScroll(target = document.documentElement): () => void - **It counts.** A modal surface is not alone on a page: a dialog opened from inside a drawer is two holders, and the one which closes first must not put the scroll back under the one still open. The first lock saves the inline value it found, the last release puts exactly that value back, and the ones between only move the count. - **The count is shared across evaluated copies of the package**, through the runtime slot `focus` already uses for the same reason: there is one scroll per document. -- **The release is idempotent**, so a surface calls it on close and again on destroy without counting twice — and a component destroyed while open owes the page its scroll, which is what the second call is for. +- **The release is idempotent**, so a surface calls it on close and again on unmount without counting twice — and a component unmounted while open owes the page its scroll, which is what the second call is for. - **It is `overflow: hidden` and nothing else.** No `paddingRight` compensation: `scrollbar-gutter: stable` is the page's own answer and it does not mis-handle fixed children. iOS Safari remains unreliable, which is the argument for having one function rather than a copy per component. - ``'s `showModal()` gives the top layer, the backdrop, a focus trap and Escape — it does **not** stop the page behind it scrolling, so a native dialog needs this too. @@ -916,7 +916,7 @@ swap(target, content, { mode, wrap, self }): Promise - `target` is an element whose **content** changes. By default the element itself is never replaced, so the reference of the caller, its `id` and any instance on it survive. - `content` is a markup string, parsed in the parsing context of the target, so ``, `
  • ` and `