Skip to content

feat(v4): ship the test helpers on a /test subpath - #862

Merged
titouanmathis merged 9 commits into
mainfrom
feat/v4-test-module
Aug 24, 2026
Merged

feat(v4): ship the test helpers on a /test subpath#862
titouanmathis merged 9 commits into
mainfrom
feat/v4-test-module

Conversation

@titouanmathis

@titouanmathis titouanmathis commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

The problem

Every v4 spec answers the same question — has the component mounted and finished its writes? — with the same block, and the answer is not guessable:

for (let i = 0; i < 5; i += 1) {
  await new Promise((resolve) => setTimeout(resolve, 10));
  await defaultScheduler.whenIdle();
}

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 are already public subpaths, so the pieces shipped and the recipe did not — a consumer writing their own js-toolkit component has no way to derive it.

The second half of the problem is recorded as gap 46 in migration/REPORT.md: settle() is generous, not deterministic. Three specs flaked on a kept transition class, passing six-for-six in isolation and failing roughly one run in three under full-suite load. Nothing was racing inside the components — 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. The fix was a hand-copied waitForClass, now duplicated in three files. The framework should ship the primitive.

The copying is already measurable inside the package: an identical seven-line mount in twelve spec files, the same settle loop in eleven, three waitForClass bodies, seven hand-rolled event recorders, and vi.spyOn(console, 'warn') forty times over.

What ships

packages/v4/src/test/index.ts, on a new @studiometa/js-toolkit-v4/test subpath:

helper what it is
mount(html) the seven-line block from twelve specs: wrapper div, innerHTML, append, settle(), return the wrapper
settle() the timing recipe, with its two numbers explained in the doc comment
frames(count = 3) await N animation frames
waitFor(predicate, options?) new — poll until truthy, return the value, throw on timeout
resetDom() empty the body and wait for the unmounts
captureDiagnostics(target?) new — collect what was reported on the diagnostic channel, and silence it
recordEvents(target, …types) new — record { type, detail } for several event names into one array
resetRegistry() new — the inverse registerComponent() deliberately lacks

Zero test-framework dependency. Nothing imports a runner, an assertion library or a spy; the module reads the DOM, the scheduler and the framework's own channels, so it works under Vitest, under Playwright and on a plain browser page. The packed-consumer fixture proves it also loads under Node.

waitFor returns the predicate's truthy value, so the same call is a guard or a query:

await waitFor(() => btn.classList.contains('is-open'));
const panel = await waitFor(() => root.querySelector('.panel'));

It polls on the ~10ms cadence the three waitForClass copies use and drains the scheduler between attempts, so it advances the framework's own work instead of spinning on timers. On timeout it throws, with the caller's message or one naming the timeout — a helper that hangs is worse than no helper.

Its doc comment carries both halves of the gap-46 rule: assert a transition's end state by polling, or by awaiting enter()/leave() where the component hands it back, never with a bare settle(); and never poll for an absence, because leaveTransition() clears the other direction's to synchronously, so the poll passes before anything has happened.

captureDiagnostics() — assert on the channel, not on the console

vi.spyOn(console, 'warn').mockImplementation(() => {}) appears 40 times across the suite, and 26 spec files touch diagnostics. Several sites do it twice over: once to silence the console, and once, separately, to add an EVENTS.diagnostic listener that calls preventDefault(). That the second is what silences the first is the whole mechanism, and none of it is derivable 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.

The spy is also 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 on a wording change — while a diagnostic reported with the wrong code passes it. Most of those 40 sites therefore test the sink and believe they test the framework.

const log = captureDiagnostics();
registerComponent(Duplicate);
expect(log.codes).toContain('registry.conflict');
log.stop();

Collecting and silencing are one step: each event is cancelled as it arrives. entries carries the whole ToolkitDiagnosticDetail for the cases where the code alone is not the assertion. target defaults to document, which sees everything — diagnostics bubble and compose — and an element scopes the capture to one subtree.

One trap found on the way, recorded in the spec: mockRestore() also clears the call history, so a spy restored before its own expect always looks unused. Two of these cases silently passed for that reason before it was caught; the file restores in an afterEach now.

recordEvents(target, …types) — the order and the payloads

Seven spec files hand-roll a variant, in two shapes: some collect the type only, some collect { type, detail }. The richer one ships, because mapping down to names is free and mapping up is impossible.

Taking several types is the point rather than a convenience. A component's contract is a sequence — open before opened, and opened carrying the height it measured — and that relative order is visible nowhere else; a per-listener vi.fn() throws away both the cross-name order and the payloads.

const log = recordEvents(root, 'ping');
instance.start();
await waitFor(() => log.events.length === 2);

The doc comment states the timing rule the shape invites getting wrong. $emit dispatches synchronously, but almost nothing calls it synchronously: the emit follows a mount, a scheduled write or a transition, and the call that started that chain has already returned. So the count is awaited through waitFor, not read.

resetRegistry() — the missing inverse

