Skip to content

fix: move css cache onto container to allow parallel runs#252

Merged
danielroe merged 14 commits intodanielroe:mainfrom
siimsams:add-test
Apr 3, 2026
Merged

fix: move css cache onto container to allow parallel runs#252
danielroe merged 14 commits intodanielroe:mainfrom
siimsams:add-test

Conversation

@siimsams
Copy link
Copy Markdown
Contributor

@siimsams siimsams commented Jan 26, 2026

Summary

  • classCache and idCache in dom.ts were module-level globals, causing concurrent process() calls to overwrite each
    other's caches across the await boundary in process(). This resulted in CSS selectors not matching and inlining
    being silently skipped.
  • Moved caches onto the container node itself so each process() call has its own isolated cache.
  • Added a test that reproduces the race condition by running two process() calls concurrently via Promise.all.

How it happened

  1. Call A: createDocument() → buildCache() populates classCache with page classes
  2. Call A: hits await Promise.all(fetchStylesheet(...)) — yields
  3. Call B: createDocument() → buildCache() overwrites classCache (e.g. empty page with no classes)
  4. Call A: resumes, processStyle() reads the overwritten cache → no selectors match → no CSS inlined

Any framework that calls process() concurrently hits this (e.g. Next.js next build processing index, 404, 500 pages in the same worker).

Test plan

  • New test should not share class/id caches between concurrent process() calls verifies inlining succeeds under concurrent usage
  • All existing tests pass (49/49)

Related issue:
resolves #251

Related PR in Next.js repository:
vercel/next.js#88640

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Jan 26, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Per-container DOM caches added: _classCache and _idCache are attached to the container Node and used instead of module-level caches. buildCache and cachedQuerySelector signatures narrowed to accept Node. Selector parsing and cache-shortcircuit behaviour adjusted; tests added for cache isolation across concurrent runs.

Changes

Cohort / File(s) Summary
DOM caching & selector logic
packages/beasties/src/dom.ts
Replaced module-level classCache/idCache with per-container container._classCache / container._idCache; narrowed buildCache(container: Node) and cachedQuerySelector(sel: string, node: Node) signatures; queue traversal uses NodeWithChildren; parseRelevantSelectors now returns null for token groups not length 1; short-circuit matching uses per-node caches; added domhandler augmentation for optional _classCache/_idCache.
Tests — cache isolation & DOM exists
packages/beasties/test/beasties.test.ts, packages/beasties/test/dom.test.ts
Added tests to ensure class/id caches are not shared across concurrent Beasties.process calls and to verify createDocument/exists() behaviour with complex and reordered selectors; creates/cleans temporary directories in tests.

Sequence Diagram(s)

(omitted)

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • perf: cache selector query #49 — Modifies packages/beasties/src/dom.ts with related selector-caching changes; likely overlaps in caching and selector-token logic.

Suggested reviewers

  • danielroe
  • alan-agius4
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description is directly related to the changeset, explaining the root cause, the fix, how the bug occurred, test strategy, and linking to the related issue and Next.js PR.
Linked Issues check ✅ Passed The PR successfully addresses issue #251 by moving module-level classCache and idCache to per-container caches, preventing concurrent process() calls from overwriting each other's caches and enabling reliable CSS inlining in concurrent scenarios.
Out of Scope Changes check ✅ Passed All changes are scoped to fixing the concurrent cache issue: type signature updates for buildCache and cachedQuerySelector, per-node cache storage, updated cache lookup logic, selector parsing adjustments, and tests verifying the fix. No unrelated changes detected.
Title check ✅ Passed The title accurately summarizes the main fix: moving the CSS cache from module-level globals onto the container node to enable safe parallel execution.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@siimsams siimsams changed the title Add test for my specific case WIP: Add test for my specific case Jan 26, 2026
siimsams and others added 4 commits March 14, 2026 22:36
…ncurrent process() calls

  `classCache` and `idCache` in `dom.ts` were module-level globals shared
  across all `process()` invocations. When two `process()` calls overlap
  (e.g. Next.js static export processing multiple pages concurrently),
  the second call's `createDocument()` → `buildCache()` overwrites the
  caches while the first call is suspended at `await Promise.all(fetchStylesheet(...))`.
  When the first call resumes, it reads the wrong cache, finds no matching
  selectors, and silently skips CSS inlining entirely.

  Fix by storing `_classCache` and `_idCache` on the container node itself
  (via the domhandler `Node` module augmentation) instead of in module-level
  variables. Each `process()` call now has its own isolated cache through
  its own document/container, eliminating the race condition.
@siimsams siimsams changed the title WIP: Add test for my specific case fix: concurrent process() calls silently skip CSS inlining due to shared module-level caches Mar 14, 2026
@siimsams siimsams marked this pull request as ready for review March 14, 2026 21:02
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/beasties/src/dom.ts (1)

