Skip to content

feat(workspace): instant workspace load + pooled previews - #10191

Open
luvkapur wants to merge 56 commits into
masterfrom
perf/runtime-optimizations
Open

feat(workspace): instant workspace load + pooled previews#10191
luvkapur wants to merge 56 commits into
masterfrom
perf/runtime-optimizations

Conversation

@luvkapur

@luvkapur luvkapur commented Feb 6, 2026

Copy link
Copy Markdown
Member

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.

Metric Before After
First paint 516 ms (blank page) 68–121 ms (app shell)
Grid populated 776 ms 500–713 ms
Main thread blocked during a grid scroll 68% 0%
Live preview iframes after scrolling 79, unbounded 20, bounded
JS heap after scrolling 1209 MB 259 MB
First preview rendered (fresh profile) 30 s+ 1.5 s
All 20 grid previews rendered (fresh profile) 2.8 s
Asset transfer, grid + 20 previews 162 MB for 9 previews 18.7 MB cold, 0.9 MB warm
Env preview compile on a warm boot 18–31 s 1.1–1.5 s
Source edit → updated preview (compiled-package model) full reload at best 2–5 s, hot, no reload

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 workspace query resolves status and issuesCount for 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 treats loading === false as "the workspace has no components", which is also true for a failed query under errorPolicy: 'all'.

Change.

  • Split the workspace query. A light query paints the grid; deferred queries load status and issuesCount and merge into the model as they resolve. Status pills and issue counts fill in progressively.
  • Serve a static app shell (top bar, sidebar, filter bar, card grid) with inline critical CSS in the HTML. The shell cross-fades out on React's first commit and removes itself after 15 s if the app fails to mount.
  • Gate the empty state on a resolved flag 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. dedupEnvs groups 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:

  • Placement is decoupled from creation. Frames are positioned ~1.5 viewports ahead of the scroll direction so a preview is in place before its card arrives (worst observed misalignment: 1 px). Creation is limited to 3 booting realms and 1 new frame per animation frame, so the first screenful renders first.
  • The frame layer lives inside the grid's scroll container, in content coordinates. Native scrolling moves previews and cards together with no per-frame JS.
  • Each frame is clipped by a card-sized wrapper, and stays hidden until its preview has rendered, so cards show their skeleton rather than a blank document.
  • The per-card path remains the default and gets an upper bound on live realms.

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, and window.location can only address one preview. The pool keeps one document and one location per 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:

Metric Per-card Pooled No previews (control)
Gesture duration 30.6 s 7.3 s 5.6 s
Long tasks (>50 ms) 79 0 0
Main thread blocked 21.0 s (68%) 0 ms (0%) 0 ms (0%)
Dropped frames 15% 0 of 863 0
Visible stutters (>100 ms) 78 0 0
Live iframes 79 20 0
JS heap 1209 MB 451 MB 143 MB

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-cache without 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 that main does not have — on main, 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:

  1. The bundler never rebuilds. Bundler watchers scope node_modules out of the watch, and this architecture consumes components as compiled packages inside node_modules.
  2. When a rebuild does run, it does not re-read the changed files: bundler snapshots treat node_modules content as immutable (managedPaths).
  3. The HMR client connects to the dev server's port directly. Preview dev servers restart on a new port mid-session, while realms keep the port that was baked into their bundle at build time — those realms silently stop receiving updates.
  4. The generated link files registered no hot acceptance (as on 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 literal import.meta.webpackHot.accept() member expression: an aliased call defeats the bundler's static analysis and compiles the acceptance away.
  5. The workspace proxy answered 503 for hot-update requests while an environment was marked compiling. A second compile pass follows every edit, so the client's manifest fetch raced it and updates were silently dropped about half the time.

Change.

  • The env dev service un-ignores the pnpm file+ variant paths that hold the workspace's component packages (registry dependencies and generated link files stay unwatched), clears snapshot.managedPaths, and coalesces the compiler's multi-file write burst into one rebuild (aggregateTimeout). Rebuilds are now native file-watch driven; no orchestration plumbing.
  • The HMR client connects through the workspace proxy's stable /_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.
  • Generated link files self-accept (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.
  • The workspace proxy passes hot-update requests through while an environment compiles; the dev server's own middleware holds them until the build settles. Upgrade handlers act only on routes they own, so one environment's handler can no longer close another environment's HMR socket — or the graphql /subscriptions socket.
  • Frontmatter changes in docs files flow through the component model: the componentChanged subscription 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 with main, 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 start now 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.js and workbox-* 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.
bit start --port 3007
node scripts/measure-ui-boot.js --url=http://localhost:3007/
node scripts/measure-preview-jank.js --url=http://localhost:3007/

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

Flag Default Effect
?batchedPreviews=false pooled previews are on by default Opts out to the per-card path
?previewPoolSize=<n> hardware concurrency × 2, clamped 8–20 Pool size
?maxLivePreviews=<n> bounded default Live-realm cap on the per-card path

Companion changes

All changes outside this repository are on one lane: teambit.rspack/preview-asset-caching. They are required for the preview measurements above.

Component Change
teambit.rspack/dev-services/preview/react-preview Per-build ETag for preview assets. Persistent rspack build cache per environment, keyed by dev-service version, rspack version, config identity, and entry-content hash (warm env compile 18–31 s → 1.1–1.5 s; staleness verified across offline edits, live rebuilds, and config changes). Watch configuration for component packages (file+ paths un-ignored, managedPaths cleared, write-burst aggregation). allowedHosts for 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 client rides the proxy's /_hmr/<envId> route on the page origin (restart-proof); react-refresh is included whenever the build is not a production build, so a plain bit start gets a working refresh runtime.
teambit.react/mounter Keeps one React root per container when the rendering context supplies one, instead of unmounting a single root on every mount.
teambit.react/ui/mounter/use-default-controls getCompositionHref no longer throws when a preview renders without a URL hash.
teambit.rspack/envs/*, teambit.harmony/envs/*, app-types/react-rspack, harmony/browser-runtime Dependency updates to pick up the changed dev service and dev server.

Verification

Every behavioral claim in this PR is backed by a scripted, repeatable check against a running workspace; nothing was verified by inspection alone.

  • Surfaces. Compositions pages, component overview pages (docs), the workspace grid (pooled and per-card paths), minimal-mode embedding, and single-preview deep links.
  • Environments. rspack-based envs (primary scope), the classic teambit.react/react webpack env with a purpose-built CommonJS component, and base-react-env (webpack, ESM dists).
  • Hot reload. CDP probes edit real sources, then assert on rendered text, count iframe navigations (zero allowed for hot paths), and capture the HMR client's own log. Verified per surface and re-verified after every fix batch.
  • Staleness. Persistent-cache correctness proven three ways: offline edit → boot serves fresh output; live rebuild rotates the ETag; config-identity change drops the cache.
  • Health gate. scripts/verify-preview-health.js passes 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.
  • Regression guards. measure-ui-boot and measure-preview-jank exit non-zero on budget violations and assert on rendered output, because a blank page and a fast page are indistinguishable at the DOM level.
  • Review. All 54 automated review findings were triaged: genuine defects fixed (hot-path slot accounting, pooled-frame lifecycle, stale timers, retry semantics, versioned data merges, subscription filters, scoped service-worker cleanup, auth-state eviction), and each dismissal is documented on its thread with the measurement that justifies it.

Additional changes

Change Reason
components/ui/version-dropdown: the version menu mounts on first user intent (hover, focus, press, open) With lazily fetched logs, an unknown version count must render a clickable dropdown; mounting the menu eagerly put every card's version rows in the DOM. Restores the behavior the component's lazy-loading spec asserts.
components/ui/version-dropdown_1/ deleted Unreferenced duplicate of components/ui/version-dropdown/; absent from .bitmap, imported by nothing. Can be split into its own PR on request.
apollo3-cache-persist added to the workspace policy Persists the Apollo cache to localStorage so a reload paints from cache. Runtime-volatile fields (preview URLs, compilation state) are removed from the restored snapshot.
Preview link file registers non-active components as lazy loaders Required for iframe recycling. The active component's code path is byte-identical to before.
GraphQL request batching remains opt-in per operation (context: { batch: true }) Unchanged from master.
luvkapur and others added 10 commits February 5, 2026 15:33
… 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>
@teambit teambit deleted a comment from sonarqubecloud Bot Feb 17, 2026
luvkapur and others added 19 commits February 17, 2026 17:34
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>
… 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>
Comment thread scopes/ui-foundation/ui/ui.main.runtime.ts Outdated
Comment thread scopes/workspace/watcher/watcher.ts Outdated
Comment thread scopes/workspace/workspace/ui/workspace/use-workspace.ts Outdated
Comment thread components/hooks/use-lane-components/use-lane-components.tsx
Comment thread scopes/preview/preview/preview.preview.runtime.tsx
Comment thread scopes/compilation/bundler/dev-server.graphql.ts Outdated
Comment thread scopes/cloud/ui/user-bar/use-dev-server-connection-status.ts Outdated
Comment thread scopes/preview/preview/generate-link.ts
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

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>
Comment thread scripts/measure-preview-jank.js
Comment thread scripts/measure-ui-boot.js
Comment thread scopes/compilation/bundler/component-server.ts
Comment thread scopes/cloud/ui/user-bar/use-dev-server-connection-status.ts Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

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>
Comment thread scopes/harmony/graphql/graphql.ui.runtime.tsx
Comment thread scopes/preview/preview/preview.preview.runtime.tsx
Comment thread scopes/preview/ui/preview-placeholder/preview-placeholder.tsx
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

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>
Comment thread scripts/verify-preview-health.js Outdated
Comment thread scopes/ui-foundation/ui/rspack/html.ts
Comment thread scopes/preview/ui/component-preview/preview.tsx Outdated
Comment thread scopes/preview/ui/preview-placeholder/preview-canvas.ts Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

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>
Comment thread scripts/verify-preview-health.js
Comment thread scopes/preview/ui/preview-placeholder/preview-placeholder.tsx Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

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>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code Review by Qodo

Grey Divider

Sorry, something went wrong

We weren't able to complete the code review on our side. Please try again manually by commenting /agentic_review on this PR.

Grey Divider

Qodo Logo

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>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code Review by Qodo

Grey Divider

Sorry, something went wrong

We weren't able to complete the code review on our side. Please try again manually by commenting /agentic_review on this PR.

Grey Divider

Qodo Logo

@luvkapur luvkapur changed the title feat(workspace): instantly load the workspace Aug 26, 2026
Comment thread scopes/preview/preview/preview.start-plugin.tsx Outdated
Comment thread scopes/compilation/bundler/dev-server.service.ts
Comment thread scopes/workspace/workspace/ui/workspace/use-workspace.ts Outdated
Comment thread scopes/workspace/workspace/ui/workspace/use-workspace.ts Outdated
Comment thread scopes/preview/ui/preview-placeholder/preview-placeholder.tsx Outdated
Comment thread scopes/preview/ui/preview-placeholder/preview-canvas.ts
Comment thread scopes/ui-foundation/ui/rspack/html.ts Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 87d05ce

@luvkapur luvkapur changed the title feat(workspace): instant workspace load, pooled previews, and end-to-end preview hot reload Aug 26, 2026
- 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:');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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

Comment on lines +243 to +244
if (failures.length) {
console.error(`FAIL:\n - ${failures.join('\n - ')}\n`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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

Comment on lines +277 to +280
pooled.ready = false;
booting += 1;
if (pooled.revealTimer) window.clearTimeout(pooled.revealTimer);
pooled.revealTimer = window.setTimeout(() => markReady(pooled), REVEAL_AFTER_LOAD_MS * 4);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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

Comment on lines +272 to +276
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';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

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

Comment on lines +398 to +400
onLoad={(event) => {
onLoad && onLoad(event);
}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

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

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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

Comment on lines +97 to +98
componentServerCompilationChanged: {
subscribe: () => graphql.pubsub.asyncIterator([ComponentServerCompilationChangedEvent]),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 3dc4986

@luvkapur luvkapur added the v3 prs to merge for bit v3 label Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v3 prs to merge for bit v3

1 participant