registerComponent() has no inverse by design: a page registers once and keeps it. A test suite is the one caller for which that is wrong — the registry is page-wide and survives resetDom(), so a name taken by one spec is still taken in the next. Three migration specs already work around it with a counter minting Widget-1, Widget-2, one of them under the comment /** Isolate tests because the page-wide registry survives resetDom(). */.

It is coarse rather than a per-name unregisterComponent(name) because the targeted form cannot be written correctly. Disposing one name's live triggers means finding the elements holding them, and the element→controller map is a WeakMap: it cannot be enumerated, so nothing can walk it, and a querySelectorAll() sweep would still miss every detached element. The coarse form is correct for a reason outside itself — the mutation-observer path already disposes a controller as its element leaves the DOM — which is why resetDom() first 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.

It clears registry, manifest, imports, responsiveElements and pendingResponsiveElements, cancels responsiveTask, and calls unwatchBreakpoints, nulling both. isReplacementListenerAttached is left alone: that listener is attached once at module scope and re-attaching it would double every reconciliation. The implementation lives in registry.ts, next to the module-private state it clears; only the re-export is in the test barrel.

The footgun is documented 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 surfaces in a test that looks unrelated. resetDom() deliberately does not call it. The module's own spec demonstrates the correct pattern — reset, then re-register what is still needed.

What deliberately does not ship

  • getInstance(el, name) — the public getInstances() already answers it.
  • countRequestedFrames — one caller, and a global requestAnimationFrame patch is a poor thing to hand out.
  • Fetch stubs and pointer-event sequences — vi.fn() and @vitest/browser's userEvent do both better.
  • No re-export shim in test-utils.ts. That file is untouched. A shim would be both a compatibility layer and a temporary solution meant to be replaced.
  • Not on the root barrel. A page has no use for these. Both hard-coded export counts stay at 86, and the packed consumer asserts each new name is absent from the root.

An event recorder was on that refusal list in the first round and now ships, which RATIONALE.md records as a reversal rather than quietly correcting: the refusal was right that vi.fn() replaces a counter, and wrong about what the seven copies were actually doing.

Plumbing — four places, one more than expected

  1. packages/v4/package.json./testdist/test/index.{js,d.ts}, shaped like ./utils.
  2. scripts/build.jsno change needed, as predicted: the !test-utils.ts exclusion is anchored at the source root and does not match test/index.ts. Verified by building.
  3. scripts/check-package.jsno change needed for the subpath: dist/test/index.js is neither dist/test-utils.* nor a spec/bench/fixture artifact, and distArtifact already allows nested directories.
  4. scripts/generate-subpaths.jsthis one did need a change, and it was not in the plan. subpaths:check reports every exports key it did not itself produce, so a hand-written ./test fails the lint as exports: unexpected ./test. groupedExports() now emits it, conditional on the barrel existing, so v3 is unaffected. The map in package.json is the generator's own output.

resetRegistry() needed no plumbing change: the generator enumerates src/index.ts and src/utils/index.ts only, so a new export in registry.ts that is not on the root barrel mints no subpath. subpaths:check confirms it.

tsconfig.json and vitest.config.js also map the package-name specifier to the source, as they already do for ./utils; without it the subpath a consumer gets would not resolve inside the repository.

One unrelated blocker, cleared first

check:package was already broken on main: npm pack --json answers with an array up to npm 11 and with an object keyed by package name from npm 12 on, and check-package.js destructured the array form, dying with TypeError: object is not iterable before packing anything. That is the only check which proves an exports subpath resolves inside the tarball, so it had to be fixed to verify this branch at all. It is the first commit, it is minimal — read the one entry out of whichever shape arrived — and both shapes are kept because CI installs Node 24, which still bundles npm 11.

Follow-up, deliberately not in this PR

This branch is purely additive: no *.spec.ts file outside the module's own is touched, and src/test-utils.ts is untouched. The cleanup — deleting the twelve mount copies, the eleven settle-loop helpers, the three waitForClass copies, the seven event recorders and the forty console.warn spies, and moving the TodoList/TodoItem/TodoCount fixtures out of test-utils.ts — lands as a separate commit, gated on the concurrent $destroy/destroyed$unmount/unmounted rename that is rewriting every v4 spec on another branch. Doing it here would guarantee a conflict.

Verification

All run in packages/v4 on the final tree:

command result
npm run lint:types pass
npm run check:diagnostics sinks are centralized and internal code references are tree-shakeable
npm run build Building 260 modules... Done building!
npm run check:package 588 files, 293.4 kB packed — node, TypeScript and browser packed consumers all pass
npm run test 114 passed (114) files, 1558 passed (1558) tests
npm run subpaths:check both packages up to date
npm run lint:fmt / oxlint --type-aware packages/v4 clean (one pre-existing warning in context.ts)