348-367: Add defensive null checks before accessing cache properties.

The cachedQuerySelector function uses non-null assertions when accessing node._classCache and node._idCache, but these properties are typed as optional. Although exists() is currently only called on beastiesContainer (which has caches attached via buildCache), the method is defined globally on Element.prototype and document. If exists were called on a different node in future code, the non-null assertions would throw a TypeError.

Consider adding a guard to check cache existence before using them, falling back to selectOne:

Suggested defensive check
 function cachedQuerySelector(sel: string, node: Node) {
   let selectorTokens = selectorTokensCache.get(sel)
   if (selectorTokens === undefined) {
     selectorTokens = parseRelevantSelectors(sel)
     selectorTokensCache.set(sel, selectorTokens)
   }

-  if (selectorTokens) {
+  if (selectorTokens && node._classCache && node._idCache) {
     for (const token of selectorTokens) {
       if (token.name === 'class') {
-        return node._classCache!.has(token.value)
+        return node._classCache.has(token.value)
       }
       if (token.name === 'id') {
-        return node._idCache!.has(token.value)
+        return node._idCache.has(token.value)
       }
     }
   }

   return !!selectOne(sel, node)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/beasties/src/dom.ts` around lines 348 - 367, The cachedQuerySelector
function currently uses non-null assertions on node._classCache and
node._idCache which can throw if those optional caches are missing; update
cachedQuerySelector to defensively check that node._classCache and node._idCache
exist before calling .has (and avoid the ! operator), and if a required cache is
absent for a given token (token.name === 'class' or 'id') fall back to using
selectOne(sel, node); keep the existing selectorTokensCache and
parseRelevantSelectors logic but only short-circuit when the corresponding cache
is present.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/beasties/src/dom.ts`:
- Around line 348-367: The cachedQuerySelector function currently uses non-null
assertions on node._classCache and node._idCache which can throw if those
optional caches are missing; update cachedQuerySelector to defensively check
that node._classCache and node._idCache exist before calling .has (and avoid the
! operator), and if a required cache is absent for a given token (token.name ===
'class' or 'id') fall back to using selectOne(sel, node); keep the existing
selectorTokensCache and parseRelevantSelectors logic but only short-circuit when
the corresponding cache is present.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 83edc04a-a697-463f-8016-83e6c22eda0c

📥 Commits

Reviewing files that changed from the base of the PR and between 9a2e301 and 1a94bd6.

📒 Files selected for processing (2)
  • packages/beasties/src/dom.ts
  • packages/beasties/test/beasties.test.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/beasties/src/dom.ts`:
- Around line 359-366: The current loop in the selector cache check (inside the
function handling selectorTokens on node with _classCache/_idCache) returns on
the first token check causing false negatives; change the logic so you do not
return false for a non-matching token — instead iterate all selectorTokens,
return true immediately if any token matches (e.g., token.name === 'class' and
node._classCache.has(token.value') or token.name === 'id' and
node._idCache.has(token.value')), and only allow the function to fall through
(no false return) when no positive match is found so the selectOne fallback can
run; apply the same fix to the equivalent block that skips partial token-groups
(the code referenced at the other occurrence around lines 380–387).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 909fa5a9-9597-4e8a-b7ee-71777e90e893

📥 Commits

Reviewing files that changed from the base of the PR and between 1a94bd6 and 0e5de3a.

📒 Files selected for processing (1)
  • packages/beasties/src/dom.ts

Comment thread packages/beasties/src/dom.ts
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Mar 14, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 85.39%. Comparing base (a1902e2) to head (0c4df7e).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #252      +/-   ##
==========================================
+ Coverage   84.81%   85.39%   +0.57%     
==========================================
  Files           8        8              
  Lines         843      842       -1     
  Branches      235      235              
==========================================
+ Hits          715      719       +4     
+ Misses        115      111       -4     
+ Partials       13       12       -1     

☔ View full report in Codecov by Sentry.
📢 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.

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq bot commented Mar 14, 2026

Merging this PR will not alter performance

✅ 9 untouched benchmarks


Comparing siimsams:add-test (0c4df7e) with main (a1902e2)

Open in CodSpeed

@siimsams
Copy link
Copy Markdown
Contributor Author

siimsams commented Apr 1, 2026

@danielroe Hey! You are probably really busy but can you take a look? If there is anything needed let me know and I'll fix it.

const tokenGroup = tokens[i]
if (tokenGroup?.length !== 1) {
continue
return null
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

can you tell me why we change this?

Copy link
Copy Markdown
Contributor Author

@siimsams siimsams Apr 3, 2026

Choose a reason for hiding this comment

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

Hey, thank you for checking this so fast.

I changed this because continue could return the wrong result when a selector list contains both simple and complex selectors.

For example, with this HTML:

<div class="parent">
  <span class="child">text</span>
</div>

and this selector:

'.nonexistent, .parent .child'

the correct result is true, because .parent .child matches.

The problem with continue was that it skipped complex groups like .parent .child, then kept evaluating only the simple ones. So in this case it would only check .nonexistent, find nothing, and return false, even though the full selector list should match.

Changing this to return null makes the cache logic stop and fall back to selectOne, which performs a full DOM query and handles complex selectors correctly.

I also added a test for this case to make sure the fallback returns the correct result.

}
if (token.name === 'id') {
return idCache!.has(token.value)
if (token.name === 'id' && !node._idCache.has(token.value)) {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

also here: is there a reason we're changing the logic?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This was changed because the previous logic could return different results for the same selector list depending on the order.

With the old code, we returned immediately on the first selector that was checked. So for:

exists('.present, .absent')

it checked .present first, found it, and returned true.

But for:

exists('.absent, .present')

it checked .absent first, did not find it, and returned false.

That is incorrect, because selector lists separated by commas use OR logic. These two selectors are equivalent and should always produce the same result.

The updated logic checks all simple selector groups before deciding. This avoids order-dependent behavior and makes the result consistent.

I also added a test to verify that:

exists('.present, .absent') === exists('.absent, .present')

so both orders behave the same way.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/beasties/test/dom.test.ts (1)

38-39: Consider adding an explicit expected value assertion.

The test only verifies that both selector orders return the same result, but doesn't assert what that result should be. If both returned false due to a bug, this test would still pass. Since .present exists, the expected result is true.

Suggested improvement
-      expect(container.exists('.present, .absent'))
-        .toBe(container.exists('.absent, .present'))
+      const result1 = container.exists('.present, .absent')
+      const result2 = container.exists('.absent, .present')
+      expect(result1).toBe(true)
+      expect(result2).toBe(true)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/beasties/test/dom.test.ts` around lines 38 - 39, The test currently
only checks that container.exists('.present, .absent') equals
container.exists('.absent, .present') which can mask failures; change the
assertion to assert the explicit expected boolean (true) since the .present
element exists—e.g., replace the equality assertion with an explicit
expect(container.exists('.present, .absent')).toBe(true) (and optionally also
expect(container.exists('.absent, .present')).toBe(true)) so both selector
orders are asserted to be true; refer to the container.exists call and the
selectors '.present, .absent' and '.absent, .present' to locate the code to
update.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/beasties/test/dom.test.ts`:
- Around line 38-39: The test currently only checks that
container.exists('.present, .absent') equals container.exists('.absent,
.present') which can mask failures; change the assertion to assert the explicit
expected boolean (true) since the .present element exists—e.g., replace the
equality assertion with an explicit expect(container.exists('.present,
.absent')).toBe(true) (and optionally also expect(container.exists('.absent,
.present')).toBe(true)) so both selector orders are asserted to be true; refer
to the container.exists call and the selectors '.present, .absent' and '.absent,
.present' to locate the code to update.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: aae1a614-a84a-4804-bb43-8d2a6fc84de1

📥 Commits

Reviewing files that changed from the base of the PR and between 57aa007 and c63fb1c.

⛔ Files ignored due to path filters (1)
  • packages/beasties-webpack-plugin/test/__snapshots__/index.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (1)
  • packages/beasties/test/dom.test.ts

@siimsams siimsams requested a review from danielroe April 3, 2026 13:14
@siimsams
Copy link
Copy Markdown
Contributor Author

siimsams commented Apr 3, 2026

Please let me know if there’s anything else I should clarify or fix, and I’ll take a look as soon as I can.

Thank you again, and I hope you have a peaceful Easter!

Copy link
Copy Markdown
Owner

@danielroe danielroe left a comment

Choose a reason for hiding this comment

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

thank you for this ❤️

@danielroe danielroe changed the title fix: concurrent process() calls silently skip CSS inlining due to shared module-level caches fix: move css cache onto container to allow parallel runs Apr 3, 2026
@danielroe danielroe merged commit 0ee615f into danielroe:main Apr 3, 2026
9 checks passed
@siimsams siimsams deleted the add-test branch April 3, 2026 13:53
@siimsams
Copy link
Copy Markdown
Contributor Author

siimsams commented Apr 5, 2026

Hey! Thank you again for taking a look and merging this. Is it possible to publish this version so I can migrate NextJs to the new version? Or is there something blocking the release?

My open PR related to these changes.
vercel/next.js#88640

@siimsams
Copy link
Copy Markdown
Contributor Author

siimsams commented Apr 7, 2026

@danielroe is it possible to publish the version that's got this fix? Or is there some beta tag I'm not aware of?

@danielroe
Copy link
Copy Markdown
Owner

apologies for that! now released ✅

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.

Module-level caches cause silent CSS inlining failure under concurrent process() calls

3 participants