feat(fuselage): render components with Tamagui and drop SCSS
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/:focusrules move into@layer reset, so element selectors no longer win over the layered component styles. -
Public API: props,
rcx-*class names andrefforwarding are kept. State class names (is-hovered,focus focus-visible, …) still pin a component's state, throughforceStyle. -
DOM: kept as before, except decorative pseudo-elements, which became
aria-hiddenelements.Boxprops reach Tamagui frames throughuseTamaguiBoxProps. -
What stays plain CSS (
fuselage.css, flat CSS, no Sass): what Tamagui can't express — native:checked/:invalid, rich text,@keyframes,@font-face— plus thercx-*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 noThemeProviderabove act as its own root.- Tamagui's base stylesheet ships in
fuselage.css.yarn tamagui-cssgenerates it, andyarn lintfails when it is stale. FuselageProvideris an optionalThemeProviderthat only lets components share one root; the app mounts one at its root.
- Tamagui's base stylesheet ships in
-
Layer order:
@layer reset, fuselage;comes first infuselage.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 thefuselagelayer 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.tsevaluates each module'sstyled()options at build time, since the compiler only reads literals.- Extracted rules land in the
fuselagelayer. - 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. Theirrcx-*classes come from always-on variants, so a consumer'sclassNamenow adds to them instead of replacing them, as ondevelop. PureItemand its parts: theItemframes with nothing worked out at runtime; the row declares its state, gap and highlight.Itemalso takesmultilineandinteractive, which skip its re-render after mount (200 sidebar rows mount 24% faster).
-
Bundling:
@tamagui/webis bundled into the Fuselagedistwith its runtime settings pinned, so consumers need no bundler config. -
Patches: yarn patches cover Tamagui 3.0.18:
- React 19 ref cleanups;
forceStyleandonLayoutleaking to the DOM;- a
stopPropagation()after every frameonClick; - 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 ondevelopas is (no Tamagui) and can be extracted into its own PR. EveryItemTitleregistered 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
PureItem1.7 ms against 38.5 ms withItem. 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
- Rebuild Fuselage. If
yarn dev:nextwas already running, restart it after removingapps/meteor/node_modules/.vite; otherwise it serves the old Fuselage JS with the new CSS. - Run the app, for example
RC_SERVER_URL=https://open.rocket.chat yarn dev:next. - 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
developbuilds. - 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 newaria-hiddendecorative elements.
- Types and lint: Meteor
tscand 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 callsuseIdonce less.
After the group change:
- Fuselage Storybook: 527 of 531 stories are pixel-identical. The other 4 (
Tooltippositioning,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;
BannedUsersalso lost a stalet_group_rcx-itemleft 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):
develop | pilot | |
|---|---|---|
| Recalc during load | 1.94 ms | 2.10 ms |
| Forced full recalc | 2.39 ms | 3.51 ms |
| CSS file used | 5.5% of 256 kB | 12.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_Textbase), and the cascade processes each. - Tamagui's
.is_DOMbase rules, which never match a Fuselage frame, are no longer emitted. - The item focus rules check
:focus-withinbefore: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=islandcould drop most of it once enough use-sites compile. Seedocs/proposals/fuselage-tamagui.md. - Known leftover:
MessageHeaderand other components with logic still put theirrcx-*class before the consumer's props, so a consumerclassNamereplaces it. - Box:
Boxitself still runs on css-in-js. - Out of scope:
RaisedHandsButtonin ui-conference spreadspressedinto aBoxand gets a React warning; judging from the code, develop does the same.