feat(fuselage): render components with Tamagui and drop SCSS

Draft
GG
1d ago
+14100-9456

Proposed changes (including videos or screenshots)

40 Fuselage components now render with Tamagui (@tamagui/web 3.x). The package no longer contains or builds any SCSS, and Sass is gone from its build, Storybook and lint. Components look and behave as before.

Decisions, measurements, the migration recipe and the lessons learned are in docs/proposals/fuselage-tamagui.md.

How it fits Rocket.Chat

  • Colors and themes: no Tamagui tokens or themes. Values stay on the --rcx-* custom properties, so palettes, dark mode, high contrast and custom themes keep working.

  • Cascade layer: Tamagui styles sit in @layer fuselage. Unlayered CSS keeps overriding them: Box styling props (css-in-js), app CSS and admin Custom CSS.

  • Global element rules: the app's Meyer reset and its button / input / code / pre / blockquote / :focus rules move into @layer reset, so element selectors no longer win over the layered component styles.

  • Public API: props, rcx-* class names and ref forwarding are kept. State class names (is-hovered, focus focus-visible, …) still pin a component's state, through forceStyle.

  • DOM: kept as before, except decorative pseudo-elements, which became aria-hidden elements. Box props reach Tamagui frames through useTamaguiBoxProps.

  • What stays plain CSS (fuselage.css, flat CSS, no Sass): what Tamagui can't express — native :checked/:invalid, rich text, @keyframes, @font-face — plus the rcx-* class names other code puts on its own elements (styles/consumer-classes.css).

  • No provider required: Tamagui needs a theme context, so every component used to mount a fallback FuselageProvider. A patch now lets a frame with no ThemeProvider above act as its own root.

    • Tamagui's base stylesheet ships in fuselage.css. yarn tamagui-css generates it, and yarn lint fails when it is stale.
    • FuselageProvider is an optional ThemeProvider that only lets components share one root; the app mounts one at its root.
  • Layer order: @layer reset, fuselage; comes first in fuselage.css, and is also prepended to <head> before Tamagui inserts its runtime sheet. Pages that inject CSS from JavaScript (Storybook) would otherwise let element resets beat every frame.

  • Group clauses off the hot rows: a Tamagui group-* clause subscribes its element to the group, and the first subscriber makes the parent render again on mount. Message and Item reveal their parts (timestamp color, toolbar, hover-revealed actions) with plain rules in the fuselage layer instead. Mounting gets 26% faster for message rows and 43% faster for item rows with actions. The first update after a message mounts gets 17% slower, since it now warms Tamagui's caches; later updates are unchanged.

  • Compiled with the Tamagui compiler: the Fuselage build runs @tamagui/vite-plugin, so a frame rendered with literal props becomes a plain element with static classes, with no Tamagui component at runtime.

    • prevalStyled.ts evaluates each module's styled() options at build time, since the compiler only reads literals.
    • Extracted rules land in the fuselage layer.
    • 45 components that only style one element are now the frame itself (Message leaves and containers, Card parts, States, Sidepanel, ButtonGroup, NavBar, …), so an app that runs the compiler flattens their use-sites too. Their rcx-* classes come from always-on variants, so a consumer's className now adds to them instead of replacing them, as on develop.
    • PureItem and its parts: the Item frames with nothing worked out at runtime; the row declares its state, gap and highlight. Item also takes multiline and interactive, which skip its re-render after mount (200 sidebar rows mount 24% faster).
  • Bundling: @tamagui/web is bundled into the Fuselage dist with its runtime settings pinned, so consumers need no bundler config.

  • Patches: yarn patches cover Tamagui 3.0.18:

    • React 19 ref cleanups;
    • forceStyle and onLayout leaking to the DOM;
    • a stopPropagation() after every frame onClick;
    • frames that throw without a theme provider;
    • in the compiler (@tamagui/static): a frame's default attributes (role, aria-*) dropped when it flattens, and variants given a runtime value bailing out instead of compiling to a lookup over their classes.

    They stay until upstream handles them.

Commits

  • The first commit, perf(fuselage): register Item titles only for menu items, works on develop as is (no Tamagui) and can be extracted into its own PR. Every ItemTitle registered its id with state, so each row rendered twice on mount; only menu items read those ids.
  • Enabling refactors and fixes come next.
  • Then one commit per component.
  • Then shared CSS, toolchain and docs.
  • Some components depend on each other, so the PR is the unit; not every intermediate commit passes on its own.

