Skip to content

perf(compiler): return nothing from visitors that only read - #490

Merged
danstepanov merged 2 commits into
nativewind:mainfrom
YevheniiKotyrlo:perf/read-only-visitors
Nov 11, 2026
Merged

danstepanov merged 2 commits into
nativewind:mainfrom
YevheniiKotyrlo:perf/read-only-visitors

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The compiler's second pass only reads - its output is discarded - but its Rule and StyleSheetExit visitors return the node they were given, and the first pass's Length visitor returns every length it does not convert. lightningcss deserializes a returned node back into its AST, so every compile reads the whole stylesheet back from JS once more.

On lightningcss 1.30.2 and later that read also fails: a returned var() carries from: null and fallback: null, which those releases no longer deserialize (#470). So with inlineVariables: false, any stylesheet holding a var() throws.

Solution

The three visitors return a node only when they changed it, so lightningcss keeps its own. The first pass's StyleSheetExit, which returns the inlined sheet, is unchanged.

Tests

src/__tests__/compiler/visitor-returns.test.ts records what each visitor hands back during a compile: the second pass returns nothing, and the first pass returns only the lengths it converts. Both fail on main.

Verification

  • The compiled output of a 1.17 MB Tailwind 4 stylesheet is byte-identical to main's with variables inlined, with inlineVariables: false and with inlineRem: false.
  • Compiling it takes 2.52 s against 4.93 s on main, and a 76 KB Tailwind app stylesheet 62.9 ms against 117.2 ms, on Ubuntu with Node 22.18.0, where main against itself differs by under 2% (the measurement).
  • On lightningcss 1.33.0, inlineVariables: false compiles that stylesheet where main throws. With variables inlined it still throws, from the inlined sheet: fix: deserialize null as None in visitor results parcel-bundler/lightningcss#1357 fixes that, and with it built on 1.33.0 every test that passes on 1.30.1 passes but the two calc(infinity * 1px) cases, which are Failed to deserialize infinity or big number when using visitors parcel-bundler/lightningcss#1188.
  • On Ubuntu with the workflow's steps - build, lint, typecheck, yarn test, node --test node/__tests__/*.mjs, both library builds, the web export, the unstaged-changes checks and commitlint - everything passes.

Not run: the iOS and Android example builds.

Refs #470.

@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Compile cost — main against this PR

main — the second pass hands every rule and the stylesheet back to lightningcss, which reads the whole sheet back from JavaScript once more before the output is discarded.

This PR — a visitor returns a node only when it changed it, and the compile takes about half the time; the stylesheet it emits is byte-identical to main's for both inputs with variables inlined, with inlineVariables: false and with inlineRem: false.

compile() and stylesheet() main this PR
a 76 KB Tailwind 4 app stylesheet 117.2 ms (108.4–134.3) 62.9 ms (58.6–64.6)
a 1.17 MB Tailwind 4 stylesheet 4.93 s (4.65–5.27) 2.52 s (2.41–2.62)

Ubuntu 24.04 under WSL2, Node 22.18.0, lightningcss 1.30.1, each side built with yarn build. Each cell is the median of five interleaved rounds' process medians, with the range across them in brackets; a process ran 40 compiles after 10 warm-ups for the small sheet and 8 after 2 for the large one, and main was measured twice a round for the noise floor, 0.7 ms and 0.07 s.

The large stylesheet

Tailwind 4.1.13 over this input, 1,170,455 bytes out:

@import "tailwindcss";
@source inline("{,hover:,focus:,dark:}{bg,text,border,ring,outline,fill,stroke,shadow}-{red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose,slate,gray,zinc,neutral,stone}-{50,{100..900..100},950}");
@source inline("{,md:,lg:}{p,px,py,pt,pb,m,mx,my,mt,mb,gap,w,h,min-w,max-w,inset,top,left,translate-x,translate-y}-{0,1,2,3,4,5,6,8,10,12,16,20,24,32,40,48,64}");
@source inline("{,hover:}{rounded,rounded-t,rounded-b,shadow,blur,opacity,scale,rotate}-{none,sm,md,lg,xl,2xl,3xl,full,0,50,75,100,45,90}");

@sammoore sammoore left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

From my testing with Tailwind, makes the compiler ~1.75x faster with inlineVariables: true and over twice as fast with it turned off.

Non-breaking change and puts us on a patch towards a higher version of lightningcss, which Expo will inevitably force upon us. (Note there are still some things that will break with lightningcss@1.30.2, the next patch version.)

@danstepanov Approved

The second pass's Rule and StyleSheetExit visitors and the first pass's
Length visitor returned the node they were given. A returned node is
deserialized back into lightningcss's AST, so every compile paid for
reading the whole stylesheet back from JS, and on lightningcss 1.30.2
and later a returned var() fails to deserialize at all.
@danstepanov
danstepanov force-pushed the perf/read-only-visitors branch from f1156ba to fce097c Compare November 11, 2026 21:42
@danstepanov
danstepanov merged commit 16790bb into nativewind:main Nov 11, 2026
6 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.

3 participants