feat(fuselage): opt-in atomic styling path for Box - #2094
Closed
ggazzo wants to merge 9 commits into
Closed
Conversation
|
Box currently emits a single class per unique combination of styling
props: the props are merged into one CSS blob, hashed, and injected as
one rule. Common declarations (e.g. `display: flex`) are duplicated
across every combination class, so the stylesheet grows with the number
of distinct combinations rather than the number of distinct declarations.
Add an experimental atomic path that emits one class per styling prop.
attachRules already dedupes identical rules per document and ref-counts
them, so shared declarations resolve to a single reused rule.
The path is opt-in via localStorage so the two can be compared on real
screens without a rebuild:
localStorage.setItem('fuselage-styling', 'atomic') // atomic
localStorage.removeItem('fuselage-styling') // default (merged)
The flag is read once at module load so the render branch stays stable
and the two paths never mix their hooks. A spec asserts the atomic path
emits the exact same declarations as the merged path.
Atomic classes were `rcx-css-<hash>`, opaque in the DOM. Prefix each with `<property>[-<value>]` when the declaration is a single tokenish rule (e.g. `rcx-display-flex-a1b2c`, `rcx-justify-content-space-between-…`), keeping a short content hash so distinct declarations never collide. Non-tokenish values (var() colors, calc, box-shadows) and multi-declaration styles fall back to the property prefix or the plain hash.
Babel plugin that rewrites <Box> at build time: static styling props are resolved to atomic class names, stripped from the JSX, merged into a plain className, and their CSS rules collected into an out-of-band sheet (deduped). Non-literal props are left for the runtime atomic path. Resolution is injected via options so the plugin reuses Fuselage's real runtime resolver (extractAtomicStylingProps + buildAtomicClassName) — the emitted classes/rules are identical to the runtime path, only computed at build time instead of on render. Includes a runnable demo and a jest spec asserting extraction, dynamic-prop pass-through, className merging, and cross-Box rule dedup.
Run the PoC plugin on real stories via an enforce:'pre' babel pass in
webpackFinal, gated behind BOX_COMPILER=1 so default Storybook is untouched.
The pass keeps JSX/TSX for SWC downstream, strips static Box styling props
into a className, and prepends a per-module attachRules(...) call so the CSS
lands at load with no external asset.
Build-time resolution reuses the real runtime resolver, bundled to a CJS
snapshot via esbuild (resolver.generated.cjs). Extract buildAtomicClassName
into its own React-free module so the bundle stays small.
BOX_COMPILER=1 yarn storybook -p 6007 → Box elements carry semantic atomic
classes baked at build time; main.iframe.bundle.js contains the injected
attachRules(".rcx-…") calls.
Stripping static styling props at build time broke code that reads them at runtime — e.g. the Colors story decorator uses `child.props.bg` as the Box children, which vanished once `bg` was removed. Add a keepProps mode: the compiler keeps the props on the element and instead emits a `data-rcx-atomic` marker listing which props are already compiled. The runtime (`useStylingProps`) drops the marked props before styling so it never restyles them, but they remain on the React element for introspection and spreads. Inert when the marker is absent. Storybook wiring uses keepProps.
Two harnesses to compare merged / atomic-runtime / build-time styling: - Node microbench (bench.node.entry.ts): measures the per-render hot path (extract → css() → hash) and stylesheet growth over a corpus of Boxes. 2000 Boxes: merged 9.58ms/1883 rules, atomic-runtime 11.07ms/22 rules, build-time 0.02ms/22 rules. Atomic runtime is not a render-CPU win (N hashes per box) but collapses the stylesheet ~85x; the CPU win is the build step. - Storybook bench story (Layout/Box/Bench) + Playwright driver: in-browser mount/update timing with real insertRule, comparable across the two servers and the localStorage toggle.
- Default resolver/styleProps to the bundled snapshot so the plugin is drop-in (a consumer adds only the plugin; no resolve wiring). Overridable via options. - Make keepProps the default: whether a prop is introspected elsewhere (cloneElement, child.props.x, spread) can't be proven statically, so keeping props on the element (runtime skips them via the marker, preserving the render-CPU win) is the safe behavior. Full stripping is opt-in (keepProps:false).
Grounded in RC's actual build: the client is compiled by Meteor's babel-compiler (Babel, not SWC under modern:true), which merges apps/meteor/.babelrc plugins — the drop-in point. Documents the coordinated change (RC must use a marker-aware Fuselage or there is no CPU win), resolver version-locking, a client-scoped .babelrc overrides block, inject mode as the Meteor-appropriate CSS delivery, verification, and measurement.
main removed styling-prop aliases (p, m, bg, w, mi, …). Update the specs, bench story and bench corpus to full prop names (padding, margin, backgroundColor, marginInline, …) and regenerate the resolver/bench snapshots against the new stylingProps.
ggazzo
force-pushed
the
feat/box-atomic-styling
branch
from
July 16, 2026 19:11
df08665 to
8e16088
Compare
Member
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds an experimental, opt-in atomic styling path for
Box, so we cancompare it against the current per-combination approach on real screens.
Today (per-combination)
All styling props on a
Boxare merged into one CSS blob, hashed, and injectedas a single class. A declaration like
display: flexis duplicated inside everycombination class, so the stylesheet grows with the number of distinct combinations.
New (atomic)
Emits one class per styling prop.
attachRulesalready dedupes identicalrules per document and ref-counts them, so shared declarations resolve to a
single reused rule — the stylesheet grows with the number of distinct
declarations instead.
How to compare
Toggle in the browser console, then reload (flag is read once at module load):
Default is unchanged production behavior; atomic is strictly opt-in.
Correctness
useStylingPropsselects the path once at module load, so the render branchis stable and the two paths never mix their hooks.
merged path (same visuals), plus one-class-per-prop and cross-Box dedup.
Notes
(precomputed classNames, drop the runtime
css()call).