The new module's own spec covers 23 cases. From the first round: waitFor's timeout path and both its messages, the returned truthy value, every falsy value treated as "not yet", mount returning a settled wrapper whose scheduled writes have landed, and resetDom clearing between tests. Added here: captureDiagnostics collecting a real registry.conflict with its full detail while the console stays untouched, its scoping to a target element and its stop(); recordEvents capturing a component's $emit with the detail, several types in delivery order through waitFor, and ignoring a type it was not given; and resetRegistry freeing a name that reports a conflict without it, dropping a lazy manifest entry without importing it, and leaving a previously registered component unable to mount. The /test subpath test asserts the barrel serves exactly these eight helpers under the package name.

Docs

DESIGN.md §13 and the matching RATIONALE.md §13 — what the subpath is, why the recipe cannot be a documented snippet, why it takes no test-framework dependency, why it is a barrel rather than per-symbol subpaths, the record of the flake that produced the polling rule, why a diagnostic is asserted on the channel and not on the console, and why resetRegistry() is coarse. packages/docs is untouched: it documents v3.

`check:package` has thrown `TypeError: object is not iterable` since npm
12: `npm pack --json` answered with an array of packed packages up to
npm 11 and answers with an object keyed by package name from 12 on. The
script destructured the array form, so the check died before it packed
anything — on this branch it took the whole packed-consumer suite with
it, which is the only proof that an `exports` subpath resolves inside
the tarball.

One package is packed either way, so the fix reads its metadata out of
whichever shape arrived. Both are kept because the two npm majors are
both in use: CI installs Node 24, which still bundles npm 11.

Unrelated to the rest of this branch; it is what had to be cleared to
verify it.
Every v4 spec answers the same question — has the component mounted and
finished its writes? — and the answer is a five-round interleaving of a
10ms timer and `defaultScheduler.whenIdle()`. Those numbers encode the
mount observer's delivery latency and the scheduler's lane order, so a
consumer writing their own js-toolkit component cannot derive them. The
pieces were already public subpaths; the recipe was not.

`src/test/index.ts` ships it: `mount()`, `settle()`, `frames()`,
`waitFor()` and `resetDom()`. `settle`, `frames` and `resetDom` are the
existing `test-utils.ts` implementations, `mount` is the seven-line
block copied byte-identically into twelve spec files, and `waitFor` is
new.

**`waitFor` is the one that pays for itself.** REPORT.md §46 recorded
three specs flaking on a kept transition class, and the fix was a
hand-copied `waitForClass` now living in three files. The helper
generalises it: the predicate's truthy value comes back, so the same
call is a guard (`() => el.classList.contains('is-open')`) or a query
(`() => root.querySelector('.panel')`). It polls on the same ~10ms
cadence and drains the scheduler between attempts, and it throws with
the given message or one naming the timeout — a helper that hangs is
worse than no helper. Both halves of §46's rule are in its doc comment,
including the asymmetry: never poll for an absence, because
`leaveTransition()` clears the other direction's `to` synchronously.

**No test framework is imported**, and none may be: the module reads the
DOM and the scheduler only, so it works under Vitest, under Playwright
and on a plain browser page. Deliberately absent are `getInstance`
(`getInstances()` already answers it), `countRequestedFrames`, fetch
stubs, pointer sequences and event recorders — spec-specific, or better
served by `vi.fn()` and `@vitest/browser`'s `userEvent`.

`test-utils.ts` is untouched and gets no re-export shim. Migrating the
specs onto this module is a follow-up, gated on the concurrent
`$destroy` → `$unmount` rename.
`@studiometa/js-toolkit-v4/test` resolves to `dist/test/index.js`, next
to `./utils` and shaped like it: one barrel, not one subpath per symbol.
A test file loads several of these at once and none of them is on a
page's critical path, so the tree-shaking argument that splits the other
two does not apply.

**Four places, all verified rather than assumed.**

`scripts/build.js` needed no change, as expected: its `!test-utils.ts`
exclusion is anchored at the source root and does not match
`test/index.ts`. Confirmed by building — 260 modules, `dist/test/`
emitted with its map and declaration.

`scripts/check-package.js` needed none either. `dist/test/index.js` is
neither `dist/test-utils.*` nor a `.spec`/`.bench`/`.fixtures` artifact,
and the `distArtifact` regex already allows a nested directory.

`scripts/generate-subpaths.js` **did**, and this is the one the plan
missed: `--check` reports every `exports` key it did not itself produce,
so a hand-written `./test` fails the lint as `exports: unexpected
./test`. `groupedExports()` now emits it, conditional on the barrel
existing, so v3 — which has no `src/test/` — is unaffected. The map in
`packages/v4/package.json` is the generator's own output.

`tsconfig.json` and `vitest.config.js` map the package-name specifier
to the source, as they already do for `./utils`; without that, the
subpath a consumer gets would not resolve inside the repository.

