feat(workspace): instant workspace load + pooled previews - #10191
feat(workspace): instant workspace load + pooled previews#10191luvkapur wants to merge 56 commits into
Conversation
… query loading Split the workspace GraphQL query into three progressive stages: - Light query (~120ms): component list, env info, server URLs, build status - Heavy query (~78ms): compositions, aspects, descriptions, issue counts - Status query (~13s): deferred — doesn't block the UI Only the light query triggers the global loading spinner (useDataQuery). Heavy and status queries use useQuery directly, so they resolve silently in the background without blocking interaction. Additional changes: - Switch lanes, cloud, and drawer hooks from useDataQuery to useQuery to remove them from the global loader - Disable Apollo BatchHttpLink by default (fast queries were blocked by slow ones in the same batch) - Fix drawer loading state: return loading=false when viewing workspace versions instead of waiting on lane queries - Proxy: add cache-control headers on JS/CSS, normalize double-slash URLs, configure timeouts Measured results (212 components): - Global loader visible ~120ms (was 12-16s) — ~100x improvement - Component data available in <200ms - Status resolves in background (~13s, doesn't block UI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> EOF )
The env icon in workspace component cards was not updating after the heavy GraphQL query delivered aspect data. Root cause: useCardPlugins() memoization only depended on component count, not on descriptor data. Fix: add descriptorAspectsSignature to the memo dependencies so plugins are recreated when aspect data arrives. Also show a subtle placeholder while env data is loading instead of a broken image. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…untime conflicts)
Resolved 18 conflicts, keeping the PR's perf architecture while adopting master's 643 commits of changes. Key resolutions: - graphql.ui.runtime: master's opt-in per-op batching (shouldBatch/enableBatching) + PR's retry link, connection reporting, and apollo cache persistence - use-workspace: PR's light/heavy/status split queries (+ master's deprecation.range) - workspace-overview: master's shared ComponentsOverview extraction wins; PR's virtual-grid dropped (to be re-ported into explorer.ui.components-overview) - preview-placeholder: PR's hydration queue wins over master's ViewportGate - ui-server: PR's dynamic component proxies + master's pending-server queue and multi-entry bundle html fallback - preview.start-plugin: both sides' publish paths combined Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
version-dropdown_1 was an untracked duplicate accidentally committed in a wip commit; @tanstack/react-virtual became unused after the virtual-grid was superseded by master's shared ComponentsOverview. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ile loading The instant-workspace path renders an empty WorkspaceModel while the light query is in flight; the overview treated zero components as 'no components yet' and flashed the create-your-first-component blank state on every cold load. Gate on WorkspaceUIContext.loading and render ComponentsOverviewSkeleton until the initial query resolves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…zations # Conflicts: # pnpm-lock.yaml
… the empty state Two separate causes of "the workspace looks blank for a moment on load": 1. The served HTML was `<body><div id="root"></div></body>`, so nothing could paint until several MB of dev-mode JS downloaded, parsed and React committed — measured first contentful paint at 516ms with the first grid cards at 776ms on a 208-component workspace. A React-level skeleton cannot cover that window because React isn't running yet. `html.ts` now inlines a static app shell (top bar, 246px sidebar, sticky filter bar, 280px-min card grid) with its own critical CSS, so the layout paints at ~82ms and cross-fades out on React's first commit, with a 15s safety net if the app never mounts. 2. `WorkspaceOverview` decided "this workspace is empty" from `loading === false`, which is also true for a settled-but-failed light query (`errorPolicy: 'all'` returns no data with loading=false) — so a transient no-data frame rendered the "create your first component" blank state. The overview now gates on a new `resolved` flag, true only once the light query has actually returned a workspace; anything else renders the grid skeleton. Measured after (208 components, 3 loads): shell at 82ms, grid at 532-1313ms, zero blank-state frames. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`bit start` reuses the built bundle whenever `createBuildUiHash` matches, but that hash covers only *which* aspects are in the bundle (their resolved paths), not what they contain. Editing UI source, recompiling and restarting therefore left the hash untouched and silently served the previous bundle — the developer tests code that is not running. This cost a full debugging cycle today: a workspace-overview fix appeared to have no effect because the bundle predated it by nine minutes. Before reusing a bundle, compare its build time (`asset-manifest.json`, rewritten by every rspack build) against the compiled output of the aspects that contribute a UI runtime, and rebuild if any is newer, naming the file that triggered it. The walk stops at the first newer file and covers ~30 packages / ~2k files — measured at ~90ms, only on the path where a local build already exists, so a consumer workspace serving the published pre-bundle never reaches it. Scoping it to UI-runtime aspects keeps a main-runtime-only edit from triggering a needless UI rebuild. Non-aspect UI components pulled in transitively are still not covered; `--rebuild` remains the escape hatch. Also adds `scripts/measure-ui-boot.js`: drives headless Chrome over CDP and samples the DOM every animation frame to report first paint, boot-shell and app-mount timings, and to fail if the "create your first component" blank state is rendered on the way. Both bugs fixed in the previous commit were invisible to source review and only observable in a real bundle in a real browser. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mping stats When one env fails to bundle, `bundler.devServer()` rejects and every env loses its preview server. The workspace UI stays up, so this console message is the only signal the developer gets - and it printed rspack's full stats as the error, burying the two real errors under ~400 lines of module listings. On the test workspace this read as "previews just never load" with no visible cause. Summarize instead: pull out the `ERROR in` blocks, recover the env id from the capsule directory name (scope_namespace_name@version is the id with its separators flattened), and print one line per error, capped, with the untouched original still going to the debug log. Before: ~400 lines of stats. After, three lines naming each broken env and its missing module. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…wn with it
`DevServerService.runOnce` created the dev servers in a single `pMapSeries` with no per-group
try/catch, so the first rejection aborted the whole batch: groups handled before it had their
`ComponentServer` built and then discarded, groups after it were never attempted, and
`bundler.devServer()` rejected. On the test workspace that meant all 208 components reported
`server.url: null` because two env capsules cannot resolve `subscriptions-transport-ws`.
Isolating the loop is necessary but not sufficient - `dedupEnvs` puts all nine envs of that
workspace into one group sharing one dev server, and building that group's context pre-bundles a
preview runtime containing *every* env's preview aspect, so one env with an unresolvable import
fails the bundle for all of them. When the bundler names the capsules that broke, drop those envs
from the group and build again: the envs that are fine get their preview, the ones that broke get
none, and both are reported. The retry restores the contexts' component lists first, because
`buildContext` merges the group into the main context and would otherwise pull the dropped envs
back in.
Failures now travel back as data (`{ servers, failures }` from `runOnce`, `getDevServerFailures()`
on `BundlerMain`) instead of as a rejection, and the start plugin prints them through the existing
`summarizeBundlerError`. `envs/runtime.ts` is untouched - the other `runOnce` implementer
(`BuilderService`) is unaffected. An all-groups-failed run still fails loudly.
Measured on perf-test-ws (208 components, 9 envs), components with a preview server:
before 0/208 all nine envs, including the seven healthy ones
after 95/208 7 envs served; community-react (107) and symphony-react@0.0.6 (6) excluded
Console output for the two broken envs is now:
preview bootstrap: 2 environments could not be served - their components will have no preview:
teambit.community/envs/community-react@2.1.8, teambit.dot-symphony/envs/symphony-react@0.0.6
teambit.community/envs/community-react@2.1.8: Module not found: Can't resolve 'subscriptions-transport-ws'
teambit.dot-symphony/envs/symphony-react@0.0.6: Module not found: Can't resolve 'subscriptions-transport-ws'
full bundler output was written to the debug log (bit globals)
Left out: parallelizing the group loop (the TODO at dev-server.service.ts stays, the pre-bundle
still has to run one group at a time), and any UI surface for the envs without a preview -
`BundlerMain.getEnvIdsWithoutDevServer()` exposes them for a later graphql field.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Preview hydration was a one-way latch: `canHydratePreview` was never set back to false and `warmedPreviews` grew forever, so the concurrency budget throttled how *fast* previews mounted but nothing bounded how *many* stayed mounted. Every mounted preview is an iframe, and every iframe is a JS realm that parses and executes the env's preview bundle again. Measured on a 194-component workspace (rspack env, eight 900px scroll steps, headless Chrome): iframes grew 37 -> 53 -> 61 -> 73 -> 79 and never shrank, ending at 1209MB heap, 156 websockets and 17.6s of cumulative script. The marginal cost of a preview is ~360ms of main thread and ~21MB. Keep a bounded set of live previews and retire the ones furthest from the viewport. Retired previews stay in `warmedPreviews`, so scrolling back re-mounts them without waiting for a hydration slot, and their assets come from the browser cache. Measured effect, same build and same server, toggled with `?maxLivePreviews=`: off: 79 iframes, 1209MB heap, 156 sockets, 17.6s script on: 58 iframes, 1176MB heap, 145 sockets, 17.4s script An honest reading: this bounds growth but is not a large win here, and it does not move CPU at all - cumulative script is unchanged because each preview still hydrates once and eviction only unmounts after that cost is paid. A cap of 12 produces the same 58 as a cap of 32, because the 2000px keep-margin (which stops a card the user is about to see from blanking) protects most of this grid. The benefit scales with workspace size: the protected band is a constant, so a larger workspace retires proportionally more. The real CPU lever is to stop giving every card its own realm. Two bugs found while making this measurable, both worth noting: - `retire()` originally closed over the hydration effect's `isMounted` flag. Hydration flips `canHydratePreview`, which re-runs that effect, whose cleanup sets the previous run's flag to false - so every registered `retire` was a no-op from birth and the first A/B showed no difference at all. It now consults a component-lifetime ref. - The first A/B was invalid for a second reason: `bit start` served a bundle built before the fix, because the staleness check added earlier only walks *aspect* packages and `preview-placeholder` is a plain UI component. That gap is documented in that commit; this is it happening for real. `?maxLivePreviews=<n>` overrides the cap, which is what makes before/after runnable against one build and one server rather than two rebuilds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Code review by qodo was updated up to the latest commit 047548e |
Pooled previews required an opt-in batchedPreviews=true query param, and in-app navigation rewrites the search string, so grids silently fell back to one-iframe-per-card mid-session. Pooling is now the default and batchedPreviews=false opts out. The watch-backend delivery probe with its watchman/fs-events/kqueue fallback is removed: it worked around one machine's wedged fseventsd, and that belongs in machine remediation (restart fseventsd), not in Bit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Code review by qodo was updated up to the latest commit ea8f200 |
Four fixes that together make preview hot updates land deterministically:
- generate-link: a realm whose hash names no preview is showing the
default one (overview pages); it must dispatch the modules-updated
event too, or docs edits never re-render there.
- ui-server: every env registers an upgrade handler on the shared http
server and all of them see every upgrade. A non-matching env's handler
closed the socket, so whichever handler ran first killed other envs'
HMR sockets (and the graphql /subscriptions socket) with a 503. Only
the owner may act on an upgrade, and only owned routes may be closed.
- ui-server: hot-update requests now pass through the proxy while an env
is marked compiling. The dev server's own middleware holds them until
the build settles; answering 503 made the HMR client abort the update
("Failed to fetch update manifest") whenever a fetch raced a rebuild -
which a second compile pass guarantees - silently dropping edits.
Also removes the invalidate() plumbing added earlier: with the component
packages watchable (the env dev-service un-ignores its pnpm file+ paths),
the bundler's own watcher drives rebuilds natively and the explicit
rebuild chain is dead weight.
Measured on the 198-component workspace: composition and docs edits
hot-swap in 3-5s with zero iframe reloads, repeatedly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Code review by qodo was updated up to the latest commit 7de3a2c |
…ragment A frontmatter label edit publishes a fresh component over /subscriptions, but the workspace subscription fragment only selected description — the normalized cache entity never received labels, so the component header's labels stayed stale until reload. Select labels alongside description so both header fields live-update. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Code review by qodo was updated up to the latest commit 4cbe2fe |
…UI shell Preview pooling and rendering: - release the hydration slot when a card unmounts between being granted a slot and hydrating, and free its auto-warm reservation on unmount - both leaked the concurrency budget until nothing could hydrate - cancel a pooled frame's readiness-fallback timer on reassignment so a timer armed for one card can never reveal another card's content - dispose pooled frames, host DOM, and global listeners when the last card unregisters instead of keeping live realms across route changes - reset a card's rendered flag when it re-registers, give the preview overlay a positioned parent, and place the retry nonce in the document query - inside the hash it was a fragment navigation that could never re-fetch a document that failed to load - render once per hash change (a second listener doubled every render) and guard async renders with a sequence so an older hash can't win the race Workspace UI data flow: - key the deferred-query merge by full component id - a versionless key could attach one version's status to another version's component - deferred snapshots now only fill fields the light model lacks, so an older one-shot result can't revert live subscription updates, and the server object merges field-wise instead of dropping host/basePath - schedule background refetch on first-load network failures too - accept compilation-change subscriptions filtered by an affected env, not only the owning server env - guard partial lane results that arrive without getHost - recompute version menus on content changes, not just length changes UI shell and misc: - scope service-worker cleanup to bit's own registrations and workbox caches (localhost origins are shared across unrelated dev servers), and drop an unused dev service-worker module - evict getCurrentUser from the restored Apollo cache so auth state never survives a session boundary - route synchronous start-plugin throws into the per-plugin error handler - discard stale connection health pings via a generation counter and confirm a failed check quickly instead of waiting for the next poll - format the measurement scripts with prettier and drop eslint artifacts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Code review by qodo was updated up to the latest commit 958c7f8 |
Cards unregister momentarily while remounting (their effect re-runs when the server url resolves), so tearing the pool down the instant the registry empties would destroy and re-boot every realm during that churn. The teardown now waits out the churn window and is cancelled by any new registration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Warmed previews run without taking a slot, but the hydration callback marked every run as a slot holder - releasing one later decremented a slot owned by another active hydration, letting the queue exceed its concurrency budget. The callback now receives whether it acquired a slot and only holds or releases in that case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Code review by qodo was updated up to the latest commit 87d05ce |
- Chunk the deferred workspace status query. Status resolution is synchronous server work (~13s for 212 components in one operation); a single unpaginated query held the server event loop for the whole duration and froze graphql, previews, and subscriptions. Chunks of 24 fetch sequentially through getHost.getMany with yields between them, status pills fill in progressively, a chunk whose resolver rejects is skipped rather than discarding every resolved status, and readiness settles either way. - Resolve the active component id per call in generated links instead of capturing it at realm boot: a pooled iframe re-pointed between same-fullName components from different scopes kept selecting the first component's modules. - Defer a pooled card's first registration until its dev server finished compiling - a frame created against a compiling server loaded the offline document and stayed on it. Rendered cards keep their live frame through later compiles; hot updates handle those. - Sweep workbox caches only when this app's own service-worker registration was present on the origin: workbox cache names are not unique per application, so name filtering alone could delete another Workbox app's offline assets. - Use the shared CLI output formatter's symbols and item styling for the preview bootstrap and UI rebuild messages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| const blockedPct = result.elapsedMs ? (result.blockedMs / result.elapsedMs) * 100 : 0; | ||
|
|
||
| console.log(`\nurl: ${result.url}\n`); | ||
| console.log(' what stays alive as you scroll:'); |
There was a problem hiding this comment.
1. Jank report bypasses formatter 📘 Rule violation ⚙ Maintainability
The new measurement CLI prints raw section headings and FAIL/PASS summaries instead of using the shared @teambit/cli formatting toolkit. Its report therefore does not follow the repository’s required CLI section and summary conventions.
Agent Prompt
## Issue description
The preview-jank report emits raw headings and status summaries instead of using the shared `@teambit/cli` formatting utilities.
## Issue Context
Use `formatTitle` for non-standard report sections, formatter symbols/items for lists, and formatted success/error summaries rather than raw `PASS`/`FAIL` output.
## Fix Focus Areas
- scripts/measure-preview-jank.js[255-284]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| if (failures.length) { | ||
| console.error(`FAIL:\n - ${failures.join('\n - ')}\n`); |
There was a problem hiding this comment.
2. Boot report bypasses formatter 📘 Rule violation ⚙ Maintainability
The new UI-boot CLI emits raw report sections, hyphen-prefixed failures, and FAIL/PASS summaries without the shared @teambit/cli formatter. This makes its terminal output inconsistent with the mandated title, item, error, and success formatting.
Agent Prompt
## Issue description
The UI-boot report manually constructs error items and status summaries rather than using the repository’s shared CLI output formatter.
## Issue Context
Render headings with `formatTitle`, failures with formatter-provided item/error symbols, and successful completion with `formatSuccessSummary`.
## Fix Focus Areas
- scripts/measure-ui-boot.js[226-247]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| pooled.ready = false; | ||
| booting += 1; | ||
| if (pooled.revealTimer) window.clearTimeout(pooled.revealTimer); | ||
| pooled.revealTimer = window.setTimeout(() => markReady(pooled), REVEAL_AFTER_LOAD_MS * 4); |
There was a problem hiding this comment.
3. Reassignments leak boot slots 🐞 Bug ≡ Correctness
Each assign() increments the global booting count, but reassignment cancels the prior readiness timer and only the latest assignment can decrement it once. Rapid scrolling can therefore leave booting permanently above maxBooting(), preventing the pool from creating frames for visible cards.
Agent Prompt
## Issue description
Repeatedly reassigning a not-ready pooled frame increments `booting` for abandoned assignments that can no longer complete, eventually blocking new frame creation.
## Issue Context
A frame should contribute at most one outstanding boot/readiness unit regardless of how many times it is reassigned before becoming ready.
## Fix Focus Areas
- scopes/preview/ui/preview-placeholder/preview-canvas.ts[227-230]
- scopes/preview/ui/preview-placeholder/preview-canvas.ts[272-280]
- scopes/preview/ui/preview-placeholder/preview-canvas.ts[365-374]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| function assign(pooled: PooledFrame, entry: CanvasEntry) { | ||
| if (pooled.assignedKey === entry.key) return; | ||
| pooled.assignedKey = entry.key; | ||
| // it is showing the previous card's component until the new one renders | ||
| pooled.wrapper.style.visibility = 'hidden'; |
There was a problem hiding this comment.
4. Recycled cards lose skeletons 🐞 Bug ≡ Correctness
Reassigning a frame leaves its former card marked in renderedKeys and leaves that card's canvasRendered state true. When the card returns to the pool it suppresses its skeleton while the newly assigned iframe wrapper is hidden, producing a blank preview area until rendering completes.
Agent Prompt
## Issue description
When a pooled frame is recycled, notify or otherwise invalidate the previous card's rendered state so that card shows its skeleton until another frame renders it.
## Issue Context
`renderedKeys` and the React `canvasRendered` state currently only transition to ready; frame reassignment has no inverse transition.
## Fix Focus Areas
- scopes/preview/ui/preview-placeholder/preview-canvas.ts[227-240]
- scopes/preview/ui/preview-placeholder/preview-canvas.ts[272-280]
- scopes/preview/ui/preview-placeholder/preview-placeholder.tsx[305-326]
- scopes/preview/ui/preview-placeholder/preview-placeholder.tsx[381-397]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| onLoad={(event) => { | ||
| onLoad && onLoad(event); | ||
| }} |
There was a problem hiding this comment.
5. Skeleton releases hydration slot 🐞 Bug ➹ Performance
ComponentPreview invokes the consumer's onLoad for the temporary srcDoc skeleton before the requestAnimationFrame installs the real preview URL. In the non-pooled grid path this calls releaseSlotIfHeld(), admitting more iframe boots before the current preview has loaded and defeating the hydration concurrency limit.
Agent Prompt
## Issue description
Do not invoke the public preview-ready `onLoad` callback when the temporary `srcDoc` skeleton loads; release hydration capacity only for the actual preview document/runtime readiness signal.
## Issue Context
`scheduledSrc` begins undefined and is assigned in a later animation frame, so the iframe loads the skeleton first and emits a native load event.
## Fix Focus Areas
- scopes/preview/ui/component-preview/preview.tsx[308-322]
- scopes/preview/ui/component-preview/preview.tsx[393-412]
- scopes/preview/ui/preview-placeholder/preview-placeholder.tsx[208-219]
- scopes/preview/ui/preview-placeholder/preview-placeholder.tsx[401-413]
ⓘ 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 027c348 |
The 2.2.12 release bump (d41835f) raised 24 @teambit/api-reference.* pins to versions that were never published: the scope's export/publish step failed during the release while the version-bump commit landed. Every fresh install - CI's bbit install included - now fails to resolve them, on both node-registry.bit.cloud and npmjs (verified per package; each registry tops out one version below the new pin). This restores the 24 pins to the previous, published versions. All restored pins were validated to resolve against the registry. When the api-reference publish is re-run, the next release bump can raise them again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| hostname: string | undefined; | ||
| private _server?: Server; | ||
| private _isRestarting: boolean = false; | ||
| isCompiling: boolean = true; |
There was a problem hiding this comment.
1. Previews compile forever 🐞 Bug ≡ Correctness
ComponentServer.isCompiling is initialized to true and never updated anywhere, so every workspace server query reports it as compiling permanently. The workspace consequently excludes every preview URL from its ready set and keeps cards behind loading placeholders indefinitely.
Agent Prompt
## Issue description
`ComponentServer.isCompiling` remains `true` for the server's entire lifetime, causing workspace previews to remain in their compiling/loading state.
## Issue Context
Update this value from the dev server or bundler's real initial-build and rebuild lifecycle rather than using a permanent initializer. Ensure the value becomes false after successful compilation and reflects subsequent rebuilds and failures.
## Fix Focus Areas
- scopes/compilation/bundler/component-server.ts[40-40]
- scopes/compilation/bundler/dev-server.graphql.ts[65-71]
- scopes/workspace/workspace/ui/workspace/use-workspace.ts[910-915]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| componentServerCompilationChanged: { | ||
| subscribe: () => graphql.pubsub.asyncIterator([ComponentServerCompilationChangedEvent]), |
There was a problem hiding this comment.
2. Compilation updates never publish 🐞 Bug ≡ Correctness
The new componentServerCompilationChanged subscription listens for ComponentServerCompilationChangedEvent, but the PR only declares that string and no code publishes it. Clients therefore receive no initial-build or hot-rebuild state transitions, leaving the new subscription path inert.
Agent Prompt
## Issue description
The compilation-status GraphQL subscription listens on an event that is never emitted, so workspace clients cannot observe compilation transitions.
## Issue Context
Hook the component server's compiler/dev-server lifecycle into Pubsub and publish payloads matching the resolver's `componentServerCompilation` shape for initial compilation and every rebuild, including affected deduplicated environments and diagnostics.
## Fix Focus Areas
- scopes/compilation/bundler/dev-server.graphql.ts[97-115]
- scopes/compilation/bundler/events/components-server-started-event.ts[8-9]
- scopes/compilation/bundler/component-server.ts[62-72]
ⓘ 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 3dc4986 |
Summary
This PR makes the local workspace experience fast end to end: the UI paints immediately, the grid renders and scrolls its previews without jank, preview assets are cached, and source edits hot-swap into running previews. It changes how the workspace query loads, how preview dev servers isolate broken environments, how the grid mounts preview iframes, how preview assets are cached and rebuilt, and how hot updates reach preview realms.
All measurements are from a 194-component workspace on rspack-based envs, in headless Chrome with fresh profiles, median of 3 runs. The scripts in
scripts/reproduce every number.Workspace loading
Problem. The UI stays blank until the full workspace query returns, and the query is dominated by two server-side resolvers.
Cause. The
workspacequery resolvesstatusandissuesCountfor every component. Both are synchronous CPU work. On the test workspace, the query takes 14.6 s and blocks the server event loop; the same query without those fields takes 29 ms. The served HTML contains only<div id="root"></div>, so nothing paints until the bundle downloads, parses, and mounts. The overview also treatsloading === falseas "the workspace has no components", which is also true for a failed query undererrorPolicy: 'all'.Change.
statusandissuesCountand merge into the model as they resolve. Status pills and issue counts fill in progressively.resolvedflag that is set only when the light query returns a workspace.Result. First paint at 68–121 ms, grid populated at 500–713 ms, and the empty state no longer appears during load.
Preview dev servers: fault isolation
Problem. One environment with a broken dependency removes previews for every component in the workspace.
Cause.
dedupEnvsgroups environments under shared dev servers, and building a group pre-bundles a preview runtime that contains every member's preview aspect. One unresolvable import fails the whole bundle. The error output is the bundler's full stats — hundreds of lines around the one relevant error.Change. Dev-server groups build independently, and a group that fails is rebuilt without the environments the bundler's
ERROR in <path>lines identify. Each failure is reported as one line naming the environment and the cause.Result. On the test workspace, components with a preview server went from 0 of 208 to 95. The two environments with the broken dependency are excluded and named; the other seven serve normally.
Preview rendering in the workspace grid
Problem. Scrolling a grid with live previews blocks the main thread for most of the gesture.
Cause. Each preview iframe boots the environment's full preview runtime: ~0.22 s of main-thread time and 76–300 MB of heap per iframe, independent of what the card shows. The grid mounts one iframe per card with no upper bound, so a scroll accumulates realms: 79 iframes, 1209 MB of heap, and a main thread blocked for 68% of the gesture. The same grid with previews disabled measures 0%.
Change. A bounded pool of preview iframes serves the whole grid. Each iframe boots once; scrolling re-points it at another component by changing its URL fragment, which re-renders inside the running realm without reloading the document. Details:
Design constraint. Rendering multiple previews into one shared realm would remove the repeated bootstrap entirely, but a shared realm cannot isolate components: anything that portals into
document.body(drawers, modals, scrims) escapes its card and paints over the grid, andwindow.locationcan only address one preview. The pool keeps one document and onelocationper preview, so portals, fixed positioning, and live controls behave exactly as in the per-card path. The commit history documents the shared-realm measurement for future reference.Result. Same build, same 60-tick wheel gesture:
Values are flat from the fourth scroll step on. Measured from a cold page, the pool's one-time boot adds ~235 ms of blocking (2% of the window) before the first gesture. The first preview renders at 3.0 s, with a skeleton visible from 1.2 s.
Thumbnail realms
A pooled grid realm booted the env's full preview runtime: ~260 ms of main thread each (438 ms cold), identical whether the card showed a real component or a no-UI behaviour. Profiling attributed ~215 ms to evaluating the overview docs template, because every preview type's link file statically imported its template module — a realm that only ever renders compositions still executed the docs app at startup.
When the pool marks a hash with
thumbnail=true, link files defer all module evaluation until a hashchange actually asks for their preview. The deferral is parameterized by each link's own preview name and never references a specific template package, so env-configured templates and mounters (docsTemplate, custom wrappers) behave identically; the active preview's link — the env's mounter and providers — initializes normally. Without the marker nothing defers: an unmarked realm on the same build still evaluates the template (~265 ms), and single-component pages render full docs.Measured on the same workspace, pooled path: warm realm boot 255–259 ms → 28–31 ms; first visible preview 2.0 s → 1.5 s; grid cumulative script 4.5 s → 0.9 s; heap 458 MB → 259 MB. With realms this cheap the pool's creation gates became the bottleneck, so they are adaptive: two realms boot until the first preview renders (proof the env assets are cached), then twelve boot with six new frames per flush. All 20 pool frames visible: 10.9 s → 3.7 s → 2.8 s (fill after the first frame ≈ 1 s). Widening beyond twelve was measured and bought nothing — the constraint moves to iframe document setup. An error thrown inside a thumbnail realm still reaches the host as
ERROR_EVENT(verified by injection). Network for the full grid with 20 live previews: 375 requests / 0.9 MB.Preview asset caching
Problem. The browser downloads the same environment bundles once per card.
Cause. Preview dev servers send
Cache-Control: no-cachewithout an ETag or Last-Modified, so the browser has no validator and re-downloads every asset in full. Every card on an environment requests the same bundles: 162.3 MB of transfer for nine previews, 138 MB of it redundant.Change. The dev server sends a per-build ETag. Semantics are unchanged — every request still revalidates, and a rebuild still invalidates — but repeat requests return 304 with no body. Verified against staleness: after an edit and rebuild, the pre-edit ETag returns 200 with the new content.
Result. 25.8 MB cold, 1.5 MB warm, for the same nine previews.
Preview hot reload
Context. On
main, dev previews consume component sources, where the bundler watches the files and react-refresh applies updates — hot reload works there. This PR consumes components as compiled packages instead, which the caching and pooling gains above depend on. In that model, editing a source initially never updated a running preview: the compiler rebuilt, but the page showed stale output until the user reloaded it. This section makes the compiled-package model hot end to end, and adds a hot boundary thatmaindoes not have — onmain, modules that are not react-refresh boundaries (docs.mdx, compositions with mixed exports) always full-reload.Cause. Five independent breaks in the compiled-package model, each sufficient on its own:
node_modulesout of the watch, and this architecture consumes components as compiled packages insidenode_modules.node_modulescontent as immutable (managedPaths).main), so any module that is not a react-refresh boundary (a composition exporting a live-controls config, any docs.mdx) bubbled to the entry and forced a full reload. Hot acceptance must also be the literalimport.meta.webpackHot.accept()member expression: an aliased call defeats the bundler's static analysis and compiles the acceptance away.Change.
file+variant paths that hold the workspace's component packages (registry dependencies and generated link files stay unwatched), clearssnapshot.managedPaths, and coalesces the compiler's multi-file write burst into one rebuild (aggregateTimeout). Rebuilds are now native file-watch driven; no orchestration plumbing./_hmr/<envId>route on the page's own origin. The route always targets the live server, so server restarts are transparent. Cloud keeps its dedicated websocket endpoint.import.meta.webpackHot.accept()): a bubbling child update re-executes the link, which re-links the fresh modules and notifies the preview UI to re-render. React-refresh boundaries still self-accept first, preserving state. A realm whose URL names no preview (overview pages show the default) also dispatches the re-render event./subscriptionssocket.componentChangedsubscription carries the fresh description and labels, and the workspace header updates in ~2 s without a reload.Result. On the test workspace: a composition edit hot-swaps in 4–5 s and a docs edit in 1–3 s, with zero iframe reloads, verified by CDP probes that count iframe navigations and scan rendered text. State is preserved for component-only modules (react-refresh); mixed-export modules and docs re-render through the link boundary.
Classic (webpack) environments. Verified separately with a purpose-built CommonJS component on
teambit.react/react: the generated link builds and runs, hot-update chunks contain the CJS module, and edits render in 1–4 s. Recovery there is reload-based — the classic env's own client config and refresh wiring, unchanged by this PR — which is exact parity withmain, where links registered no acceptance at all.UI bundle and cache freshness
This PR adds two browser-side caches that did not exist before: a dev service worker that keeps the UI shell available when the dev server is down, and a persisted Apollo cache that lets a reload paint from local data. Both make the UI faster and both create new ways to show stale content, so freshness is enforced at every layer:
Server bundle. The pre-existing rebuild check hashes which aspects are in the bundle, not their contents, so a rebuilt aspect could be served from the previous bundle. Before reuse,
bit startnow compares the bundle's build time against the compiled output of the UI-runtime aspects and rebuilds if any is newer, naming the file that triggered the rebuild. The check costs ~90 ms and runs only when a local build exists; consumer workspaces serving the published pre-bundle never reach it.Service worker (new). The worker is network-first for every request; cached content serves only when the dev server is unreachable. Its caches are scoped per workspace and named by a per-build token, and its activate handler deletes every cache from a different build, so the offline fallback is never older than the current bundle. A stale production (Workbox) worker from a previous version is detected, unregistered, and its caches cleared. Cleanup is scoped to bit-owned artifacts — registrations at
/service-worker.jsandworkbox-*caches — because localhost origins are shared across unrelated dev servers over time.Apollo cache (new). The persisted snapshot restores component metadata for instant rendering. Runtime-volatile fields (preview server URLs, compilation state) and authentication state (
getCurrentUser) are removed on restore, so connection state, compilation state, and the signed-in user always come from the live server.Measurement scripts
Two scripts drive headless Chrome over CDP and exit non-zero on budget violations, so they work as regression guards:
scripts/measure-ui-boot.js— first paint, boot-shell, and app-mount timings. Fails if the empty state is ever painted.scripts/measure-preview-jank.js— long tasks, total blocking time, dropped frames, and stutters during a continuous scroll, plus per-step counts of live iframes, sockets, heap, and requests.scripts/verify-preview-health.js— console errors (infrastructure vs. content warnings), failed same-origin requests, stuck-pending requests, and rendered frame count for a given page. Used as the pass/fail gate for every preview change in this PR.Both assert on rendered output rather than DOM state, because a blank page and a fast page are indistinguishable at the DOM level.
Flags and defaults
?batchedPreviews=false?previewPoolSize=<n>?maxLivePreviews=<n>Companion changes
All changes outside this repository are on one lane:
teambit.rspack/preview-asset-caching. They are required for the preview measurements above.teambit.rspack/dev-services/preview/react-previewfile+paths un-ignored,managedPathscleared, write-burst aggregation).allowedHostsfor local preview servers so the HMR upgrade is accepted behind the workspace proxy. Lazy compilation disabled for preview servers.teambit.rspack/rspack-dev-server/_hmr/<envId>route on the page origin (restart-proof); react-refresh is included whenever the build is not a production build, so a plainbit startgets a working refresh runtime.teambit.react/mounterteambit.react/ui/mounter/use-default-controlsgetCompositionHrefno longer throws when a preview renders without a URL hash.teambit.rspack/envs/*,teambit.harmony/envs/*,app-types/react-rspack,harmony/browser-runtimeVerification
Every behavioral claim in this PR is backed by a scripted, repeatable check against a running workspace; nothing was verified by inspection alone.
teambit.react/reactwebpack env with a purpose-built CommonJS component, andbase-react-env(webpack, ESM dists).scripts/verify-preview-health.jspasses on a fresh browser profile and across a mid-session recompile: zero chunk errors, zero stuck requests, zero failed same-origin requests, all frames rendered.measure-ui-bootandmeasure-preview-jankexit non-zero on budget violations and assert on rendered output, because a blank page and a fast page are indistinguishable at the DOM level.Additional changes
components/ui/version-dropdown: the version menu mounts on first user intent (hover, focus, press, open)components/ui/version-dropdown_1/deletedcomponents/ui/version-dropdown/; absent from.bitmap, imported by nothing. Can be split into its own PR on request.apollo3-cache-persistadded to the workspace policycontext: { batch: true })