Skip to content
Merged
16 changes: 16 additions & 0 deletions packages/v4/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,22 @@ See [RATIONALE.md — 11. Autoload](./RATIONALE.md#11-autoload).

See [RATIONALE.md — 12. Storage](./RATIONALE.md#12-storage).

## 13. Testing — the `/test` subpath

`@studiometa/js-toolkit-v4/test` ships the eight helpers a test of a component cannot write for itself: `mount(html)`, `settle()`, `frames(count?)`, `waitFor(predicate, options?)`, `resetDom()`, `captureDiagnostics(target?)`, `recordEvents(target, ...types)` and `resetRegistry()`.

- **The subpath exists because the timing recipe is not derivable.** "Has this component mounted and finished its writes?" is answered by five rounds of a 10ms timer followed by `defaultScheduler.whenIdle()`, and those two numbers encode the mount observer's delivery latency and the scheduler's lane order. Neither half works alone: `whenIdle()` can resolve before the observer has reported the element, and a timer can return between two lanes. `defaultScheduler` and `nextFrame` were already public, so the pieces shipped and the recipe did not.
- **It depends on no test framework.** Nothing in the module imports a runner, an assertion library or a spy; it reads the DOM and the scheduler only, so it runs under Vitest, under Playwright and on a plain browser page. That is also why it is not on the root barrel: a page has no use for it, and the root export count stays what it was.
- **`mount()` returns the wrapper `div` it created**, never the markup's own root, so a fragment of several siblings works and `querySelector()` has a stable handle. `.firstElementChild` is one property away.
- **`waitFor()` returns the predicate's truthy value**, which makes the same call a guard (`() => el.classList.contains('is-open')`) or a query (`() => root.querySelector('.panel')`). It polls on a 10ms cadence and drains the scheduler between attempts, and on timeout it throws — with the caller's `message`, or one naming the timeout.
- **A transition's end state is asserted by polling, never by `settle()`.** A method that starts a transition does not hand it back, and a kept end state lands only after `nextFrame()`, the `from` and `active` states, and either a `transitionend` or one more frame. `settle()` is generous rather than deterministic, which is a flake that passes alone and fails under load. **Polling for an _absence_ is wrong** for the mirror-image reason: `leaveTransition()` clears the other direction's `to` synchronously, so the poll passes before anything has happened.
- **`captureDiagnostics()` reads the channel, and asserting on `console.warn` does not.** A recovered failure is reported on a cancelable event whose _default behaviour_ is the console line; a spy on that line cannot see the code, the severity or the reporting component, and it passes for the wrong diagnostic. The helper cancels each event as it arrives, which is the same act that suppresses the sink — so collecting and silencing are one step, not two, and no spy is involved. `target` defaults to `document`, which sees everything a connected element reported, because diagnostics bubble and compose.
- **`recordEvents(target, ...types)` keeps the order and the payloads.** A component's contract is "`open` came before `opened`, and `opened` carried the height it measured", and a call-counting spy throws both away. Recording several types into one array is the only place their relative order is visible; the richer `{ type, detail }` shape ships because a caller wanting names alone can map, and the reverse is impossible. `$emit` dispatches synchronously but is almost never _called_ synchronously, so the count is awaited with `waitFor`, not read.
- **`resetRegistry()` is the inverse `registerComponent()` deliberately lacks.** A page registers once and keeps it; a test suite is the one caller for which a page-wide registry surviving `resetDom()` is wrong, and the workaround it forces is a counter minting `Widget-1`, `Widget-2`. It is coarse rather than a targeted `unregisterComponent(name)` because the element→controller map is a `WeakMap`: it cannot be enumerated, so nothing can walk it to dispose live triggers, and a `querySelectorAll()` sweep would still miss detached elements. Clearing everything works because the mutation-observer path already disposes a controller as its element leaves the DOM — hence the call belongs _after_ `resetDom()`, and **never in an `afterEach`**, which would unregister the module-top-level registrations for every later test in the file.
- **What is deliberately not in it**: an instance lookup (`getInstances()` already answers it), frame counters, fetch stubs and pointer sequences. Each is either specific to one spec or already served by `vi.fn()` and `@vitest/browser`'s `userEvent`.

See [RATIONALE.md — 13. Testing](./RATIONALE.md#13-testing).

## Status for #694

- `LoadService` is removed. `KeyService` is ported, with a target and a fixed repeat counter (§8). Mutation handling is internal to the registry. See §8.
Expand Down
50 changes: 50 additions & 0 deletions packages/v4/RATIONALE.md
Original file line number Diff line number Diff line change
Expand Up @@ -830,3 +830,53 @@ The `create<Area>Storage` presets are a different case and stay, because each re
### Why the adapters are tested against the platform

`providers.spec.ts` drives each adapter through the six methods for real — the actual storage areas, the actual `location` and `history` — including the paths that only the platform has. To test `createStorage()` over the memory provider proves the storage. It proves nothing about the four adapters that touch the platform, which are the part that can fail.

## 13. Testing

### Why the helpers are a shipped module and not a documented snippet

The recipe encodes two internals — how late a `MutationObserver` delivers, and in what order the scheduler drains its lanes — and a consumer has no way to derive either. A snippet in the documentation makes every consumer copy a number they cannot maintain; when a lane is added, their copy is silently wrong. The subpath makes the copy the framework's own, and the framework's own version is the one the framework's specs prove.

The evidence is that the copying already happened inside the package: an identical seven-line `mount` in twelve spec files, the same settle loop in eleven, and three hand-copied `waitForClass` bodies. A helper duplicated by the people who wrote the scheduler will be duplicated worse by anyone else.

### Why it depends on no test framework

A helper that imports `vitest` is a helper only Vitest users can have, and it drags a runner into the dependency graph of a package that has none. Nothing here needs one: `settle()` awaits a timer and the scheduler, `mount()` writes to `document.body`, and `waitFor()` throws a plain `Error`. The three things a runner is genuinely needed for — spies, assertions and fixtures — are the three things the module refuses to ship.

### Why it is a barrel and not one subpath per symbol

Every other subpath in this package exists to keep one imported symbol from dragging a barrel's graph onto a page. A test file is not a page: it loads several of these helpers at once, it is never served to a browser as production code, and the whole module is smaller than the graph any splitting would save. `./utils` is split because a component imports one easing function; `./test` is not, for the same reason read the other way.

### Why `waitFor()` returns its value

The two questions a test asks about deferred DOM work are "is it true yet" and "what is it now", and they are the same poll. Returning the truthy value collapses them into one helper instead of a predicate version and a query version, and it removes the second lookup a guard-only version forces on the caller — a lookup which can observe a _different_ element from the one the guard passed on.

### Why polling replaced `settle()` for transitions, and only in one direction

This is the record of a defect. Three specs asserted a kept transition class after `settle()`, passed six-for-six in isolation, and failed roughly one run in three under full-suite load. Nothing was racing inside the components: `open()` and `close()` start a transition and do not return it, a kept end state lands several deferred steps later, and `settle()` is generous rather than deterministic. Reading a deferred write at a moment nothing promised is a flake even when everything it reads is correct.

The asymmetry is what makes it a rule rather than a habit. `leaveTransition()` clears the other direction's `to` class synchronously, before its first await, so "the class is gone" is already true when nothing has happened yet — polling for an absence passes for the wrong reason, and would pass against a component that does nothing at all. An absence is asserted directly, after the awaited call which causes it.

### Why a diagnostic is asserted on the channel and not on the console

`vi.spyOn(console, 'warn')` appears forty times across the suite and twenty-six spec files touch diagnostics, several of them twice over: once to silence the console and once, separately, to add a listener that cancels the event. That the second call is what silences the first is the whole point, and it is not guessable from outside — a consumer would have to know the channel name, the detail shape, and that cancelling a cancelable diagnostic is what suppresses its default sink. `captureDiagnostics()` collapses the two into one call whose result is the thing worth asserting on.

The spy is not merely redundant, it is the weaker assertion. It reads a formatted string, so it cannot tell `registry.conflict` from `registry.lazy-name-mismatch`, cannot see the severity or the reporting component, and breaks when the sink's wording changes — while a diagnostic reported with the wrong code passes it. Most of those forty sites therefore test the sink and believe they are testing the framework. The channel is public and stable precisely so that it, not the console, is what a test reads.

One trap this uncovered, worth recording because it silently inverts an assertion: `mockRestore()` also clears the call history, so a spy restored before its own `expect` always looks unused. The module's own spec restores in an `afterEach` for that reason.

### Why `resetRegistry()` is coarse, and why it is not `unregisterComponent(name)`

The targeted form cannot be written correctly. Disposing one name's live triggers means finding the elements that hold them, and the element→controller map is a `WeakMap` by design: it cannot be enumerated, so nothing can walk it, and a `querySelectorAll()` sweep for the name would still miss every detached element still carrying a controller. A per-name inverse would be an API that looks precise and is not.

The coarse form is correct for a reason outside itself: the mutation-observer path already disposes a controller as its element leaves the DOM, so once the DOM is empty there are no live triggers left to find. That is why the ordering — `resetDom()`, then `resetRegistry()` — is part of the contract rather than advice, and why the doc comment says outright that the `WeakMap` is not cleared instead of implying a clean slate.

Its footgun is stated in the same place because the shape of a spec file guarantees someone will hit it: registrations happen at module top level, so `resetRegistry()` in an `afterEach` unregisters them for every later test in the file, and the failure appears in a test that looks unrelated. `resetDom()` deliberately does not call it.

### What was refused

`getInstance(el, name)` — the public `getInstances()` answers it, and a second spelling of a lookup is a second thing to keep true. A frame counter that patches `requestAnimationFrame` — one caller, and a global patch is a poor thing to hand out. Fetch stubs and pointer-event sequences — `vi.fn()` and `@vitest/browser`'s `userEvent` do both better, and each was shaped by the one spec that grew it.

An event recorder was refused on that same list and then shipped as `recordEvents()`, which is worth recording as a reversal rather than quietly correcting. The refusal was right about `vi.fn()` replacing a _counter_ and wrong about what the seven spec files hand-rolling one were actually doing: they were recording `{ type, detail }` across several event names into one array, because the assertion is the sequence and its payloads, and that is the one thing a per-listener spy cannot express. Two shapes existed in the wild — `type` only, and `{ type, detail }` — and the richer one ships because mapping down is free and mapping up is impossible.

A re-export shim in `test-utils.ts` was refused too. The old file keeps the fixtures and the two helpers that stay source-only, and the specs move onto the new module in one pass; a shim would be both a compatibility layer and a temporary solution meant to be replaced.
4 changes: 4 additions & 0 deletions packages/v4/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
"types": "./dist/utils/index.d.ts",
"import": "./dist/utils/index.js"
},
"./test": {
"types": "./dist/test/index.d.ts",
"import": "./dist/test/index.js"
},
"./package.json": "./package.json",
"./watchAttributeNamespace": {
"types": "./dist/subpaths/watchAttributeNamespace.d.ts",
Expand Down
6 changes: 5 additions & 1 deletion packages/v4/scripts/check-package.js
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,11 @@ try {
const packOutput = await run('npm', ['pack', '--json', '--pack-destination', packRoot], {
cwd: packageRoot,
});
const [metadata] = JSON.parse(packOutput);
// `npm pack --json` answers with an array of packed packages up to npm 11,
// and with an object keyed by package name from npm 12 on. One package is
// packed either way: read its metadata out of whichever shape arrived.
const packed = JSON.parse(packOutput);
const [metadata] = Array.isArray(packed) ? packed : Object.values(packed);
assert(metadata, 'npm pack returned no package metadata.');

const packedFiles = assertPackageContent(metadata);
Expand Down
50 changes: 50 additions & 0 deletions packages/v4/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,56 @@ export function registerManifest(entries: ComponentManifest): void {
}
}

/**
* Drop every registration, so the names a test used are free again.
*
* `registerComponent()` has no inverse by design: a page registers once and
* keeps the registration for as long as it lives. A test suite is the one
* caller for which that is wrong — the registry is page-wide and it survives
* `resetDom()`, so a name registered by one spec is still taken in the next
* one, and the workaround is a counter that mints `Widget-1`, `Widget-2` and
* tells the reader nothing. This is the escape hatch, re-exported from
* `@studiometa/js-toolkit-v4/test`; it is not part of a page's vocabulary.
*
* **It is coarse on purpose.** A targeted `unregisterComponent(name)` would
* have to find the elements holding that name's live triggers, and the map
* from element to controller is a `WeakMap`: it cannot be enumerated, so
* nothing can walk it to dispose them, and a `querySelectorAll()` sweep would
* still miss every detached element. Clearing the whole registry works because
* the mutation-observer path already disposes a controller as its element
* leaves the DOM — which is why the call belongs *after* `resetDom()`.
*
* Two things it therefore does not do. It cannot clear that `WeakMap`, so a
* controller whose element is still connected keeps its trigger; empty the DOM
* first. And it does not narrow the attribute filter the shared observer built
* from the options of everything ever registered — an extra watched attribute
* costs a reconciliation pass that finds no owner, and nothing more.
*
* **Where the call belongs.** Spec files register at module top level, so
* this in an `afterEach` silently unregisters everything for every later test
* in the file. Put it in an `afterAll`, or call it and register again straight
* away:
*
* ```ts
* afterEach(async () => {
* await resetDom();
* resetRegistry();
* registerComponents(Subject, Emitter);
* });
* ```
*/
export function resetRegistry(): void {
registry.clear();
manifest.clear();
imports.clear();
responsiveElements.clear();
registryState.pendingResponsiveElements.clear();
registryState.responsiveTask?.cancel();
registryState.responsiveTask = null;
registryState.unwatchBreakpoints?.();
registryState.unwatchBreakpoints = null;
}

/** Import and register a lazy entry once per name. Failed imports are not retried. */
function importComponent(name: string, target?: Element): Promise<void> {
const pending = imports.get(name);
Expand Down
Loading
Loading