Two assertions pin the surface: one in the module's spec through the
package name, and one in `test/package-node-consumer.js`, which runs
against the packed tarball. The latter also proves the module loads
outside a browser — it reaches for the DOM only inside the helpers that
need one. The root barrel is untouched, so both hard-coded export counts
stay at 86.
DESIGN gains a §13 stating what the `/test` subpath is, what its five
helpers do and what it deliberately omits. RATIONALE gains the matching
§13, which is mostly the record of a defect: three specs asserting a
kept transition class after `settle()` passed in isolation and failed
one run in three under load, and the mirror-image case explains why the
rule is one-directional — `leaveTransition()` clears the other
direction's `to` synchronously, so polling for an absence passes before
anything has happened.

The two files also record the refusals, so the next reader does not
re-propose them: no test-framework dependency, one barrel rather than
per-symbol subpaths, no `getInstance`, no frame counter, no fetch stub
or event recorder, and no re-export shim in `test-utils.ts`.
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Code Review

Risk: Medium — issues that should be addressed before merge.

Adds and publishes the v4 /test helper barrel, including DOM settling, frame waiting, polling, diagnostics capture, event recording, and registry reset utilities. It also updates package export generation, packed-consumer coverage, and documents the testing contract.

1 issue found:

  • issuepackages/v4/src/test/index.ts:271 — Enforce zero-duration timeouts before sleeping

Review usage: 253,056 in (205,283 cached) / 2,427 out tokens — $0.0474 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 9cf051c.

Previous review runs

Previous run archived 2026-08-24T12:58:37Z

Code Review

Risk: Low — The change adds the v4 /test subpath and validates its packed-package resolution without introducing blocking defects.

The MR ships framework-independent helpers for mounting, settling, frame waiting, polling, and DOM cleanup. It also updates package metadata, local resolution, subpath generation, documentation, and npm pack metadata handling for both supported output shapes.

No issues found.


Review usage: 32,125 in (16,018 cached) / 662 out tokens — $0.0129 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 752c73a.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

Export size

Bundled per export with peer dependencies left external, dynamic imports excluded and the output minified; sizes are gzipped.

@studiometa/js-toolkit-v4

Export Size (gzip) Diff
test 11.71 kB +11.71 kB (+100.0%)
component 11.66 kB +1 B (+0.0%)
registerComponent 11.27 kB -1 B (-0.0%)
registerComponents 11.28 kB -1 B (-0.0%)
Unchanged (392)

@studiometa/js-toolkit

