fix(injector): decide style lifetime by the DOM, not by ref counts - #281
fix(injector): decide style lifetime by the DOM, not by ref counts#281tenphi wants to merge 23 commits into
Conversation
Since the render path became hook-free it no longer disposes the classes it
injects, so `refCount` only ever grew. Everything that read `refCount === 0` as
"unused" therefore found nothing: `gc()` and `cleanup()` never evicted a single
rule, and `tastyDebug` reported `Unused: 0 classes` while silently dropping every
injected-but-detached class from its totals.
Lifetime now follows the DOM. `gc()` collects classes no element carries, keeping
the `capacity` most recently used, and `refCount` becomes what it can honestly
be — an explicit pin held by `inject()` callers until they dispose. `inject()`
takes `{ track: false }` for callers that keep no handle, which is what the
render path passes; that also drops two Map writes per chunk from the cached
render path. `cleanup()` is now `gc({ force: true })`, and `performBulkCleanup`
gives way to `SheetManager.deleteClasses`, which deletes an explicit list and
reports how many it removed.
Two hazards only mattered once GC could actually delete, and are fixed here too:
the scheduled GC no longer runs inline during a render (the missing-rIC fallback
was synchronous, and `touch()` runs in React's render phase), and an injection
seeds its LRU stamp so the rules the current render just wrote are the last
eviction candidates rather than the first. Cache-key cleanup now scans the map
once per batch instead of once per deleted class.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
📦 Snapshot releasePublished |
…th tests
Follow-up to the lifetime fix. `refCount` was the name that caused the bug: it
read as "how many users does this class have", so three separate call sites
trusted it long after the render path had stopped maintaining it. The counter
only ever meant "someone holds a dispose handle", so it is now `pinCounts`, the
`inject()` option is `{ pin: false }`, and the private release path is `unpin()`.
"Unused" now has exactly one definition, `StyleInjector.collectUnused()`, exposed
as `getUnusedClasses()`. `gc()` evicts from it, `getMetrics()` counts it, and
`tastyDebug` reports it by calling it rather than reimplementing the predicate —
which is how debug drifted from the injector in the first place.
`gc-render.test.tsx` becomes `style-lifetime.test.tsx`, the single home for the
contract, and grows the cases that would have caught the original breakage:
the summary must account for every class the injector holds (the reported
symptom was 271 classes reported against 379 held), tastyDebug must report
exactly what a forced `gc()` then deletes, metrics must count the same set, the
scheduled GC must collect with no manual call, a rich multi-chunk style object
must be fully collectible, a custom `namePrefix` must survive the DOM scan, and
pinned or server-rendered classes must never even be *reported* as unused — not
merely spared from deletion, which `deleteClasses` was already backstopping.
Verified by mutation: reintroducing the original bug fails 14 of these; breaking
only the debug side fails 7; dropping either guard in `collectUnused` fails the
matching case. Also raises the timeout on the public-api runtime cross-check,
whose dynamic import overruns the 5s default whenever the browser project is
competing for the same cores.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review caught that deferring the sweep is not enough. A zero-delay task is
asynchronous but not commit-aware: a concurrent render can yield after
computeStyles() injects and touches a class and before React commits the element
carrying it, and the sweep landing in that gap sees no matching DOM node and
deletes rules the pending render is about to use. Reproduced exactly as
reported — no requestIdleCallback, `{ touchInterval: 1, capacity: 0 }`,
computeStyles(), one setTimeout(0), then attach: registry and CSS already gone.
An idle callback can land in the same gap when a low-priority render is paused.
Classes now carry the sweep generation they were injected or touched in, and the
scheduled sweep spares the current one: a class whose element has not been
committed yet is indistinguishable from a dead one, so it gets until the next
sweep to prove itself. The generation advances on every scheduled sweep, even
one the capacity check skipped, so nothing becomes permanently exempt. An
explicit gc() or cleanup() is a decision the caller just made and still collects
immediately.
Also gives the idle callback a timeout. Without one it can be starved
indefinitely, and a page that never goes idle is exactly the one accumulating
styles fastest.
Both scheduling paths are covered by the reported repro, plus a case proving the
spare lasts one sweep rather than forever; dropping the guard fails five of them.
Two existing tests were coupled to the old shape: one spied on `gc` (the
scheduled path now calls the shared sweep directly) and now asserts the
generation advanced, and the interval test leaned on touch()'s same-millisecond
dedupe landing a particular way, which made it flaky — it now varies the class
name per render pass so each pass definitely touches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review was right that one generation of grace is still a heuristic, and that the test I added codified the failure rather than ruling it out: concurrent work can stay pending across an unbounded number of turns and sweeps, so any count-based or time-based window can still delete rules a pending render is about to attach. Protection is now tied to the only commit signal a hook-free render path has — the sweep's own DOM scan. Each usage entry records whether a sweep has seen the class on an element since a render last claimed it: injection and touch() clear that flag, the scan sets it. The scheduled sweep collects observed classes only, so a class whose element has not been committed is never automatically evicted, however long the render takes. The same flag closes the reuse case, where a cache hit hands an in-flight render a class that is already in the registry and no longer in the DOM. The generation counter is gone; what remains of it is a sweep count for diagnostics and tests. The trade-off is stated rather than hidden: a class that mounts and unmounts between two sweeps is never seen, so it is not collected automatically. It stays cached and reusable, and explicit gc()/cleanup() still take it. There is a test that pins exactly this. Reverts the idle-callback timeout from the previous commit. It was an unbenchmarked performance change riding along with a correctness fix, and on the pages most likely to hit it — no idle budget, large DOM — it would force an unchunked querySelectorAll and full rule walk onto a busy main thread. Idle-only again; if starvation needs addressing it should be an explicit option with a large-DOM benchmark behind the deadline. Dropping the observation guard fails five tests, including the reported repro through both scheduling paths and a case that holds a render pending across four sweeps. Bundle stays under the size limit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…out fallback Review demonstrated a third way past the protection: `observed` is per class, so the DOM scan cannot tell which render produced the element it found. An older element still mounted satisfies a newer pending claim, and once that element unmounts the class is collected out from under the render that has yet to commit. Reproduced as described. That is not a gap in the rule, it is the rule's ceiling. A pending render and a render that already committed and unmounted are indistinguishable from outside: both present as claimed-at-T, absent-now, and React reports neither commit nor discard for a class name handed out during render. Any per-class flag, counter, generation or deadline is therefore a heuristic that a sufficiently delayed render defeats — which is what three counterexamples in a row established. So the heuristics are removed rather than iterated on: no sweep generations, no observation flag, no idle deadline. Scheduling is back to `requestIdleCallback`, which modern engines have. What remains from this PR is what stands on its own — collection driven by the DOM instead of a pin count nothing maintains, one definition of "unused" shared by gc(), metrics and tastyDebug, and explicit gc()/cleanup() that work. The one part of the fallback worth keeping is now opt-in. Where `requestIdleCallback` is missing, the sweep used to run inline — inside the very render that touched the class, which is the worst place for it. It is now skipped unless `gc.timeoutFallback` asks for a deferred timeout instead. The race the review found is documented on the GC section rather than papered over, since automatic collection is opt-in and explicit collection runs when the caller chooses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review's point stands: because the broken pin counts blocked every deletion,
`configure({ gc: ... })` collects nothing today, so making collection work would
have moved those apps straight onto a sweep that can delete rules an in-flight
concurrent render is about to attach. Documenting that race does not stop this
PR from introducing it.
Automatic sweeping is therefore off unless `gc.unsafeAutoCollect` asks for it,
and the flag is named for what it is. Explicit `gc()` and `cleanup()` are
unchanged: they collect when called, at a moment the caller picked.
`touchInterval` and `timeoutFallback` now read as what they are — settings for
that opt-in mode. `touch()` still maintains the LRU stamps either way, since
explicit capacity-based collection orders by them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sed-section-9e584b
Rendering resolves class names and records what they stand for; the commit puts
them in the sheet. Each styled component takes one `useInsertionEffect`, keyed
by the class-name string, that acquires its whole chunk set on mount and
releases it on unmount, and only a class that was held and then fully released
can be collected.
This is what makes collection safe, and it is a different argument from the ones
review kept knocking down. Those all tried to prove no pending render was
holding a class, which is undecidable from outside React. This does not try:
collection may delete a class a pending render is about to use, and the effect
re-inserts it from its recipe when that render commits, before any layout effect
runs. Deletion is recoverable rather than prevented, so the reported
older-element-satisfies-a-newer-claim case is simply not a case any more.
Consequences worth stating:
- No `querySelectorAll('[class]')` anywhere. Collection is map operations over
what was released, not a scan of the page.
- A bare `computeStyles()` has no commit to put its rules back, so it injects
during the call as before and pins the class. RSC and SSR are unaffected: the
hook is taken only where there is a document, which keeps `tasty()` usable as
a server component.
- `touch()` is a deprecated no-op and the usage map is gone; releases drive the
schedule now, via `gc.releaseInterval`.
- `gc.unsafeAutoCollect` and `gc.timeoutFallback` are gone with the hazard that
needed them.
- The factory class-name cache now checks the recipe still exists, so a name
that outlived its injector cannot commit to nothing.
- Bundle limit 56.6 -> 56.9 kB for the recipe store and the commit lifecycle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first cut of the commit model cost 9-14% on cached rerenders, which is outside the gate this design was accepted under. Two things were paying for it: a managed render hashed its cache key into a class name on every render where the old path did a map lookup, and the factory class-name cache checked itself against the recipe store through two calls and a map lookup. Class-name resolution is now memoized per cache key, and the memo carries whether the CSS has been described, so a managed render is one lookup rather than a hash plus a second lookup. The factory cache compares a style epoch — one integer read — that changes when the global injector is replaced, which is the only way a cached name can outlive what it stands for. Measured against main (two runs each, chromium): representative tree update, cached 232.3 -> 230.2 hz -0.9% (rme 3-4%) representative tree update, 20 new 237.0 -> 219.1 hz -7.6% micro: tasty cached rerender 97.6 -> 90.3 hz -7.5% micro: tasty mount + remove 77.5 -> 73.7 hz -4.9% micro: tasty style change 68.3 -> 64.8 hz -5.1% The representative cached update is flat. The micro-benchmarks, which measure one component doing nothing else, still carry the per-component hook: a deps array and a setup closure per render that React allocates whether or not the deps changed. That is the standing cost of having a commit signal at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review is right that the cost was universal: every browser `tasty()` instance
took the insertion effect and the managed render path, including applications
that never enable collection and `tasty({})` with no styles at all. `gc` is
opt-in, so the machinery it exists for should be too.
Components now take the managed path only where a document exists and `gc` is
configured. Everything else keeps the synchronous path exactly as before — no
hook slot, no dependency array, no setup closure per render. The gate is
memoized against the style epoch because it decides whether a hook is taken and
must not move between renders of one instance; `configure()` locks once the
first styles are generated, so it settles on the first render.
Measured against main (chromium), applications without `gc`:
micro: tasty cached rerender 97.6 -> 95.7 hz -2.0%
micro: tasty mount + remove 77.5 -> 75.8 hz -2.2%
micro: tasty style change 68.3 -> 68.3 hz +0.1%
representative tree update, cached 232.3 -> 249.7 hz +7.5% (rme 3.8%)
The tree gain is real rather than noise: the render path no longer calls
`touch()` for every chunk of every render, which is work main was doing on
behalf of a GC that could not collect anything.
Bundle limit 56.9 -> 57.1 kB for the second render path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review is right that the managed path was still injecting keyframes during render and dropping the disposer on the floor. That contradicted the contract this PR introduced — a discarded render left CSS behind — and it took a reference on every rerender that bypassed the factory cache, one that nobody ever gave back, so collection could never reclaim them. Keyframes are now part of the recipe rather than a render-time side effect. They go into the sheet in `acquire()`, with the rules that animate them, and the declarations are rewritten there against the names that injection actually returned — which is also the first time that rewrite has been reliable, since the old order re-injected under a cache key that had already been claimed. The disposers are held per class and run when the rules are deleted, so keyframes outlive nothing and nothing outlives them. Tied to rule deletion rather than to release: collection is deferred, so a class released to zero and re-acquired before the pass keeps both its rules and the keyframes they reference. Covered by the two cases review asked for — a render that never commits writes none, and repeated rerenders followed by unmount leave none — plus disposal on collection and re-injection when a later commit brings the class back. Removing the disposal fails three of them. The unmanaged path is unchanged: it injects during render as before, and its classes are pinned, so nothing there was ever collectible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught what I did not: I checked the main bundle locally and missed that `core` (+131 B) and `babel-plugin` (+262 B) were over too. Both pull the injector in through `config`, so the commit lifecycle and the second render path land in them as well. Limits raised to match, with headroom: core 53.65 -> 53.95 kB, babel-plugin 49.35 -> 49.8 kB. Nothing to trim here — the second render path exists because applications without `gc` should not pay for the first one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the commit lifecycle — the per-component `useInsertionEffect`, the recipe store, acquire/release — with the far smaller thing that solves the same problem: never delete a rule that was in use recently. The hazard was always one shape. A render resolves a class and commits it a little later, and in between nothing on the page carries it, which from outside React is indistinguishable from a class that is finished. Every attempt to tell those apart failed, because React reports neither commit nor discard. So this does not try. A class counts as wanted when it is injected and again every sweep that finds it on an element, and collection leaves alone anything wanted within `gc.grace` — 10s by default. A render would have to stay pending for the whole window to lose its rules, and it gets them back on its next render. It costs nothing to run. The timestamp is written by the sweep's own DOM scan, which already walks the live classes, so rendering tracks nothing per class: `touch()` is now a render counter and the usage map is gone. Measured against the same benchmark with gc off, on a quiet machine, bracketed both sides: micro: same-props update 108.3 -> 107.9 hz -0.3% (drift 1.5%) micro: host-prop update 75.3 -> 74.9 hz -0.6% (drift 0.1%) micro: mount + remove 80.6 -> 78.6 hz -2.4% (drift 5.6%) For comparison the insertion-effect version cost -10.3%, -7.3% and -5.6% on the same three, and was only paid for by applications that opted into collection. This is free enough that automatic collection is simply on whenever `gc` is configured, with no gate and no second render path. Smaller everywhere else too: six exports fewer, five bundles back under their original size limits, and `tasty()` is hook-free again, so nothing changes for RSC or SSR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught the main bundle 114 B over. The last commit added real code — per-class keyframe ownership and the hot band in the summary — so there is nothing to give back beyond keying local keyframes on the same string `keyframes()` already dedupes on, which drops the extra hash and 26 B with it. Limit 56.6 -> 56.9 kB, main only. The other four are untouched and under. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review found the rename never reaching the class rule, and it is the bug I had described in an earlier reply and then reintroduced by reverting the design that fixed it. Chunk CSS was written first and the corrected declarations second, under a cache key the first write had already claimed — so the second write was a hit and the rewrite was dropped. A local `fade` colliding with another `@keyframes fade` became `fade-tk0` while the rule still said `animation: fade`. `holdKeyframes()` now only takes the reference and reports the names, and the chunk write applies them. Ownership moved out to `ownKeyframes()`, called once the rules exist to inspect — and only for chunks whose declarations actually name the animation, so a colour chunk that happened to render alongside an animated one no longer keeps its keyframes alive for its own lifetime. Also from review: `cache().classes.all` had the same hole `summary()` did, and was still active plus eviction-eligible, so a just-detached class appeared in neither. It is built from everything held now, with a `Held:` count alongside. `touch()` stays a render counter per the maintainer's call — its class argument is ignored and it is marked deprecated rather than made to preserve the old per-class refresh. Reuse is marked wanted in `inject()`, which is the path that actually matters. Changeset stays minor. Bundle limits: core 53.65 -> 53.9 kB alongside main's earlier move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two more from review, both in the fix from the last commit. The rewrite moved before injection, but the cache key did not follow it. Two components authoring the same `animation: fade 1s` over different local `@keyframes fade` share a chunk key derived from the authored styles alone, so the second — injected under another name — was handed the first's class and went on animating the first's keyframes. The key now carries which keyframes the rules ended up animating, and only when a rename applied, so nothing else re-keys. My collision test had missed this by varying the second duration, which changes the key on its own and proved nothing; it now keeps the shorthand identical. Ownership matched declaration substrings, so an animation called `crossfade` counted as a use of keyframes called `fade` — a component sharing only the crossfade chunk kept `fade` alive after the class that really ran it was gone. `referencesAnimation()` sits next to `replaceAnimationNames()` in the keyframes module and parses the same way, so what counts as running an animation is decided once: `animation` and `animation-name` values, whole tokens only. Both scenarios are tests, and each mutation — dropping the key suffix, going back to a substring match — fails exactly one of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review found the previous fix was client-only, and worse than incomplete: the chunk key suffix came from a name the client injector happened to hand out, so the server allocated a different class for the same component. Two components with identical `animation: fade 1s` over different definitions got one class on the server and two on the client — a hydration mismatch — and the SSR collector deduplicated the two `@keyframes fade` rules by name, so the second component ran the first one's animation. A local `@keyframes` is now emitted as `<authored>-<hash of its steps>`, which is a pure function of what was written. The client, the SSR collector and the RSC pass all resolve it the same way and fold it into the chunk's cache key through one shared helper, so all three agree on the animation name and on the class that references it, without coordinating. Collisions cannot happen: different steps are different names by construction. That also removes the runtime rename entirely — there is no longer a first definition that keeps the plain name and a second that gets renamed, which is what made the old behaviour order-dependent. New parity tests run a server render and a client one and compare: one definition gives the same name and class on both, two different definitions stay apart on both. Making the resolver return the authored name fails four tests across the two suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sed-section-9e584b
… total Two more from review, plus a merge of main. `keyframes()` caches by steps alone and returns whatever name got there first, so two authored animations describing the same movement — `fade` and `spin` with the same from/to — collapsed onto one rule. The class rewritten to `spin-1xolwbf` then animated nothing, because only `fade-1xolwbf` existed. Local keyframes now ask for a rule under their own name via `distinctByName`, which scopes the low-level cache key by name. Fixing it the other way round — having the rewrite follow whatever name came back — would have put the runtime ordering back into the output, which is exactly what the deterministic naming removed. `summary()` still under-counted: raw blocks live in the sheet manager's own store rather than in `registry.globalRules`, so `rawRuleCount` was always zero and raw CSS was missing from the total; and `@font-face`, `@counter-style` and `@function` use key prefixes the scan never looked for. The prefixes are now a named map, so a rule type that is held but not counted has to be an omission from one list rather than from a chain of ternaries, and raw CSS is read from where it actually lives. A test builds one of every rule type and asserts the reported total equals what the sheets hold. Bundle limits: main 56.9 -> 57.2 kB, core 53.9 -> 54.2 kB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…total
Two more from review, both about the summary still not being a summary of
everything.
`countRules()` counts brace-delimited blocks, which is neither the number of
rules nor the number of raw blocks: one `@keyframes` is three blocks and one
rule. Raw rules are now counted from the parsed sheet through a new
`SheetManager.getRawRuleCount()`, which is the only place that knows.
`totalCSSSize` and `css('all')` read `getCSSText()`, which covers the managed
sheets only — raw CSS has its own — so raw bytes were missing from the total and
from a view the debug docs describe as component + global + raw. Both include it
now.
The test that was supposed to catch the first of these did not: it counted
braces over `getCSSText()`, which excludes the raw sheet while double-counting a
two-step keyframes, and the two errors cancelled for that fixture. It reads
`cssRules.length` off the parsed sheets now, including the raw one, and gained a
case for a single raw at-rule. Each fix fails a test when reverted, which the
old helper could not have told me.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two more from review, both in what the last commit added.
`css('all')` always put managed CSS before raw, but the raw sheet sits wherever
it landed: prepended to `adoptedStyleSheets`, or wherever in `<head>` the first
raw injection happened. Where two rules of equal specificity meet, a fixed order
names the opposite winner from the live page — which makes the view worse than
not having it, since it reads as authoritative. `SheetManager` assembles the
order now, because it is the only thing that knows how its own sheets are
arranged.
`totalCSSSize` trimmed the joined string, so raw CSS with edge whitespace — an
ordinary multiline template literal — made the total smaller than
`rawCSSSize`, a total smaller than one of its own parts. The sources are kept
byte-for-byte and only joined when both are present.
Both are tests, and both mutations fail one: a fixed managed-then-raw order,
and trimming the join. Bundle limits: main 57.2 -> 57.5 kB, core 54.2 -> 54.5 kB.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| if (!raw) return managed ? [managed] : []; | ||
| if (!managed) return [raw]; | ||
|
|
||
| return this.isRawFirst(registry, root) ? [raw, managed] : [managed, raw]; |
There was a problem hiding this comment.
[P2] Preserve the raw position among all managed sheets
This reduces placement to one bit around a single aggregated managed string, so it cannot represent a raw sheet between managed sheets. I reproduced it with maxRulesPerSheet: 1: compute one class, inject raw CSS, then compute a second class. The DOM order is first managed sheet, raw sheet, second managed sheet, but css(all, { prettify: false }) returns first class, second class, raw rule because isRawFirst only compares against the first managed element. Equal-specificity rules again show the wrong cascade winner. The added test covers raw-before-all only. Please serialize managed sheets individually and merge the raw sheet at its actual DOM position; adopted mode can retain its always-first shortcut.
| const sheetManager = injector.instance._sheetManager; | ||
| if (!registry || !sheetManager) return ''; | ||
|
|
||
| return sheetManager.getOwnedCSSInOrder(registry, root).join('\n'); |
There was a problem hiding this comment.
[P2] Flush queued writes before reading all CSS
getAllCSS now calls SheetManager directly, bypassing the flush performed by every injector read API. I reproduced this with batchInjection: always: after computeStyles() and injectRawCSS(), hasPendingStyleWrites() is true; calling tastyDebug.css(all) leaves it true and returns before either queued rule reaches a sheet. The previous implementation called injector.getCSSText()/getRawCSSText(), so this read was a flush point as documented by the batching contract. Please flushStyles() before gathering the registry sheets, or expose the ordered read through a flushing StyleInjector method.
What was broken
tastyDebug.summary()reportsUnused: 0 classes, 0 rules, 0Bon any app, while the Chunks line counts far more classes than the Active line:The ~108 injected-but-detached classes were dropped from the summary entirely.
Root cause:
refCountis decremented only by thedisposehandleinject()returns, and since the hook-free refactor (3d06dba, v3) the render path callsinject()and throws that handle away. There is no unmount signal at all, sorefCountbecame a write-only counter that never reaches0.Three things read
refCount === 0as "unused", so all three were dead:tastyDebug—summary(),cache()andcss('unused')always came up empty.gc()— the per-classrefCount > 0guard skipped every class. Verified withcapacity: 0and everything unmounted:gc({ force: true })swept 0. Injected CSS grew for the lifetime of the page.cleanup()/tastyDebug.cleanup()—performBulkCleanupfiltered onrefCount === 0, so it never selected a rule.What a sweep does now
What a style is worth keeping is decided by the DOM. A sweep scans for the classes actually on the page and sorts everything the injector holds into five bands — only the last is ever deleted:
gc.graceago (default 10s)gc.capacitywhen ordered by when it went coldgc({ force: true })andcleanup()take bands 4 and 5 together, ignoring capacity. Band 3 is spared even then — an explicit cleanup is still no reason to take rules from a render that has not committed yet.Band 3 is what makes this safe without a commit signal. Rendering is not commit-aware: a render can resolve a class and commit it a little later, and in between nothing on the page carries it. From outside React that is indistinguishable from a class that is finished — React reports neither commit nor discard — so collection does not try to tell them apart. A render would have to stay pending for the whole window to lose its rules, and it gets them back on its next render.
The clock starts when a sweep notices, not when the element actually left: nothing observes that moment, so the sighting is the only honest starting point, and every class gets the same full window however long ago it went.
It costs nothing to run
The timestamps are written by the sweep's own DOM scan, which already walks the live classes, so rendering tracks nothing per class.
touch()is now only a render counter and the usage map is gone. Measured against the same benchmark withgcoff, on an idle machine, bracketed both sides:Four of the five bundles come out smaller than main; the main bundle grows ~150 B and its limit moves 56.6 -> 56.9 kB, for keyframe ownership and the fuller summary accounting.
How it got here
The design took a long detour that is worth knowing about, because review drove it:
useInsertionEffectper styled component. Correct, but it cost −10.3% on re-renders, put a hook back intotasty()(which v3 removed for RSC), and had to be gated behind an opt-in so provider-less apps did not pay for it.MutationObserveralternative was prototyped — hook-free and much better on re-renders, but it stalled the representative benchmark. Parked onproto/mo-commit-signalwith two bugs found and fixed and one stall unexplained.Notes for the reviewer
StyleUsageremoved,InjectOptionsadded.touch()is now a render counter — it no longer tracks per-class usage.gcconfig istouchInterval,capacity,grace.tasty()is hook-free, so RSC and SSR are untouched.requestIdleCallbacknothing is collected automatically; running the sweep inline would put it inside the render that touched the class.refCounts→pinCounts. The old name is what caused the bug: three call sites read it as "how many users does this class have" long after the render path stopped maintaining it. It only ever meant "someone holds a dispose handle".@keyframesare now disposed with the class that animates them, instead of leaking a reference per render. Moving the animation-name rewrite to injection time is also the first point at which that rewrite has worked — the old order re-injected under a cache key that had already been claimed, so it was silently dropped.Testing
src/style-lifetime.test.tsxis the contract suite: what is rendered survives, what went cold is collected, pinned and server-rendered classes are never touched, andgc(),getMetrics()andtastyDebugnever disagree about what "unused" means — including the accounting invariant the reported bug violated (271 classes reported against 379 held).src/debug.test.tsxis new; that module had no tests.2134 tests pass. Hygiene, build,
check:test-onlyand all five size limits green.🤖 Generated with Claude Code