Measurements

  • Component SCSS: 5,923 lines → none; about 2,650 lines of plain CSS remain.
  • fuselage.production.js: 34.4 → 96.5 kB gzip.
  • fuselage.css: 26.7 → 8.7 kB gzip.
  • Net about +44 kB gzip.
  • Compiled in an app (bench pages built with the compiler against an ESM Fuselage build), warm mount: 150 messages 16.1 → 10.1 ms, 300 button groups 11.8 → 1.5 ms, 200 sidebar rows with PureItem 1.7 ms against 38.5 ms with Item. Computed styles are identical at rest, hovered and focused.
  • Rocket.Chat doesn't run the compiler yet; that needs an ESM Fuselage build and the compiler in the app's bundler (rspack).

Issue(s)

https://rocketchat.atlassian.net/browse/ARCH-2565

Steps to test or reproduce

  1. Rebuild Fuselage. If yarn dev:next was already running, restart it after removing apps/meteor/node_modules/.vite; otherwise it serves the old Fuselage JS with the new CSS.
  2. Run the app, for example RC_SERVER_URL=https://open.rocket.chat yarn dev:next.
  3. Go through the home page, a room, the members and user panels, a modal, preferences and the marketplace.

What was compared against develop:

  • Fuselage Storybook: all 531 stories, pixel by pixel in the same Chromium. All identical.
  • apps/meteor and ui-client Storybooks: the only failures are stories that also differ between two develop builds.
  • The real app: the pages listed in step 3, logged in on open.rocket.chat, are pixel-identical to a develop-based build. The only differences are avatars still loading and the port number shown in the Site URL warning.
  • Jest:
    • Fuselage 669/669.
    • Meteor client: all 379 suites pass.
    • The fuselage-dependent packages' unit tests are green.
    • Snapshot diffs show only Tamagui class names, the removed rcx-box rcx-box--full, class order, shifted _r_N_ ids and the new aria-hidden decorative elements.
  • Types and lint: Meteor tsc and the fuselage-dependent packages' typecheck pass, and Fuselage lint has no errors.

After the provider-free change, compared against the commit before it:

  • Fuselage Storybook: 527 of 531 stories are pixel-identical. The other 4 (PositionAnimated, Dropdown) also differ between two runs of the same build.
  • Jest:
    • Fuselage: 669/669.
    • Rocket.Chat's Jest suites: 3,209 tests, all passing.
    • The fuselage-dependent packages' unit tests are green, apart from abac, which doesn't use Fuselage and needs a database.
    • Snapshots only shift _r_N_ ids, since each component calls useId once less.

After the group change:

  • Fuselage Storybook: 527 of 531 stories are pixel-identical. The other 4 (Tooltip positioning, PositionAnimated) also differ from run to run.
  • Computed styles: identical for message rows and item rows with actions, at rest, hovered and focused.
  • Jest:
    • Fuselage: 669/669.
    • Rocket.Chat: 3,559 tests.
    • Consumer packages: green. Snapshots only lose the t_group_* class names.
  • Not run here: customOAuth.spec.ts (server) crashes the Jest worker in this environment, as it did before the change.

After the compiler changes:

  • Fuselage Storybook: 525 of 531 stories are pixel-identical. The other 6 (PositionAnimated, Dropdown) also differ between two captures of the same build.
  • Compiled dist: the computed styles of the bench pages and the ui-client Storybook built against it match the uncompiled build (the ui-client failure, UserAutoComplete, fails in both).
  • Jest:
    • Fuselage: 675/675 before the rebase, 452/452 after it (the rest were snapshot tests).
    • Meteor client and the fuselage-dependent packages: green. Their snapshots only changed Tamagui atomic class hashes, class order and generated ids; BannedUsers also lost a stale t_group_rcx-item left from the group change.

develop removed the Jest snapshot tests (#42977). The branch is rebased on it and carries no snapshots; the snapshot comparisons above were made before that.

Style recalc on a room-like page (about 1,010 elements: sidebar rows, messages, toolbar, form; Chromium, median of 15 runs):

developpilot
Recalc during load1.94 ms2.10 ms
Forced full recalc2.39 ms3.51 ms
CSS file used5.5% of 256 kB12.7% of 86 kB
  • Selector matching is a small part of it. Each element matches about 3.2× more rules (atomic classes and the .is_View/.is_Text base), and the cascade processes each.
  • Tamagui's .is_DOM base rules, which never match a Fuselage frame, are no longer emitted.
  • The item focus rules check :focus-within before :has(). Together these cut the full recalc from 3.87 to 3.51 ms.

Further comments

  • Bundle size: the runtime is still bundled; TAMAGUI_RUNTIME=island could drop most of it once enough use-sites compile. See docs/proposals/fuselage-tamagui.md.
  • Known leftover: MessageHeader and other components with logic still put their rcx-* class before the consumer's props, so a consumer className replaces it.
  • Box: Box itself still runs on css-in-js.
  • Out of scope: RaisedHandsButton in ui-conference spreads pressed into a Box and gets a React warning; judging from the code, develop does the same.