Export Size (gzip) Diff
(barrel) 17.48 kB
AbstractService 598 B
Base 9.1 kB
ComponentLoader 2.31 kB
DEFAULT_DIAGNOSTIC_PREFIX 102 B
DragService 2.02 kB
IDLE_TIMEOUT 57 B
KeyService 935 B
LoadService 666 B
MutationService 849 B
PointerService 1.13 kB
RafService 1020 B
ResizeService 1.12 kB
ScrollService 1.36 kB
VISIBLE_ROOT_MARGIN 72 B
autoload 2.4 kB
closestComponent 419 B
composeManifests 119 B
createApp 996 B
defineFeatures 326 B
defineManifest 512 B
fromMetaGlob 228 B
fromWebpackContext 131 B
getClosestParent 197 B
getDirectChildren 202 B
getInstanceFromElement 125 B
getInstances 187 B
getScopedGroups 104 B
importOnInteraction 926 B
importOnMediaQuery 243 B
importWhenIdle 225 B
importWhenPrefersMotion 271 B
importWhenVisible 935 B
isDirectChild 218 B
logTree 551 B
queryComponent 594 B
queryComponentAll 601 B
readEagerTokens 201 B
registerComponent 305 B
registerComponents 356 B
registerManifest 2.87 kB
registerManifests 2.89 kB
useDrag 2.05 kB
useKey 943 B
useLoad 676 B
useMutation 876 B
usePointer 1.15 kB
useRaf 1 kB
useResize 1.13 kB
useScroll 1.36 kB
utils 10.08 kB
utils/Queue 291 B
utils/SmartQueue 473 B
utils/addClass 240 B
utils/addStyle 239 B
utils/animate 3.34 kB
utils/boundingRectToCircle 206 B
utils/cache 208 B
utils/camelCase 405 B
utils/clamp 98 B
utils/clamp01 114 B
utils/collideCircleCircle 129 B
utils/collideCircleRect 192 B
utils/collidePointCircle 128 B
utils/collidePointRect 122 B
utils/collideRectRect 128 B
utils/createEaseInOut 123 B
utils/createEaseOut 91 B
utils/createElement 635 B
utils/createLocalStorage 1.32 kB
utils/createLocalStorageProvider 296 B
utils/createMemoryStorageProvider 174 B
utils/createNoopProvider 128 B
utils/createRange 115 B
utils/createSessionStorage 1.32 kB
utils/createSessionStorageProvider 288 B
utils/createStorage 1.3 kB
utils/createUrlSearchParamsInHashProvider 461 B
utils/createUrlSearchParamsInHashStorage 1.35 kB
utils/createUrlSearchParamsProvider 429 B
utils/createUrlSearchParamsStorage 1.34 kB
utils/damp 106 B
utils/dashCase 404 B
utils/debounce 122 B
utils/domScheduler 310 B
utils/ease 519 B
utils/easeInCirc 285 B
utils/easeInCubic 287 B
utils/easeInExpo 286 B
utils/easeInOutCirc 288 B
utils/easeInOutCubic 289 B
utils/easeInOutExpo 288 B
utils/easeInOutQuad 288 B
utils/easeInOutQuart 289 B
utils/easeInOutQuint 289 B
utils/easeInOutSine 288 B
utils/easeInQuad 285 B
utils/easeInQuart 286 B
utils/easeInQuint 286 B
utils/easeInSine 285 B
utils/easeLinear 77 B
utils/easeOutCirc 286 B
utils/easeOutCubic 288 B
utils/easeOutExpo 286 B
utils/easeOutQuad 286 B
utils/easeOutQuart 286 B
utils/easeOutQuint 286 B
utils/easeOutSine 286 B
utils/endsWith 128 B
utils/fold 168 B
utils/getAncestorWhere 123 B
utils/getAncestorWhereUntil 148 B
utils/getComponentResolver 140 B
utils/getOffsetSizes 194 B
utils/hasWindow 88 B
utils/historyPush 524 B
utils/historyReplace 526 B
utils/inertiaFinalValue 169 B
utils/isArray 63 B
utils/isBoolean 78 B
utils/isDefined 75 B
utils/isDev 78 B
utils/isEmpty 206 B
utils/isEmptyString 108 B
utils/isFunction 79 B
utils/isNull 68 B
utils/isNumber 91 B
utils/isObject 108 B
utils/isString 77 B
utils/keyCodes 122 B
utils/lerp 84 B
utils/loadElement 220 B
utils/loadIframe 241 B
utils/loadImage 241 B
utils/loadLink 237 B
utils/loadScript 251 B
utils/localStorageProvider 839 B
utils/lowerCase 404 B
utils/map 93 B
utils/matrix 136 B
utils/mean 126 B
utils/memo 130 B
utils/memoize 228 B
utils/memoryStorageProvider 843 B
utils/nextFrame 179 B
utils/nextMicrotask 133 B
utils/nextTick 148 B
utils/noop 62 B
utils/noopValue 76 B
utils/objectToURLSearchParams 322 B
utils/pascalCase 407 B
utils/random 93 B
utils/randomInt 113 B
utils/randomItem 234 B
utils/removeClass 242 B
utils/removeStyle 243 B
utils/round 95 B
utils/saveActiveElement 92 B
utils/scrollTo 2.31 kB
utils/sessionStorageProvider 838 B
utils/smoothTo 476 B
utils/snakeCase 406 B
utils/spring 154 B
utils/startsWith 125 B
utils/throttle 125 B
utils/toggleClass 242 B
utils/transform 347 B
utils/transition 1010 B
utils/trapFocus 441 B
utils/tween 1.72 kB
utils/untrapFocus 120 B
utils/upperCase 404 B
utils/urlSearchParamsInHashProvider 845 B
utils/urlSearchParamsProvider 839 B
utils/useScheduler 309 B
utils/wait 103 B
utils/withLeadingCharacters 135 B
utils/withLeadingSlash 142 B
utils/withTrailingCharacters 135 B
utils/withTrailingSlash 142 B
utils/withoutLeadingCharacters 122 B
utils/withoutLeadingCharactersRecursive 165 B
utils/withoutLeadingSlash 133 B
utils/withoutTrailingCharacters 122 B
utils/withoutTrailingCharactersRecursive 165 B
utils/withoutTrailingSlash 133 B
utils/wrap 122 B
version 56 B
withBreakpointManager 1.54 kB
withBreakpointObserver 1.71 kB
withDrag 2.18 kB
withExtraConfig 163 B
withFreezedOptions 187 B
withGroup 455 B
withIntersectionObserver 303 B
withMountOnMediaQuery 393 B
withMountWhenInView 347 B
withMountWhenPrefersMotion 431 B
withMutation 1010 B
withName 109 B
withRelativePointer 1.29 kB
withResponsiveOptions 2.4 kB
withScrolledInView 3.05 kB

@studiometa/js-toolkit-v4

