Skip to content

fix(server): make collect linear in the number of rules - #1521

Merged
Anber merged 3 commits into
callstack:masterfrom
fedifazzy:server-collect-linear
Oct 26, 2026
Merged

Anber merged 3 commits into
callstack:masterfrom
fedifazzy:server-collect-linear

Conversation

@fedifazzy

Copy link
Copy Markdown
Contributor

Motivation

collect() is O(n²) in the number of top-level rules.

It moves every top-level rule of the parsed stylesheet into critical or other with append. For a node that still has a parent, append removes it with splice. The rule being moved is always at index 0, so every move shifts all remaining nodes.

On a 1.6 MB application stylesheet (~13.5k top-level rules), one call takes ~0.2 s on Node 20, and seconds on a busy server while incremental marking is active. collect() is synchronous, so the event loop is blocked for the whole call on every SSR request. The implementation is the same in 6.3.0 and 8.2.0.

While testing, two more bugs showed up:

  • other loses the non-critical rules of an at-rule, and repeats its critical ones, whenever a non-critical rule comes first. For example, @media (min-width: 1px) { .n {} .c {} } with class="c" gives @media (min-width: 1px) { .c {} } in other.
  • blockedClasses lets every second matching rule into critical. The regexp has the g flag, so test keeps lastIndex between selectors.

Summary

  • Rules are collected into arrays first. Then the stylesheet is detached at once with removeAll(), and the nodes are appended, so nothing is spliced out of the big root. Handled top-level at-rules are no longer removed one by one either. Keyframes are looked up only where they used to stay: in top-level at-rules that were not handled.
  • handleAtRule filters both clones of the at-rule with isCritical directly, instead of the index - removedNodesFromOther arithmetic.
  • The class regexps no longer use the g flag, and the classes from the HTML are deduplicated.
  • Whitespace between rules now follows the source stylesheet; previously the removals glued the rules together. Neither output starts with whitespace.
  • The @linaria/server tests were not running. The package had no test script, and since prettier 3 made format async, every prettified snapshot was compared with a pending Promise: 16 of 22 failed on master. The first commit awaits the standalone prettier with the postcss plugin, which also works without --experimental-vm-modules, and adds the test script.

One collect() call after a warm-up, on N rules .c{i}{color:red}:

Top-level rules Node 20, before Node 20, after Node 20 --stress-incremental-marking, before after
8,000 22 ms 12 ms 235 ms 66 ms
16,000 123 ms 26 ms 749 ms 164 ms
32,000 445 ms 77 ms 2,570 ms 454 ms

On a real 1.6 MB stylesheet with the HTML of six pages, a call takes 67–92 ms instead of 164–185 ms on Node 20. critical is identical apart from whitespace.

Most of the remaining time goes to parsing the stylesheet and building other on every call. An API that accepts a prepared stylesheet, or lets callers skip other, could remove that, but it is out of scope here.

Test plan

  • pnpm --filter @linaria/server test: 23 tests, 22 snapshots.

  • New tests cover:

    • splitting an at-rule between critical and other;
    • repeated blockedClasses;
    • rule order in a 20,000-rule stylesheet.

    The first two fail on master.

  • The snapshot changes are whitespace only: blank lines between rules, as in the source. One more snapshot is re-serialized without escaped quotes.

  • pnpm --filter @linaria/server typecheck, eslint packages/server and prettier are clean.

  • Changeset: @linaria/server patch.

🤖 Generated with Claude Code

fedifazzy and others added 2 commits October 22, 2026 13:10
prettier 3 formats asynchronously, so every prettified snapshot was compared with a pending
Promise and 16 of 22 snapshots failed. Nobody noticed because @linaria/server had no test
script, so `turbo run test` skipped the package.

Use the standalone build of prettier with the postcss plugin, which also works without
--experimental-vm-modules, await it, and add the test script the other packages use. One
snapshot is re-serialized without escaped quotes by the current jest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
collect moved every top-level rule of the parsed stylesheet into `critical` or `other` with
`append`. For a node with a parent, `append` removes it with `splice`, and the moved rule was
always at index 0, so one call was O(n²) in the number of top-level rules: about 0.2 s for a
1.6 MB stylesheet on Node 20 and seconds while incremental marking is active.

Rules are now collected first. The stylesheet is detached at once with `removeAll`, and only
then are the nodes appended. Handled top-level at-rules are no longer spliced out one by one.

Two bugs found along the way:
- `other` dropped the non-critical rules of an at-rule and repeated its critical rules
  whenever a non-critical rule came first. The `index - removedNodesFromOther` lookup removed
  the wrong child. Both copies of the at-rule are now filtered by `isCritical` directly.
- `blockedClasses` let every second matching rule into `critical`, because `test` on a regexp
  with the `g` flag keeps `lastIndex` between selectors. The class regexps no longer use `g`,
  and the classes from the HTML are deduplicated.

Whitespace between rules in the output now follows the source stylesheet. Previously the
rules were glued together by the removals.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e591765

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@linaria/server Patch
@linaria/atomic Patch
@linaria/core Patch
@linaria/babel-plugin-interop Patch
linaria Patch
@linaria/postcss-linaria Patch
@linaria/react Patch
@linaria/stylelint-config-standard-linaria Patch
@linaria/stylelint Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

postcss 8.4 gives a node appended to a root of two or more nodes the
whitespace of the last one, so from the third rule on the output lost
the whitespace of the source stylesheet. Append each list of nodes in
one call while the root is still empty, and test the raw output.

The snapshots of two tests lose blank lines that came from the second
rule rather than from the source.

@Anber Anber left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks! There was a small bug, I've fixed it. We'll release this soon.

@Anber
Anber merged commit b3769e2 into callstack:master Oct 26, 2026
4 checks passed
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.

2 participants