Skip to content

fix(injector): decide style lifetime by the DOM, not by ref counts - #281

Open
tenphi wants to merge 23 commits into
mainfrom
claude/tastydebug-unused-section-9e584b
Open

fix(injector): decide style lifetime by the DOM, not by ref counts#281
tenphi wants to merge 23 commits into
mainfrom
claude/tastydebug-unused-section-9e584b

Conversation

@tenphi

@tenphi tenphi commented Aug 27, 2026

Copy link
Copy Markdown
Owner

What was broken

tastyDebug.summary() reports Unused: 0 classes, 0 rules, 0B on any app, while the Chunks line counts far more classes than the Active line:

Active:   271 classes, 1041 rules, 263.2KB
Unused:   0 classes, 0 rules, 0B
Total:    271 classes, 1565 rules, 542.8KB
Chunks (8 types, 379 classes)

The ~108 injected-but-detached classes were dropped from the summary entirely.

Root cause: refCount is decremented only by the dispose handle inject() returns, and since the hook-free refactor (3d06dba, v3) the render path calls inject() and throws that handle away. There is no unmount signal at all, so refCount became a write-only counter that never reaches 0.

Three things read refCount === 0 as "unused", so all three were dead:

  1. tastyDebugsummary(), cache() and css('unused') always came up empty.
  2. gc() — the per-class refCount > 0 guard skipped every class. Verified with capacity: 0 and everything unmounted: gc({ force: true }) swept 0. Injected CSS grew for the lifetime of the page.
  3. cleanup() / tastyDebug.cleanup()performBulkCleanup filtered on refCount === 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:

Band Deleted
1 Rendered — some element carries the class right now never
2 Not ours — queued for a batched write, pre-allocated, or server-rendered never
3 Hot — nothing carries it, but that was noticed less than gc.grace ago (default 10s) never
4 Cached — cold, but within gc.capacity when ordered by when it went cold never
5 Everything else on every sweep

gc({ force: true }) and cleanup() 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 with gc off, on an idle machine, bracketed both sides:

case gc off gc on
same-props update 108.3 107.9 −0.3% (drift 1.5%)
host-prop update 75.3 74.9 −0.6% (drift 0.1%)
mount + remove 80.6 78.6 −2.4% (drift 5.6%)

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:

  1. First cut collected on a DOM scan alone. Review showed a sweep can land between a render resolving a class and the commit attaching it.
  2. Two heuristics were tried and broken by counterexample — a one-sweep generation guard, then a per-class "has a sweep seen it" flag, which an older element still mounted can satisfy on behalf of a newer pending claim.
  3. That led to a commit signal: one useInsertionEffect per styled component. Correct, but it cost −10.3% on re-renders, put a hook back into tasty() (which v3 removed for RSC), and had to be gated behind an opt-in so provider-less apps did not pay for it.
  4. A MutationObserver alternative was prototyped — hook-free and much better on re-renders, but it stalled the representative benchmark. Parked on proto/mo-commit-signal with two bugs found and fixed and one stall unexplained.
  5. The grace window replaced all of it: instead of proving a render is not pending, simply never delete what was in use recently. No hook, no observer, no second render path, and free on the hot path.

Notes for the reviewer

  • Public API: StyleUsage removed, InjectOptions added. touch() is now a render counter — it no longer tracks per-class usage. gc config is touchInterval, capacity, grace.
  • tasty() is hook-free, so RSC and SSR are untouched.
  • Scheduling is idle-only. Without requestIdleCallback nothing is collected automatically; running the sweep inline would put it inside the render that touched the class.
  • refCountspinCounts. 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".
  • Local @keyframes are 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.
  • The grace window is a heuristic. It is bounded by a configurable window rather than by luck, but it is a heuristic, and you rejected two earlier ones. Flagging it rather than letting it pass quietly.

Testing

src/style-lifetime.test.tsx is the contract suite: what is rendered survives, what went cold is collected, pinned and server-rendered classes are never touched, and gc(), getMetrics() and tastyDebug never disagree about what "unused" means — including the accounting invariant the reported bug violated (271 classes reported against 379 held). src/debug.test.tsx is new; that module had no tests.

2134 tests pass. Hygiene, build, check:test-only and all five size limits green.

🤖 Generated with Claude Code

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>
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

📦 Snapshot release

Published 0.0.0-snapshot.342cc63.

pnpm add @tenphi/tasty@0.0.0-snapshot.342cc63

…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>
Comment thread src/injector/injector.ts Outdated
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>
Comment thread src/injector/injector.ts Outdated
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>
Comment thread src/injector/injector.ts Outdated
…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>
Comment thread src/injector/injector.ts
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>
tenphi and others added 3 commits August 27, 2026 18:12
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>
Comment thread src/tasty.tsx Outdated
Comment thread src/compute-styles.ts Outdated
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>
Comment thread src/compute-styles.ts Outdated
Comment thread src/debug.ts
Comment thread src/compute-styles.ts Outdated
Comment thread .changeset/fix-style-gc.md
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>
Comment thread src/compute-styles.ts Outdated
Comment thread src/compute-styles.ts Outdated
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>
Comment thread src/compute-styles.ts Outdated
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>
Comment thread src/injector/injector.ts Outdated
Comment thread src/debug.ts
tenphi and others added 2 commits August 28, 2026 15:22
… 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>
Comment thread src/debug.ts Outdated
Comment thread src/debug.ts
…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>
Comment thread src/debug.ts Outdated
Comment thread src/debug.ts Outdated
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];

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[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.

Comment thread src/debug.ts
const sheetManager = injector.instance._sheetManager;
if (!registry || !sheetManager) return '';

return sheetManager.getOwnedCSSInOrder(registry, root).join('\n');

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

[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.

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