Export Size (gzip) Diff
(barrel) 22.67 kB
BREAKPOINTS 778 B
Base 8.59 kB
DIAGNOSTICS 714 B
DRAG_MODES 162 B
EVENTS 155 B
MOUNT_ATTRIBUTE 69 B
SWAP_MODES 129 B
children 244 B
createContext 472 B
createFallbackProvider 1.33 kB
createGroup 1.07 kB
createLocalStorage 2.33 kB
createMemoryStorageProvider 1.21 kB
createService 640 B
createServiceMixin 1017 B
createSessionStorage 2.33 kB
createStorage 2.3 kB
createUrlSearchParamsInHashProvider 1.21 kB
createUrlSearchParamsInHashStorage 2.34 kB
createUrlSearchParamsProvider 1.21 kB
createUrlSearchParamsStorage 2.34 kB
defaultScheduler 1.5 kB
defineManifest 983 B
domUpdate 1.23 kB
emitExtendable 1.09 kB
fromMetaGlob 203 B
fromWebpackContext 131 B
getBreakpoints 776 B
getInstances 2.86 kB
inject 176 B
injectContext 675 B
injectContextSync 634 B
jsonSerializer 95 B
localStorageProvider 1.21 kB
memoryStorageProvider 1.21 kB
namespaceQualifier 120 B
nextFrame 115 B
on 8.94 kB
perTarget 322 B
provide 182 B
provideContext 704 B
provideRootContext 748 B
read 126 B
registerManifest 11.33 kB
reportDiagnostic 329 B
sessionStorageProvider 1.21 kB
setBreakpoints 806 B
signal 925 B
subscribeContext 1.44 kB
swap 2.9 kB
toggle 177 B
until 172 B
urlSearchParamsInHashProvider 1.21 kB
urlSearchParamsProvider 1.21 kB
useBreakpoint 1.44 kB
useDrag 3.36 kB
useInView 1.43 kB
useKey 1.47 kB
useMediaQuery 1.06 kB
useMutation 1.4 kB
usePointer 1.85 kB
usePrefersReducedMotion 1.09 kB
useRaf 1.95 kB
useResize 1.43 kB
useScroll 2.67 kB
useScrollProgress 3.64 kB
useWindowScroll 2.66 kB
useWindowSize 1.43 kB
utils 9.25 kB
utils/DEFAULT_DAMP_FACTOR 109 B
utils/INERTIA_FRAME 97 B
utils/MAX_SPRING_RATIO 100 B
utils/SCROLL_ALIGNMENTS 117 B
utils/SCROLL_AXES 100 B
utils/TRANSFORM_PROPS 137 B
utils/TRANSITION_OPTIONS 132 B
utils/camelCase 449 B
utils/capitalize 119 B
utils/clamp 133 B
utils/clamp01 149 B
utils/clampDampFactor 157 B
utils/createEaseInOut 120 B
utils/createEaseOut 91 B
utils/createElement 638 B
utils/createRange 205 B
utils/damp 211 B
utils/debounce 121 B
utils/decayOver 162 B
utils/deepmerge 312 B
utils/easeInCirc 94 B
utils/easeInCubic 81 B
utils/easeInExpo 97 B
utils/easeInOutCirc 150 B
utils/easeInOutCubic 141 B
utils/easeInOutExpo 150 B
utils/easeInOutQuad 139 B
utils/easeInOutQuart 140 B
utils/easeInOutQuint 140 B
utils/easeInOutSine 156 B
utils/easeInQuad 80 B
utils/easeInQuart 81 B
utils/easeInQuint 81 B
utils/easeInSine 104 B
utils/easeLinear 77 B
utils/easeOutCirc 121 B
utils/easeOutCubic 111 B
utils/easeOutExpo 124 B
utils/easeOutQuad 110 B
utils/easeOutQuart 112 B
utils/easeOutQuint 111 B
utils/easeOutSine 132 B
utils/enterTransition 679 B
utils/fold 200 B
utils/getOffsetSizes 268 B
utils/historyPush 391 B
utils/historyReplace 392 B
utils/inertiaDecay 199 B
utils/inertiaFinalValue 187 B
utils/inertiaStep 232 B
utils/inertiaTimeConstant 178 B
utils/isBoolean 90 B
utils/isDefined 87 B
utils/isFunction 86 B
utils/isNull 78 B
utils/isNumber 103 B
utils/isObject 115 B
utils/isString 89 B
utils/kebabCase 421 B
utils/leaveTransition 679 B
utils/lerp 120 B
utils/loadImage 245 B
utils/loadLink 776 B
utils/loadScript 697 B
utils/lockScroll 565 B
utils/lowerCase 84 B
utils/map 128 B
utils/matrix 150 B
utils/mean 147 B
utils/memo 217 B
utils/noop 62 B
utils/noopValue 76 B
utils/objectToURLSearchParams 266 B
utils/pascalCase 434 B
utils/random 93 B
utils/randomInt 132 B
utils/randomItem 163 B
utils/round 130 B
utils/saveActiveElement 571 B
utils/scrollPosition 857 B
utils/scrollTo 1.86 kB
utils/selectorFor 2.78 kB
utils/setClassesOrStyles 218 B
utils/smoothTo 2.83 kB
utils/snakeCase 421 B
utils/spring 343 B
utils/throttle 151 B
utils/transform 286 B
utils/transition 577 B
utils/trapFocus 717 B
utils/untrapFocus 587 B
utils/upperCase 84 B
utils/wait 103 B
utils/withLeadingCharacters 142 B
utils/withLeadingSlash 152 B
utils/withTrailingCharacters 143 B
utils/withTrailingSlash 153 B
utils/withoutLeadingCharacters 127 B
utils/withoutLeadingCharactersRecursive 144 B
utils/withoutLeadingSlash 138 B
utils/withoutTrailingCharacters 129 B
utils/withoutTrailingCharactersRecursive 147 B
utils/withoutTrailingSlash 140 B
utils/wrap 154 B
viewTransition 1.66 kB
warn 321 B
watchAttributeNamespace 2.55 kB
watchAttributes 2.02 kB
whenDOMSettled 2.35 kB
withDrag 4.03 kB
withInView 2.12 kB
withKey 2.15 kB
withMutation 2.09 kB
withPointer 2.52 kB
withRaf 2.63 kB
withResize 2.11 kB
withScroll 3.34 kB
withScrollProgress 4.34 kB
write 124 B

