Skip to content

test: compile each Tailwind render afresh, and fail on a compile error - #477

Draft
YevheniiKotyrlo wants to merge 5 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/tailwind-harness-cache-key
Draft

YevheniiKotyrlo wants to merge 5 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/tailwind-harness-cache-key

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Problem

src/__tests__/vendor/tailwind/_tailwind.tsx compiles every render through @tailwindcss/postcss with from: __dirname and base: Date.now().toString(). The plugin keeps a compiler per from and base (getContextFromCache) and reuses it until a file it read changes, unless no file is at from (L182–L196); from is the test directory, which does not change. So two renders that read the same millisecond share a compiler, and the second compiles the first one's classes:

jest.spyOn(Date, "now").mockReturnValue(0);
await render(<View className="left-0" />); // stylesheet: left-0
await render(<View className="left-1" />); // stylesheet: left-0, and no style

A vendor case can then assert on another case's output. On main, auto-cols-max and object-cover failed in one of six runs of src/__tests__/vendor/tailwind and passed in the next; a shared compiler gives that symptom, though no run captured the collision itself.

A compile that fails is only logged: the plugin prints the error and returns an empty stylesheet (L334–L352), so the render registers nothing, and a case that expects no style passes. And the class collection reads every child as an element, so a render whose tree holds text throws Cannot read properties of undefined (reading 'children').

Solution

render compiles its CSS with compile from @tailwindcss/node, the string form of the compileAst the plugin calls, which keeps no compiler between calls. Each render builds its own classes, and a CSS that fails to compile rejects the render with Tailwind's error. The plugin's cache serves watch mode, and a from that names no file would not rebuild for every render: once one render's CSS uses no Tailwind feature, the plugin keeps that compiler and returns before it looks for the file, so a later render's CSS comes back uncompiled (L158–L160). @tailwindcss/node joins the dev dependencies at the range the other Tailwind packages use, locked to the 4.1.13 the plugin already resolves.

The class collection skips text and empty children. render.debug takes the harness's options, as renderSimple.debug does, and render's options no longer list plugin and className, which it never read.

Tests

src/__tests__/vendor/tailwind/harness.test.tsx:

  • freezes Date.now and renders left-0, then left-1: each render's stylesheet holds only its own class, and each view gets its own style;
  • imports a missing file and expects the render to reject;
  • renders after a render whose CSS uses no Tailwind feature;
  • renders beside a caller's own console.error spy and expects it left in place;
  • renders a tree with text and empty children.

The first, second and last fail with the harness from main.

Verification

Every ci.yml job passes on this head merged into main: lint, typecheck, the tests (1344 passed), both builds, the web export, nothing unstaged, commitlint, and the iOS dev build on macOS 27.0.1 with Xcode 27.0, where CI pins 26.6.

Run in one process, src/__tests__/vendor/tailwind ends at 388–390 MB of heap against 1589 MB on main; with four workers it takes 7.86 s against 8.13 s, inside this PR's spread. The method and every run are in this comment.

No existing issue tracks this. I searched for flaky, tailwind test cache, Date.now and cache bust.

@tailwindcss/postcss reuses one compiler per `from` and `base`, and an
incremental rebuild keeps the first input's `@source inline()` classes.
The harness passed a fixed `from` and `base: Date.now()`, so two renders
that read the same millisecond shared a compiler and the second one
compiled the first one's classes. A per-render counter keys each render
on its own.
The case already checked that each render compiles only its own class; it now
also checks the style each render applies, so it fails on what a vendor case
sees as well as on what the compiler produced.
The plugin reuses a cached compiler until a file it read changes, and
compiles afresh whenever no file is at `from`, so a `from` with no file
behind it compiles each render's own CSS in one cache entry, where a
`base` per render kept a compiler per render. A failed compile reached
only `console.error` and an empty stylesheet; it now fails the render.
`render.debug` takes the harness's options, and `render` no longer
lists `plugin` and `className`, which it never read.
@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Heap and time — main against this PR

Heap. Every file of src/__tests__/vendor/tailwind in one process, two passes a side; jest reads the heap after a forced GC at the end of each file:

node --expose-gc node_modules/jest/bin/jest.js src/__tests__/vendor/tailwind --runInBand --logHeapUsage
after the first file after the last file growth per file, on average
main (eda2b286) 275 MB 1589 MB 69 MB
this PR, merged into main 108 MB 388–390 MB 14 MB

For scale, main's src/__tests__/native, which compiles no Tailwind, grows by 7 MB a file the same way.

Time. jest src/__tests__/vendor/tailwind --maxWorkers=4, one warm-up and five measured runs a side, alternating:

runs median
main (eda2b286) 8.18, 8.13, 8.15, 8.06, 7.86 s 8.13 s
this PR, merged into main 8.44, 7.86, 7.43, 7.42, 7.88 s 7.86 s

The difference sits inside this PR's spread.

Windows 11 Pro 10.0.26200, AMD Ryzen 9 9950X3D, Node 26.8.1, jest 29.7.0; @tailwindcss/node 4.1.13 here, @tailwindcss/postcss 4.1.13 on main.

@YevheniiKotyrlo YevheniiKotyrlo changed the title test: give each Tailwind render a compiler of its own test: compile each Tailwind render afresh, and fail on a compile error Oct 20, 2026
…s no cache

@tailwindcss/postcss keeps a compiler per input for watch mode. With one
input per test file, a render whose CSS uses no Tailwind feature left a
compiler every later render in the file reused, and the console spy that
caught the plugin's logged failures also took any other error and a
caller's own spy. The harness now compiles with @tailwindcss/node's
compile, the call the plugin wraps, which keeps no cache and rejects a
failed compile; it is declared as a devDependency at the version the
lockfile already holds.
… without reassigning it

getClassNames read `.props` of a text child and threw; it now walks the
tree with isValidElement and Children. render builds its input CSS in a
pure helper instead of reassigning its `css` parameter, renderSimple
builds its warnings without mutation, the options are readonly, and the
debug option's doc names the inspector check the code makes.

This branch has not been deployed

No deployments
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