Skip to content

feat(fuselage): opt-in atomic styling path for Box - #2094

Closed
ggazzo wants to merge 9 commits into
mainfrom
feat/box-atomic-styling
Closed

feat(fuselage): opt-in atomic styling path for Box#2094
ggazzo wants to merge 9 commits into
mainfrom
feat/box-atomic-styling

Conversation

@ggazzo

@ggazzo ggazzo commented Jul 16, 2026

Copy link
Copy Markdown
Member

What

Adds an experimental, opt-in atomic styling path for Box, so we can
compare it against the current per-combination approach on real screens.

Today (per-combination)

All styling props on a Box are merged into one CSS blob, hashed, and injected
as a single class. A declaration like display: flex is duplicated inside every
combination class, so the stylesheet grows with the number of distinct combinations.

New (atomic)

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 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):

localStorage.setItem('fuselage-styling', 'atomic') // atomic (experimental)
localStorage.removeItem('fuselage-styling')        // default (per-combination)

Default is unchanged production behavior; atomic is strictly opt-in.

Correctness

  • useStylingProps selects the path once at module load, so the render branch
    is stable and the two paths never mix their hooks.
  • A spec asserts the atomic path produces the exact same declarations as the
    merged path (same visuals), plus one-class-per-prop and cross-Box dedup.

Notes

  • Draft — for A/B comparison, not merge-ready.
  • No changeset yet (experimental).
  • Follow-up if it wins: move the same per-prop resolution to build time
    (precomputed classNames, drop the runtime css() call).

@changeset-bot

changeset-bot Bot commented Jul 16, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 8e16088

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

ggazzo added 9 commits July 16, 2026 15:33
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
ggazzo force-pushed the feat/box-atomic-styling branch from df08665 to 8e16088 Compare July 16, 2026 19:11
@ggazzo

ggazzo commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Superseded — split into two stacked PRs: #2097 (opt-in atomic styling path → main) and #2098 (build-time compiler PoC, stacked on #2097).

@ggazzo ggazzo closed this Jul 16, 2026
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