@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.04%. Comparing base (fbb572d) to head (9cf051c).

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #862   +/-   ##
=======================================
  Coverage   97.04%   97.04%           
=======================================
  Files         175      175           
  Lines        4535     4535           
  Branches     1322     1323    +1     
=======================================
  Hits         4401     4401           
  Misses        122      122           
  Partials       12       12           
Flag Coverage Δ
eslint-plugin-js-toolkit 94.43% <ø> (ø)
js-toolkit 97.93% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

v3 mount benchmarks

Base and head measured on this runner, alternating over 3 rounds each; every value is the median of the round medians. Running both sides on one machine is what removes cross-machine noise — a cached baseline from another runner would put it back.

A move under 25%, or on a benchmark under 5 ms, is not reported as a change: it is inside the measured noise of a shared runner.

No benchmark moved beyond the noise floor.

Within noise (10)
Group Benchmark Base Head us / component Change
destroy 1000 flat components — v3 v3 — destroy 16.1 ms 15.2 ms 15.20 -5.6%
destroy 1000 flat components — v4 v4 — destroy 2.80 ms 3.40 ms 3.40 +21.4%
swap 1000 components — v3 v3 — control 0.30 ms 0.30 ms 0.30 0.0%
swap 1000 components — v3 v3 — flat 41.8 ms 40.4 ms 40.40 -3.3%
swap 1000 components — v3 v3 — nested 45.9 ms 48.8 ms 48.80 +6.3%
swap 1000 components — v3 v3 — realistic 121.8 ms 124.0 ms 124.00 +1.8%
swap 1000 components — v4 v4 — control 2.10 ms 2.00 ms 2.00 -4.8%
swap 1000 components — v4 v4 — flat 16.6 ms 17.3 ms 17.30 +4.2%
swap 1000 components — v4 v4 — nested 15.0 ms 15.8 ms 15.80 +5.3%
swap 1000 components — v4 v4 — realistic 74.7 ms 78.0 ms 78.00 +4.4%

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

v4 mount benchmarks

Base and head measured on this runner, alternating over 3 rounds each; every value is the median of the round medians. Running both sides on one machine is what removes cross-machine noise — a cached baseline from another runner would put it back.

A move under 25%, or on a benchmark under 5 ms, is not reported as a change: it is inside the measured noise of a shared runner.

Group Benchmark Base Head us / component Change
mount 1000 components, one insertion in-view — one controller per element 23.6 ms 30.7 ms 30.70 +30.1%
Within noise (17)
Group Benchmark Base Head us / component Change
destroy 1000 flat components, one removal flat 3.10 ms 2.60 ms 2.60 -16.1%
destroy 5000 flat components, one removal flat 16.0 ms 15.9 ms 3.18 -0.6%
mount 1000 components, one insertion control — declared but unregistered 1.70 ms 1.80 ms 1.80 +5.9%
mount 1000 components, one insertion flat 15.5 ms 15.5 ms 15.50 0.0%
mount 1000 components, one insertion nested 4 deep 11.7 ms 12.4 ms 12.40 +6.0%
mount 1000 components, one insertion realistic — 5 refs, 3 options, 4 handlers 61.9 ms 63.4 ms 63.40 +2.4%
mount 1000 components, one insertion responsive option — breakpoint cascade per mount 14.8 ms 14.3 ms 14.30 -3.4%
mount 1000 flat components, 1 vs 10 insertions 1 insertion 10.1 ms 10.4 ms 10.40 +3.0%
mount 1000 flat components, 1 vs 10 insertions 10 insertions 11.7 ms 10.5 ms 10.50 -10.3%
mount 5000 components, one insertion control — declared but unregistered 15.7 ms 15.6 ms 3.12 -0.6%
mount 5000 components, one insertion flat 63.8 ms 64.6 ms 12.92 +1.3%
mount 5000 components, one insertion in-view — one controller per element 156.8 ms 146.2 ms 29.24 -6.8%
mount 5000 components, one insertion nested 4 deep 60.7 ms 63.7 ms 12.74 +4.9%
mount 5000 components, one insertion realistic — 5 refs, 3 options, 4 handlers 308.2 ms 306.8 ms 61.36 -0.5%
mount 5000 components, one insertion responsive option — breakpoint cascade per mount 77.8 ms 76.3 ms 15.26 -1.9%
mount 5000 flat components, 1 vs 10 insertions 1 insertion 58.2 ms 61.4 ms 12.28 +5.5%
mount 5000 flat components, 1 vs 10 insertions 10 insertions 61.3 ms 57.7 ms 11.54 -5.9%

