fix(ui): repair scope SSR and shrink the ui pre-bundle 58MB to 24MB - #10628
Conversation
PR Summary by QodoFix scope SSR failures and shrink shipped UI SSR bundle
AI Description
Diagram
High-Level Assessment
Files changed (9)
|
Code Review by Qodo
1. Ad-hoc chalk in writeStats
|
| const filePath = writeBundleStats(stats, name); | ||
| if (filePath) this.logger.console(`${chalk.magenta('[Rspack]')} wrote bundle stats to ${chalk.cyan(filePath)}`); |
There was a problem hiding this comment.
1. Ad-hoc chalk in writestats 📘 Rule violation ⚙ Maintainability
The new writeStats() CLI output uses direct chalk formatting and a hardcoded [Rspack] prefix instead of the repository’s shared CLI output formatting toolkit. This risks inconsistent CLI output styling across commands and bypasses the documented style guide.
Agent Prompt
## Issue description
`UiMain.writeStats()` prints CLI output using ad-hoc `chalk` formatting (e.g. `chalk.magenta('[Rspack]')`) rather than using the shared CLI output formatting toolkit.
## Issue Context
The repo requires CLI output to follow `scopes/harmony/cli/cli-output-style-guide.md` and use the shared formatter utilities from `@teambit/cli` (`scopes/harmony/cli/output-formatter.ts`) to keep output consistent and maintainable.
## Fix Focus Areas
- scopes/ui-foundation/ui/ui.main.runtime.ts[309-315]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| const filePath = join(dir, `${name}.stats.json`); | ||
| writeFileSync(filePath, JSON.stringify(json)); |
There was a problem hiding this comment.
2. Stats path can break 🐞 Bug ◔ Observability
writeBundleStats() builds the output path with the unsanitized name, so names containing path separators can create an unintended nested path and make writeFileSync() fail (ENOENT). UiMain.writeStats() swallows that failure (debug-only), so enabling BIT_UI_BUNDLE_STATS may produce no stats files without any visible signal.
Agent Prompt
## Issue description
`writeBundleStats(stats, name)` uses `join(dir, `${name}.stats.json`)` and only creates `dir`. If `name` contains `/` or `\\`, the resulting `filePath` includes intermediate directories that do not exist, causing `writeFileSync()` to throw. The caller (`UiMain.writeStats`) catches and logs only at debug level, making the failure effectively silent when diagnostics are explicitly enabled.
## Issue Context
This is an opt-in diagnostics path (`BIT_UI_BUNDLE_STATS`), so it must be robust to odd names and should reliably write a file when enabled.
## Fix Focus Areas
- scopes/ui-foundation/ui/rspack/bundle-stats.ts[24-39]
- scopes/ui-foundation/ui/ui.main.runtime.ts[305-316]
## Suggested fix
- Sanitize `name` into a filename-safe value (e.g., replace `/` and `\\` with `_`, and prevent `..` segments).
- Ensure the parent directory of `filePath` exists (e.g., `mkdirpSync(dirname(filePath))`) before writing.
- (Optional) If writing fails, consider logging a `warn` (still not failing the build) so the user who opted in understands why no file appeared.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
|
Code review by qodo was updated up to the latest commit 557eef4 |
|
Code review by qodo was updated up to the latest commit c1f9286 |
Part 2 of #10596 — the workspace/scope dedupe. Builds both UI roots in **one** rspack compilation with an entry each, so the chunks they share are emitted once. Takes the shipped `@teambit/ui` pre-bundle from **24 MB to 16 MB**. > Stacked on #10628 and targets that branch. Retarget to `master` once #10628 merges. ## The roots are the same app The issue estimated a small win from file-level comparison ("only 25 files / 1.9 MB byte-identical"). At the *module* level they are all but identical: | | modules | bytes | | --- | --- | --- | | workspace root | 3,403 | 16.00 MB | | scope root | 3,403 | 16.00 MB | | **shared by both** | **3,402** | **15.96 MB** | | workspace-only | 1 | 40 KB | | scope-only | 1 | 40 KB | The one differing module is the generated root entry (`ui.root<hash>.js`) — same aspect graph, different root aspect id baked in. Both the `workspace` and `scope` aspects are already in *both* bundles; the root id only selects which one renders. So the duplicate is essentially total, and so is the saving. | | before | after | | --- | --- | --- | | browser chunks | 16.4 MB (2 copies) | **8.2 MB** (1 copy) | | ssr | 7.9 MB | 7.9 MB | | **artifact total** | **24 MB** | **16 MB** | The 6.53 MB vendor chunk and 0.53 MB CSS are now shared; each root adds ~40 KB of its own. Combined with #10628 this is 58 MB → 16 MB. It also lowers peak memory during `bit build` — the concern raised on #10612 — since there is one module graph instead of two, and the `BundleUI` task got slightly faster (17s vs 18-19s). ## Layout ``` artifacts/ui-bundle/ .hash JSON: { "<rootAspectId>": "<sha1>", … } public/bit/ workspace.html only the chunks the workspace entry needs scope.html only the chunks the scope entry needs asset-manifest.json static/js|css/… shared chunks, emitted once ssr/index.js scope only ``` Three things had to change to support it: 1. **`.hash` is now a map.** `shouldServeBundleUi` compares a *per-root* hash, but there is now one bundle. `.hash` holds one hash per root aspect id and `readBundleUiHash` looks up the root being served. A root missing from the map — or a bundle in the old layout — reads as "no pre-bundle" and falls back to a local build. 2. **No more `index.html`.** With two roots in one compilation there is no single default document, so the server falls back to `<root>.html`. This is the sharpest edge in the PR: get the name wrong and every *client-side* route 404s while the SSR-rendered ones keep working. There is a test for exactly that (below). 3. **The asset manifest is entry-aware.** `generateAssetManifest` hardcoded `entrypoints.main`, and it is shared with the preview aspect's rspack config, so renaming was not an option. It now also emits `entrypointsByName`; `entrypoints` keeps its old meaning for single-entry compilations, so the preview side is untouched. The SSR middleware prefers its root's entry and falls back to `entrypoints`. `build()` now builds every registered root rather than one, so `uiRootAspectIdOrName` only selects the output location. That is nearly free — the module graph is shared — and it keeps one layout everywhere instead of one for the build task and another for `bit start`. A bare scope registers only the scope root, so it emits just `scope.html`. `bit start --dev` is unaffected; the dev config is separate and still single-entry. ## Deletes machinery Because the two roots bundled concurrently, #10612 had to defer closing the compilers (`openBuildCompilers` + a `deferClose` option on `UiMain.build`) instead of closing inside `build()`. With one compiler there is no concurrent sibling, and it collapses into a single close in `build()`'s `finally`. `buildIfNoBundle` also stopped constructing a whole rspack config — resolving every root's aspects — just to read `output.path`. ## Testing Both roots verified in a real browser, on **both** paths that can serve them: - *local build* (`bit start --rebuild`): bare scope emits `scope.html` and SSRs every route; workspace emits both htmls with the 6.53 MB vendor chunk present once. - *pre-bundle* (the production path): built artifact placed in the bvm install, `bit start` logged `returned from ui bundle cache` and `bundle will be served from …/ui-bundle/public/bit`, with no local build directory created. Scope SSR renders (`/` 27 KB, `/ui/button` 52 KB); workspace serves `workspace.html` through the fallback for client-side routes. Workspace and scope home pages, component page, code tab and API reference all render with no console errors. New e2e assertion covers change 2 — `?rendering=client` makes the SSR middleware call `next()`, which is the only way to reach the history-api fallback. Verified it catches the bug: pointing the fallback back at `index.html` fails that test and only that test, which is precisely the failure mode described above.
…10628/#10629 (#10631) Sanity e2e for `bit start` itself, covering both UI roots. Part 3 of #10596 follow-up work. > Rebased onto `master` now that #10628 and #10629 have merged; targets `master` directly. Until now nothing exercised `bit start` end to end. That is how the scope SSR bundle managed to throw on every request for months (#10628), and the layout change in #10629 has a matching failure mode: name the fallback document wrong and every *client-side* route 404s while SSR-rendered ones keep working. `e2e/harmony/ui-start.e2e.ts` starts a real server per root and asserts, over http: - startup writes nothing matching `/error|exception|unhandled/i` to stderr — a server can listen fine with an aspect that failed to load - the served document has a react root - **every script and stylesheet the document references actually resolves 200** — this is the one that catches assets emitted under a path the server does not expose - a deep client-side route returns a document (the history-api fallback) - `/graphql` answers without errors - workspace only: the document loads the *workspace* entry and not the scope one — both roots are entries of one bundle now, so serving the wrong document would still look like a working page, just booting the other root's app - scope only: the markup is server-rendered and contains the exported component 12 assertions, ~1 min. All `--rebuild`, so they describe this repo's code rather than whichever bit release is installed. ## Two supporting changes **`HttpHelper` can start either root.** It was hardcoded to the bare scope (`scopes.remotePath`, and a ready-message string naming `teambit.scope/scope`). It now takes `{ extraArgs, uiRootAspectId }`, derives the cwd from the root, and builds the ready message per root. Existing callers use the unchanged two-arg form. It also records stderr so tests can assert on a clean startup. **`portHolders()` now filters to listening sockets** (`lsof -ti tcp:PORT -sTCP:LISTEN`). Without it lsof also reports processes holding a *client* socket to the port — including the mocha process itself, since node keeps connections alive after a test fetches from the server. `waitForPortToBeFree` read that as a foreign process squatting the port and refused to continue, failing the `after` hooks. Only a listener can actually hold a port. This was a latent bug in the helper; the new tests hit it because they fetch every referenced asset. --- ## Also: Qodo review findings from #10628 / #10629 Both of those merged before their review findings were addressed, so the actionable ones land here. Each was verified against the code rather than taken on trust. **`bit start` 404s on an existing local UI build (from #10629) — the important one.** `buildIfNoBundle()` treated *any* existing `public/bit` directory as a valid build, but the server now falls back to `<root>.html`, which a build made before #10629 does not contain. Reproduced end to end: with the pre-fix check the whole UI returns **404** on `/` and on deep routes; with the fix it detects the missing document, rebuilds, and serves 200. This would have hit every user upgrading past #10629 with a previously-built local UI. It now checks for the root's document rather than the directory. **Hash written for roots that were never built (from #10629).** `generateHash()` walked a hardcoded root list and threw when one was not registered. Beyond failing in a scope-only runtime, it could record a hash for a root whose document was never emitted — which reads at startup as "a pre-bundle exists" and then 404s, the same failure as above. It now walks the same registered roots `build()` turns into entries, via a new `UiMain.getUiRoots()`. **Service worker bound to a document that is not emitted (from #10629).** Confirmed in the built artifact: `service-worker.js` contained `createHandlerBoundToURL("public/index.html")` while the build emits only `scope.html` / `workspace.html`. With an entry per root there is no single app shell, so `navigateFallback` is removed — the express history-api fallback already serves the right document. Verified the built service worker no longer contains that binding. **Entry name collisions (from #10629).** `Object.fromEntries` would silently keep only the last of two entries sharing a sanitized name, leaving a root with no chunks and no document while still looking built. Now throws instead. **Stats filename could break (from #10628).** `writeBundleStats` interpolated an unsanitized name into a path, so a root name containing `/` would fail with ENOENT into a swallowed debug log. Now sanitized. Not changed: the "ad-hoc chalk in `writeStats`" rule violation. That line matches the surrounding `[Rspack]` log statements in the same file; the style guide it cites covers section titles and symbols in command output, not diagnostic log lines. Happy to switch it if you'd rather be strict. The `preview/bundle-stats.ts` copy of the sanitization fix lands with #10632, which is where that file lives.
…10628/#10629 (#10631) Sanity e2e for `bit start` itself, covering both UI roots. Part 3 of > Rebased onto `master` now that #10628 and #10629 have merged; targets `master` directly. Until now nothing exercised `bit start` end to end. That is how the scope SSR bundle managed to throw on every request for months (#10628), and the layout change in #10629 has a matching failure mode: name the fallback document wrong and every *client-side* route 404s while SSR-rendered ones keep working. `e2e/harmony/ui-start.e2e.ts` starts a real server per root and asserts, over http: - startup writes nothing matching `/error|exception|unhandled/i` to stderr — a server can listen fine with an aspect that failed to load - the served document has a react root - **every script and stylesheet the document references actually resolves 200** — this is the one that catches assets emitted under a path the server does not expose - a deep client-side route returns a document (the history-api fallback) - `/graphql` answers without errors - workspace only: the document loads the *workspace* entry and not the scope one — both roots are entries of one bundle now, so serving the wrong document would still look like a working page, just booting the other root's app - scope only: the markup is server-rendered and contains the exported component 12 assertions, ~1 min. All `--rebuild`, so they describe this repo's code rather than whichever bit release is installed. **`HttpHelper` can start either root.** It was hardcoded to the bare scope (`scopes.remotePath`, and a ready-message string naming `teambit.scope/scope`). It now takes `{ extraArgs, uiRootAspectId }`, derives the cwd from the root, and builds the ready message per root. Existing callers use the unchanged two-arg form. It also records stderr so tests can assert on a clean startup. **`portHolders()` now filters to listening sockets** (`lsof -ti tcp:PORT -sTCP:LISTEN`). Without it lsof also reports processes holding a *client* socket to the port — including the mocha process itself, since node keeps connections alive after a test fetches from the server. `waitForPortToBeFree` read that as a foreign process squatting the port and refused to continue, failing the `after` hooks. Only a listener can actually hold a port. This was a latent bug in the helper; the new tests hit it because they fetch every referenced asset. --- Both of those merged before their review findings were addressed, so the actionable ones land here. Each was verified against the code rather than taken on trust. **`bit start` 404s on an existing local UI build (from #10629) — the important one.** `buildIfNoBundle()` treated *any* existing `public/bit` directory as a valid build, but the server now falls back to `<root>.html`, which a build made before #10629 does not contain. Reproduced end to end: with the pre-fix check the whole UI returns **404** on `/` and on deep routes; with the fix it detects the missing document, rebuilds, and serves 200. This would have hit every user upgrading past #10629 with a previously-built local UI. It now checks for the root's document rather than the directory. **Hash written for roots that were never built (from #10629).** `generateHash()` walked a hardcoded root list and threw when one was not registered. Beyond failing in a scope-only runtime, it could record a hash for a root whose document was never emitted — which reads at startup as "a pre-bundle exists" and then 404s, the same failure as above. It now walks the same registered roots `build()` turns into entries, via a new `UiMain.getUiRoots()`. **Service worker bound to a document that is not emitted (from contained `createHandlerBoundToURL("public/index.html")` while the build emits only `scope.html` / `workspace.html`. With an entry per root there is no single app shell, so `navigateFallback` is removed — the express history-api fallback already serves the right document. Verified the built service worker no longer contains that binding. **Entry name collisions (from #10629).** `Object.fromEntries` would silently keep only the last of two entries sharing a sanitized name, leaving a root with no chunks and no document while still looking built. Now throws instead. **Stats filename could break (from #10628).** `writeBundleStats` interpolated an unsanitized name into a path, so a root name containing `/` would fail with ENOENT into a swallowed debug log. Now sanitized. Not changed: the "ad-hoc chalk in `writeStats`" rule violation. That line matches the surrounding `[Rspack]` log statements in the same file; the style guide it cites covers section titles and symbols in command output, not diagnostic log lines. Happy to switch it if you'd rather be strict. The `preview/bundle-stats.ts` copy of the sanitization fix lands with (cherry picked from commit 59bd5c2)
…SSR gap Rebuilt the UI/preview pre-bundle from current source and refreshed .bundle-cache/ - UI artifact 80 MB -> 16 MB, matching upstream #10629's single-compilation dedupe now that it's reflected on this branch. Verified end to end against a real `npm run bundle` build: 16/16 UI-bundling sanity tests passing, including SSR. Total shipped distribution 216 MB / 2,933 files -> 160 MB / 2,839 files. Also documents a scope-UI SSR crash found while validating (window is not defined in useUserAgent), confirmed scoped to local --rebuild mode only - the shipped, forPreBundle-filtered pre-bundle is unaffected. Not fixed this session; tracked as a known gap. See PRs #10628, #10629, #10631 for the upstream work behind the numbers.
Adds `docs/ui-bundle-size-analysis.md` — where the remaining bundle size sits after #10628 and #10629, what I measured, and what I tried that did not work. Written to be picked up cold in a later session. > Rebased onto `master` now that #10628, #10629 and #10631 have merged; targets `master` directly. Env preview duplication is deliberately excluded — the core envs are being removed, which takes it along. ## Also in here - **Preview bundle stats.** `BIT_UI_BUNDLE_STATS=1` now covers the preview pre-bundle too, so one build produces `browser`, `scope-ssr` and `preview` stats together. It is a small copy of the UI helper rather than an import: `@teambit/ui`'s index is imported by browser code, and re-exporting a node-only module through it pulled `fs` polyfills into the UI bundle (caught by the build failing on `Can't resolve 'constants'`). - **Stats were being under-reported.** rspack's `toJson` groups assets and modules into summary rows ("assets by status") that carry a size but no name. That showed up as a single unattributable 3.3 MB / 17.9% bucket, and `assets: 0`. All `groupModulesBy*` / `groupAssetsBy*` flags are now off. - **`analyze-bundle.mjs` crashed** on assets without a `name` (those same grouped rows). - **`code-view.tsx`** imported `createElement` from the `react-syntax-highlighter` package root, which defeats its own `prism-light` import two lines later. Changed to the deep path. Behaviour-neutral, and worth stating plainly: **it saves nothing today** — see below. ## Headline findings **The two eagerly-loaded syntax highlighting registries are the biggest single item** — `highlight.js` (1.34 MB, every language) plus `refractor` (0.85 MB, every Prism language), in both the browser and ssr bundles. Neither is imported directly anywhere in this repo. They come in through `react-syntax-highlighter`'s package root, which re-exports every build including the full-language ones. The blocker is that the remaining root imports are in *published* `@teambit` components in `node_modules` (`api-reference.renderers.schema-node-member-summary`, `documenter.ui.code-snippet`), whose source is not in this repo. I verified this: fixing the in-repo imports changes the artifact by **0 bytes**. Filed separately as #10633. I tried the bundler-level workaround (alias `lowlight` → `lowlight/lib/core`, `refractor` → `refractor/core`) and **rejected it**: bit fails the build because both are transitive and would have to be declared dependencies of `@teambit/ui`, and it silently degrades any consumer relying on auto-registered languages to plain text. That is a product call, not a build one. **`lodash` is the best effort-to-reward item.** It is CJS-only (no `module` field), so it cannot be tree-shaken, and the repo has 280 `from 'lodash'` imports and zero cherry-picked ones. It is 0.52 MB of the 1.96 MB preview bundle — 28% — for six functions. Fixing it pays out in the browser, ssr and preview bundles at once. Also documented: `graphql` shipping whole into preview (28%), `sucrase` (0.47 MB) arriving via `react-live` and never lazy-loaded, `date-fns` at 302 modules, and the fact that one 6.23 MB chunk is the entire eager payload — which is what makes the cold-cache first paint slower than client-only rendering (measured in #10628). `@shikijs/langs` is 1.45 MB but already lazy-loaded per language, and is called out as the pattern the rest of the UI should copy. ## Update after the stack merged Rebased onto `master`. Two follow-ups folded in: - **`preview/bundle-stats.ts` gets the filename sanitization** that #10631 applied to its UI twin (a Qodo finding from #10628). This file only exists on this branch, which is why it was carried over rather than fixed there. - **The doc records that the service worker no longer claims navigations.** #10631 removed the `navigateFallback` that still pointed at an `index.html` the multi-entry build stopped emitting, so the analysis notes that re-adding an offline shell now has to answer what that means for two roots. Qodo reviewed this PR and found no issues. Re-validated on the rebased branch: full compile, fresh-capsule build of both bundles with `BIT_UI_BUNDLE_STATS=1`, and `analyze-bundle.mjs` reproducing the figures the doc quotes — 6.23 MB eager chunk, `@shikijs/langs` 1.45 MB / 9.5%, `highlight.js` 1.34 MB / 8.7%, and in preview `graphql` 0.52 MB / 27.9% next to `lodash` 0.52 MB / 27.7%.
`useUserAgent` reads `ssrBrowserContext`, and that context is provided
in exactly one place — wrapping only `{hudItems}` and `{routes}`,
*inside* `ClientContext`. Everything rendered outside that subtree
therefore gets `undefined`, falls back to `window.navigator`, and throws
`ReferenceError: window is not defined` on the server. The ssr
middleware swallows it and serves the empty client shell, so the page
still looks fine in a browser.
`ClientContext` is not a bare provider: it renders icons, the theme
switcher, the loader ribbon and the tooltip mount point. Any of those
growing a `useIsMobile` call — directly or through a dependency bump —
takes scope SSR down. Swapping the nesting so `SSRBrowserProvider` wraps
`ClientContext` closes the whole class.
## Why this surfaced now
Reported from a branch that removes core envs from the manifest, where
the crash reproduces deterministically:
```
[ssr] failed at '/'
ReferenceError: window is not defined
at useUserAgent → useIsMobile → Tooltip
```
That branch already has all of #10628's fixes, so this is not a
regression of them — it is a second, independent gap that #10628 did not
cover. It was latent on `master` only because nothing inside
`ClientContext` currently happens to call `useIsMobile`; changing which
aspects load changes what renders there.
Worth being explicit that the `.cjs` fix and the `use-user-agent` alias
from #10628 are still doing their jobs. This is a tree-position bug, not
a duplicate-context one — the alias pins the module to a single copy, so
provider and consumer share the context object; the consumer is simply
rendered where no provider is above it.
## Proof
Rendering a `Tooltip` inside `ClientContext` — what the reporting branch
effectively does — and changing nothing else:
| nesting | `ui-ssr.e2e.ts` |
| --- | --- |
| `ClientContext` > `SSRBrowserProvider` (before) | **2 failing** —
`#root` empty, ssr fell back to the client shell |
| `SSRBrowserProvider` > `ClientContext` (after) | **4 passing** |
The synthetic `Tooltip` was then removed; both UI suites pass on the
final diff (16 assertions).
## Residual gap
Render-plugin `reactContext`s (pubsub, lanes, notifications, user-agent)
are applied by `ServerRenderer` *outside* the JSX this file passes, so
they remain above the provider. A component rendered by one of those
contexts — as opposed to inside its children — would still hit this.
Covering that needs `ssrBrowserContext` supplied as a render plugin
rather than as JSX, which is a larger change and not what the reported
failure needs.
Also worth noting separately: `scopes/ui-foundation/user-agent` provides
`userAgentContext`, which `useUserAgent` never reads — it only reads
`ssrBrowserContext`. That aspect looks vestigial for this purpose.
Part 1 of #10596. Cuts the shipped
@teambit/uipre-bundle from 58 MB to 24 MB, and fixes scope SSR, which turned out to have been throwing on every request for months.The workspace/scope single-compilation dedupe (~8 MB) is deliberately left for a follow-up PR — it needs an artifact-layout and
.hashredesign, and mixing it in would make the SSR fix hard to verify.Size: 58 MB → 24 MB
ui-bundle/scope/public/bit/ssr/index.jsui-bundle/scopeui-bundle/workspaceTwo causes, both in
rspack.ssr.config.ts:devtool: 'eval-cheap-module-source-map'wrapped every module ineval('…')with an inlined base64 source map — 2,566 of them, 23.4 MB, 60% of the file. It entered as'eval-cheap-source-map', // TODOand was later retuned in chore(webpack): better development sourcemaps #7147 as a development sourcemap change; it was never a deliberate choice for a shipped artifact. NowshouldUseSourceMap ? 'source-map' : false, matching the browser config's opt-in.optimization.minimizerat all. It now mirrors the browser build'sSwcJsMinimizerRspackPlugin.Scope SSR was dead, and it took three fixes
Only
scope.ui-root.tssetsssr: true. The middleware catches any render error and callsnext(), falling through to the staticindex.html— which looks completely normal in a browser. So the failure was invisible:curl /andcurl '/?rendering=client'returned byte-identical 1,027-byte responses with an empty<div id="root"></div>.Three independent defects were stacked:
cjsmissing from the SSR asset catch-all. The browser config excludes/\.(cjs|js|mjs|jsx|ts|tsx)$/; the SSR config omittedcjs, so every.cjsmodule was emitted as anasset/resourcewhose module value is the file's URL. A component imported from one reached React as a tag name →Minified React error #65= "Invalid tag: /public/ssr/<hash>.cjs". Introduced by the webpack→rspack migration (feat(workspace | preview): migrate from webpack to rspack #10187), which replaced a shared base config with two hand-written ones and addedcjsto only one.use-user-agentpresent at two versions (0.0.199 and 0.0.200). Each copy callscreateContext, soui.ui.runtime.tsxprovidedssrBrowserContexton one instance whileTooltip'suseUserAgentread the other, gotundefined, took the browser fallback and dereferencedwindowon the server. Fixed by adding it toresolveAlias— the list that exists for exactly this class of bug. Aliasing the hook fixes every consumer, so the duplicatedTooltipcopies need no aliasing of their own.windowin auseEffectdependency array —useCurrentUserhad}, [window.location.href]);. The effect body never runs on the server, but a dependency array is evaluated on every render, including the server one.All routes now render server-side and the log shows zero SSR failures:
//ui/button/ui/button/~codeA visible side effect: the document title was the build-time placeholder (
bit-local-88bfe855) and is now the actual scope name.Browser runtime effect
This turns SSR on for the first time in months, so it is a real runtime change. Bare scope on localhost, 7 runs, median, SSR vs
?rendering=client:SSR is ~5× faster to first paint on a repeat visit, and ~200 ms slower on a cold first load where the 6.4 MB of JS dominates and the larger HTML delays stylesheet discovery. Server startup is unchanged (~1.77 s); the first request drops from 0.79 s to 0.49 s while now doing a real render instead of throwing. Note localhost has ~0 latency, which flatters client rendering — over a network SSR's margin widens.
Testing
New e2e
e2e/harmony/ui-ssr.e2e.tsasserts on the served HTML, since a browser cannot distinguish a working SSR render from the fallback.It runs
bit start --rebuild, which matters:getBundleUiPathresolves throughgetAspectDirFromBvm, so without--rebuildthe server serves the pre-bundle from the installed bvm version and the test would assert on whatever bit release happens to be installed rather than on this code.HttpHelpergained an optional extra-args parameter for this.Verified the test actually catches the bug — with the
cjsfix reverted, 2 of the 3 assertions fail. (The third, an assertion that the scope name appears anywhere in the document, passed in both states because--rebuildputs the scope name in the static<title>; it now asserts inside#rootinstead.)Manual verification on both UI roots, with the freshly built artifact swapped into the bvm install and the released
.hashfiles preserved (a.hashmismatch makesbit startsilently rebuild locally instead of serving the artifact). Confirmed the new bundle was genuinely served by diffing content-hashed asset names against the originals. Both roots checked in a real browser: workspace and scope home, component page, code tab, API reference — no console errors. The one error found (componentChangedsubscription missing on a bare scope) reproduces identically on the original bundle and is pre-existing.Bundle analysis tooling
BIT_UI_BUNDLE_STATS=1makes the UI build write an rspack stats file per compilation, andscripts/analyze-bundle.mjs(npm run analyze-bundle) summarizes assets plus the heaviest packages and workspace scopes. No new dependency. This is what found defect 2 above.It also surfaced that 81–86 packages appear at more than one version in a single bundle (
@teambit/design.ui.tooltipat eight). That is only ~0.6 MB (~3% of module bytes), so it is a React-context correctness risk rather than a size lever — worth tracking separately.Not in scope
The six env preview pre-bundles (
@teambit/{react,node,mdx,env,aspect,readme}/artifacts/env-template) are 37.9 MB with only 6.4 MB of unique content — 31.5 MB is byte-identical across the six. That is now the largest single remaining item, bigger than the workspace/scope dedupe, and is not covered by #10596 as written.