titouanmathis and others added 5 commits August 24, 2026 14:51
`registerComponent()` has no inverse by design — a page registers once and
keeps it — and a test suite is the one caller for which that is wrong. The
registry is page-wide and survives `resetDom()`, so a name taken by one spec
is still taken in the next, and the workaround it forces is a counter minting
`Widget-1`, `Widget-2`, which tells the reader nothing.

`resetRegistry()` is coarse rather than a per-name `unregisterComponent()`
because the targeted form cannot be written correctly: the element-to-
controller map is a `WeakMap`, so nothing can enumerate it to dispose the live
triggers it holds, and a `querySelectorAll()` sweep would still miss every
detached element. Clearing everything works because the mutation-observer path
already disposes a controller as its element leaves the DOM — which is why the
doc comment makes `resetDom()` first part of the contract, says outright that
the `WeakMap` is not cleared, and states where the call belongs: an `afterAll`
or an explicit reset followed by re-registration, never a bare `afterEach`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnepYhqjPMcFCSb43PoHRM
`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 mechanism, and none of
it is guessable from outside: a consumer would have to know the channel name,
the detail shape, and that cancelling a cancelable diagnostic suppresses its
default sink.

The spy is also the weaker assertion. It reads a formatted string, so it cannot
tell one code from another, cannot see the severity or the reporting component,
and breaks on a wording change — while a diagnostic reported with the wrong
code passes it. `captureDiagnostics()` collapses collecting and silencing into
one call and hands back both the codes and the full details, so the assertion
lands on what the framework reported rather than on where it happened to print.

The spec records one trap found on the way: `mockRestore()` also clears the
call history, so a spy restored before its own `expect` always looks unused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnepYhqjPMcFCSb43PoHRM
Seven spec files hand-roll a variant of this, and two shapes exist in the
wild: some collect the type only, some collect `{ type, detail }`. The richer
one ships, because mapping down to names is free and mapping up is impossible.

Recording several types into one array is the point rather than a convenience.
A component's contract is a sequence — `open` before `opened`, and `opened`
carrying the height it measured — and that relative order is visible nowhere
else; a per-listener spy that counts calls throws away both the order across
names and the payloads.

The doc comment states the timing rule the shape invites getting wrong.
`$emit` dispatches synchronously, but almost nothing calls it synchronously:
the emit follows a mount, a scheduled write or a transition, and the call that
started that chain has already returned. So the count is awaited through
`waitFor`, not read.

This reverses one line of the module's own "what was refused" list, where an
event recorder was grouped with fetch stubs and pointer sequences. That was
right about `vi.fn()` replacing a counter and wrong about what the seven copies
were doing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnepYhqjPMcFCSb43PoHRM
The implementation stays in `registry.ts`, next to the module-private state it
clears; only the re-export lives here, because a test suite is its one caller
and a page has no use for it. It is deliberately not on the root barrel, which
keeps its export count at 86.

Its cases show both halves of the reason it exists: the same name registered
twice reports a conflict without it and registers cleanly after it, and a
component registered earlier stops mounting once the registry is emptied. The
block's own `afterEach` is the documented pattern in miniature — the reset,
then the registrations still needed — because a bare reset in an `afterEach`
would unregister this file's top-level components for every later test.

`resetDom()` deliberately does not call it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnepYhqjPMcFCSb43PoHRM
DESIGN §13 said "the five helpers" and listed event recorders among what was
deliberately refused, so both statements had to change rather than be quietly
left standing. The refusal is recorded as a reversal, with what it got right
and what it got wrong.

RATIONALE §13 gains the two arguments that are not obvious from the code: why a
diagnostic is asserted on the channel and not on the console — the spy is not
just redundant, it is the weaker assertion, and it passes for the wrong code —
and why `resetRegistry()` is coarse, which is that the targeted per-name form
cannot be written correctly over a `WeakMap` at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NnepYhqjPMcFCSb43PoHRM
*/
export function recordEvents(target: EventTarget, ...types: string[]): EventRecording {
const events: RecordedEvent[] = [];
const listener = (event: Event) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Enforce zero-duration timeouts before sleeping

When timeout is 0 and the predicate is falsy, Date.now() > deadline is usually false at the same millisecond, so the helper sleeps for 10ms and drains the scheduler before rejecting. This violates the documented timeout option and makes an explicit zero-timeout call non-immediate; use a deadline check that handles equality (or otherwise reject before the polling delay).

Confidence: high.


Reviewed by @weareikko/code-review v0.9.5 for commit 9cf051c.

@titouanmathis
titouanmathis merged commit 7ebd65d into main Aug 24, 2026
12 checks passed
@titouanmathis
titouanmathis deleted the feat/v4-test-module branch August 24, 2026 13:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant