Skip to content

refactor(component-loading): stage 2 — host consolidation + Lever 1 perf#10369

Draft
davidfirst wants to merge 55 commits into
masterfrom
refactor/component-loading-v2-take-3-stage2
Draft

refactor(component-loading): stage 2 — host consolidation + Lever 1 perf#10369
davidfirst wants to merge 55 commits into
masterfrom
refactor/component-loading-v2-take-3-stage2

Conversation

@davidfirst
Copy link
Copy Markdown
Member

Stage 2 of the component-loading rewrite. Builds on top of stage 1 (PR #10359, still open).

This branch took the unified loader from a thin adapter wrapping the legacy WorkspaceComponentLoader into a real two-pass implementation, then routed Workspace.get / Workspace.getMany through it, and added in-flight dedup to close the perf gap.

What's new beyond stage 1

Two-pass loader host (workspace-loader-host.ts, 470 lines)

  • Pass 1: build all Components with extensions config, NO slots fired (one batched consumer.loadComponents call)
  • Pass 2: SCC-order env/aspect candidates by env-of-env relation (iterative Tarjan's), fire slots and register aspects in order; supports cycles between aspects
  • Pass 3: parallel slot fire for the remaining components

Replaces the per-group state machine in WorkspaceComponentLoader.getAndLoadSlotOrdered (1029 lines), though that file is still alive — see "what's not done."

Workspace.get / Workspace.getMany route through unified loader. Three coordinated fixes made this safe:

  • loadingDepth counter on the host; recursive loadMany calls return config-only components without firing slots
  • originatedFromHarmony: true on pass 1's consumer.loadComponents to suppress the global onComponentLoad subscriber from firing redundantly
  • UnifiedComponentLoader.publish(id, phase, component) API (currently unused, kept for future use)

Lever 1 — in-flight dedup in unified.getMany. Per-(id, phase) in-flight tracking; recursive getMany calls await the existing promise instead of triggering another host call. Closes a measured perf regression where core-aspect-env was being loaded 21 times during a single bit status.

Task 8.9 — implicit auto-import removed. 12 sites migrated: 6 to scope.getOrImport, 6 to plain scope.get with importIfMissing=false. A new Scope.getOrImport API makes the auto-import intent explicit at the call site.

Bench

warm bit status (bit6 self-workspace, 311 components):
  legacy:  ~10-11s
  new:     ~10-11s   (parity; variance dominates)

cold bit status:
  not re-benched on this branch — Lever 1's win is largely on the warm path
  (it dedups in-flight loads; cold loads happen anyway)

bit status produces byte-identical output under both BIT_LOADER=old and BIT_LOADER=new.

What's NOT done in this PR

  • Delete workspace-component-loader.ts (task 8.16.6). Still used by:
    • Workspace.get's legacyComponent shortcut (the onComponentLoad-subscriber bridge)
    • Workspace.listInvalid
    • Various other callers
      Each needs to be migrated or removed before WCL can go.
  • Full e2e validation under the new path (task 8.16.5). bit list ran locally: 5/6 pass. The one failure is unrelated to this PR — it's fallout from task 8.9's auto-import removal.
  • Docs update (task 8.16.8).

The structural and perf wins are landed; the remaining work is cleanup and verification.

Design references

  • openspec/changes/rewrite-component-loading/spike/01-consolidated-host-sketch.md — why the consolidation works
  • openspec/changes/rewrite-component-loading/design-stage2-perf.md — caching-first perf strategy (retracts the original "skip phases" framing)
  • openspec/changes/rewrite-component-loading/audit/06-cache-invalidation.md — invalidation surface inventory

Test plan

  • Full e2e suite under BIT_LOADER=new (default in this branch) passes on CI
  • bit status produces correct output on a non-trivial workspace
  • bit tag and bit snap flows work end-to-end
  • No memory regressions on cold-cache bit status (~311 component workspace)

davidfirst added 30 commits May 8, 2026 11:42
Adds the openspec change "rewrite-component-loading" with full proposal,
design, capability spec, tasks, and the Group 1 pre-work audit:

- audit/01-call-sites.md       — 88 call sites of workspace.get/getMany/list,
                                 classified by required load shape
- audit/02-consumer-component-mutations.md
                               — 9 post-load mutation sites with per-site
                                 migration strategy
- audit/03-caches.md           — inventory of all 11+ caches and their
                                 mapping into the new unified ComponentCache
- audit/04-auto-import-sites.md
                               — 12 sites relying on implicit scope auto-
                                 import (6 → getOrImport, 6 → local-only)
- audit/05-benchmarks-baseline.md
                               — bit list/status/show baselines on the
                                 311-component bit6 self-workspace
…ypes

Scaffolds the new @teambit/component-loader aspect package and adds the
foundational types that the unified loader will use:

- phase.ts          — Phase string union ('identity' | 'files' |
                      'dependencies' | 'extensions' | 'aspects'),
                      phaseRank, isPhaseAtLeast, DEFAULT_PHASE
- load-events.ts    — LoadEvent discriminated union and typed
                      LoadEventEmitter wrapping Node's EventEmitter
- component-not-found.ts
                    — ComponentNotFound error class with missingIds
                      (thrown when the loader returns nothing locally;
                      callers must use scope.import or getOrImport
                      explicitly for network resolution)

Adds Component.loadedPhase to @teambit/component, aliased locally as
LoadedPhase to avoid a circular dependency between the two packages
(canonical declaration remains in @teambit/component-loader).

Refs openspec/changes/rewrite-component-loading group 2.
…dation

Implements the unified cache that will replace the 11+ ad-hoc caches
catalogued in audit/03-caches.md.

- component-cache.ts   — ComponentCache wraps the existing
                         LRUCacheAdapter (@teambit/harmony.modules.
                         in-memory-cache) for size + LRU semantics.
                         Key shape: `::`. Each entry
                         stores a content hash; get(id, phase, hash)
                         returns the cached value only if the stored
                         hash matches the caller-supplied one and
                         evicts stale entries as a side effect.
                         invalidate() accepts ComponentID, an array,
                         'all', or { phase } and returns the count
                         removed.
- hash-inputs.ts       — pure function getHashInputs(phase, ctx).
                         Composes a deterministic string from the
                         per-phase required inputs (id, bitmap hash,
                         file signature, component config hash,
                         workspace config hash, aspect state hash).
                         A v1 prefix busts every entry if the format
                         ever changes; throws if a required input
                         for the requested phase is missing.
- component-cache.spec.ts (16 tests) and hash-inputs.spec.ts
  (10 tests). All 26 pass. Cover hit, stale-on-file-change,
  stale-on-bitmap-change, invalidate-one (single + array),
  invalidate-all, invalidate-by-phase, LRU eviction (recent get
  preserves an entry), per-phase field requirements, and the
  determinism guarantee that excluded inputs do not affect a hash.

Refs openspec/changes/rewrite-component-loading group 3.
Adds the orchestration layer of the unified loader:

- loader-host.ts                      — LoaderHost interface declaring
                                        the workspace/scope operations the
                                        loader needs (bitmap IDs, hashes,
                                        file signature, loadAtPhase). Lets
                                        @teambit/component-loader avoid a
                                        circular dependency on @teambit/
                                        workspace; the workspace package
                                        will adapt itself to LoaderHost
                                        in stage 1.
- unified-component-loader.ts         — UnifiedComponentLoader class with
                                        get / getMany / list / listIds /
                                        invalidate / ensurePhase. Owns the
                                        ComponentCache and LoadEventEmitter.
                                        getMany is two-pass: cache lookups
                                        first (cached entries emit
                                        load:component cached=true, no
                                        phase events), then parallel host
                                        loads bracketed by one
                                        load:phase:start/end pair. Custom
                                        runWithConcurrency worker pool
                                        (default 16). The host owns the
                                        actual disk-reading and dep
                                        resolution machinery during stage 1
                                        — internalizing those into private
                                        loadIdentity/Files/Dependencies/
                                        Extensions/Aspects methods is
                                        stage 2/3 work tracked in tasks.md.
- unified-component-loader.spec.ts    — 17 tests covering get, getMany
                                        (throwOnMissing both modes),
                                        listIds (no host load calls),
                                        list, invalidate (single + all),
                                        ensurePhase (idempotent + upgrade),
                                        and event ordering (fresh load
                                        emits start/phase:start/component/
                                        phase:end/end; cached load skips
                                        phase events; callId is unique
                                        per call).

Refs openspec/changes/rewrite-component-loading group 4.
Tasks 4.1, 4.7-4.12 done; 4.2-4.6 delegated to LoaderHost during stage 1
(internalization is stage 2/3 work). All 43 tests pass.
Stage-1 dual-mode integration. Default behaviour unchanged; opt in via
BIT_LOADER=new env var to route Workspace.get/getMany/listWithInvalid
through the unified loader.

- workspace-component/workspace-loader-host.ts (new)
  WorkspaceLoaderHost implements LoaderHost against the existing
  workspace state. listBitmapIds reads .bitmap directly. Hash methods
  return version-counter strings bumped from existing OnBitmapChange
  and OnWorkspaceConfigChange slots. loadAtPhase delegates to the
  existing WorkspaceComponentLoader.get and tags the result as phase
  'aspects' (the existing loader always full-hydrates). Catches the
  three known not-found errors (MissingBitMapComponent,
  ComponentNotFoundInPath, legacy ComponentNotFound) and returns
  undefined per the loader contract.

- workspace.ts
  - Adds readonly fields unifiedLoader and loadEvents.
  - Constructs them at the bottom of the existing constructor.
  - Adds private useNewLoader() (process.env.BIT_LOADER === 'new')
    and private translateLoadOpts() (stage-1 conservative: every
    translation maps to phase: 'aspects' so behaviour is preserved
    exactly).
  - Workspace.get dual-routes; both paths converge on the existing
    env-as-aspect side-effect logic.
  - Workspace.getMany dual-routes; throwOnFailure maps to throwOnMissing.
  - Workspace.listWithInvalid dual-routes; missing IDs surface in the
    invalidComponents channel during stage 1 (load errors will get a
    structured channel in stage 2).
  - Workspace.list is unchanged (already routes through getMany).
  - clearAllComponentsCache and clearComponentCache also invalidate the
    unified cache so both modes stay in sync.
  - Adds Workspace.getOrImport(id, loadOpts?) — explicit replacement
    for the implicit auto-import behaviour in ScopeComponentLoader.get
    (12 sites enumerated in audit/04-auto-import-sites.md will migrate
    to either this helper or a plain scope.get during stage 2).

Smoke-tested both loader modes on the bit6 workspace:
  - bit list, bit status, bit show all run under BIT_LOADER=new.
  - npm run lint clean (0 warnings, 0 errors).
  - bit6 compile teambit.workspace/workspace succeeded.

Refs openspec/changes/rewrite-component-loading group 5.
Stage-1 pilot migration of `bit status` plus a CLI progress renderer.

Status command (scopes/component/status/status.main.runtime.ts):
- Switched workspace.listWithInvalid() → workspace.listWithInvalidAtPhase(
  'dependencies'). Status only needs modification status, dep info, and
  issues — extensions and aspects phases are pure overhead. Under the
  legacy loader the phase is ignored and behaviour matches
  listWithInvalid(loadOpts) exactly. Smoke-tested both modes; user-
  visible status output is identical.
- The stage-1 host still full-hydrates internally, so the immediate
  perf win is just cache sharing; stage-2 internalisation of phase-
  native paths is where the actual skipped work materialises.

Workspace (scopes/workspace/workspace/workspace.ts):
- Added Workspace.listWithInvalidAtPhase(phase, loadOpts?) — same shape
  as listWithInvalid but routes through unifiedLoader.getMany with the
  requested phase under BIT_LOADER=new. listWithInvalid now delegates
  with phase=DEFAULT_PHASE so existing callers are unchanged.

Progress renderer (workspace-component/load-progress-renderer.ts, new):
- Subscribes once at workspace construction via attachLoadProgressRenderer.
- Renders progress through Logger.setStatusLine: `loading N/312 (phase)`.
- Only renders on batches ≥ 10 components (single get() calls stay
  silent — no flicker).
- Tracks one in-flight call at a time so inner get() events from
  workers don't clobber the outer batch's progress line.
- Rate-limits intermediate updates to ≥100ms apart (first and last
  always render). Reduces piped-output noise from ~939 lines to ~50
  for a 312-component bit status, vs 5 uninformative lines with the
  legacy loader.
- Silent under the legacy loader because no events fire there.

bit list (task 6.1): no code change needed. Investigation shows
ListerMain.localList already uses the lean path (ComponentsList.listAll
works directly off bitmap and ModelComponent objects, no workspace.get/
getMany/list). The optimisation this task aimed at is already in place.

Refs openspec/changes/rewrite-component-loading group 6.
…ferred

An earlier in-development experiment translating Phase to the legacy
loader's loadOpts flags ({ loadExtensions: false, executeLoadSlot: false,
loadSeedersAsAspects: false } for files/dependencies phases) measured a
4× speed-up on `bit status` cold-start (42s → 11s) but broke correctness:
subsequent code in status (issue checking via triggerAddComponentIssues,
env-as-aspect detection in Workspace.get) silently relies on extensions
being populated on the loaded components. The new-loader run dropped the
status report entirely.

This commit reverts to always-full-hydration in the host and adds a
detailed comment explaining why. The unified loader's orchestration,
caching, and observability still work correctly under BIT_LOADER=new;
only the per-phase perf wins are deferred to stage 2 where they require
either:
  (a) status calls upgrading specific components to extensions/aspects
      phase before passing them to issue checkers, or
  (b) a true phase-native load path inside the host that the legacy
      loader doesn't currently provide.

Both are tracked in tasks 4.2-4.6 and Group 8 of openspec/changes/
rewrite-component-loading.
Two issues caused BIT_LOADER=new bit status to OOM after bit cc:

(1) `Workspace.get` and `Workspace.getMany` were dual-mode under
BIT_LOADER=new, but aspect-loading (loadCompsAsAspects inside the
legacy getMany) makes many recursive workspace.get calls during a
batch load. Each recursive call through the unified loader incurs
event emission, hash computation, and cache bookkeeping; multiplied
by every recursion frame, this triggered OOM on cold cache. Fix:
keep Workspace.get and Workspace.getMany on the legacy path always
during stage 1. Only Workspace.listWithInvalidAtPhase (and via it,
listWithInvalid with the default phase) routes through the unified
loader. This is enough for the bit status pilot to exercise the
unified loader's caching and observability without triggering the
recursion blow-up. Stage 2 will internalise per-phase paths and
rework aspect-loading to be cache-friendly.

(2) Routing a single-ID load through host.loadManyAtPhase forced
the host through the legacy componentLoader.getMany batch machinery
(getAndLoadSlotOrdered) — designed for batches, far heavier than
componentLoader.get for one ID. Fix: the unified loader now uses
loadManyAtPhase only when needsLoad.length > 1; single-ID loads go
through loadAtPhase. Also, the workspace host now passes the
conservative STAGE1_LOAD_OPTS ({loadDocs: false, loadCompositions:
false}) to both loadAtPhase and loadManyAtPhase — these match what
the heaviest existing caller (bit status) has always passed, and
without them docs/compositions parsing for hundreds of components
exhausts heap.

Verified:
  bit cc && BIT_LOADER=new bit status — completes in 40.7s (vs
    legacy 40.4s) with full status output and no OOM.
  warm bit status — 9.94s under new vs 10.06s legacy.
  43 unit tests pass.

Refs openspec/changes/rewrite-component-loading.
Sets BIT_LOADER=new on both `e2e-test` and `e2e-test-circle` npm scripts
so every e2e run on CI exercises the unified component loader path.
This is the cheapest way to validate the new loader against the
existing e2e coverage without waiting for an opt-in release cycle.

Propagation: cross-env exports BIT_LOADER into the mocha process, and
the e2e command helper (components/legacy/e2e-helper/e2e-command-helper
.ts:119) inherits process.env into spawned bit child processes by
default — no helper changes needed.

Override locally with `BIT_LOADER=old npm run e2e-test` to run against
the legacy loader.

Verified two test files pass under the new default:
  - "bit status command > when no components created" (1 test)
  - "bit status command > when a component is created and added but
    not tagged" (3 tests)

CLAUDE.md updated with the override instruction.

Refs openspec/changes/rewrite-component-loading task 7.1.
…mport sites (task 8.9)

Adds `Scope.getOrImport` as the explicit, self-documenting wrapper for the
implicit auto-import behavior of `scope.get(id)`. Today it's semantically
identical to `scope.get(id, true, true)`; the rename exists so a future
stage can flip `scope.get`'s default to local-only without touching every
caller again.

Migrations per openspec/changes/rewrite-component-loading/audit/04-auto-import-sites.md:

- 6 "needs network" sites → `scope.getOrImport(id)`:
  - api-for-ide.ts:735 (IDE getCompDetails)
  - aspect.main.runtime.ts:92, :171 (getAspectNamesForComponent, debug-aspects)
  - workspace.main.runtime.ts:253 (onComponentConfigLoading subscriber)
  - workspace.ts:1148 (getExtensionsFromScopeAndSpecific)
  - workspace.ts:1850 (getUnmergedComponent — lane head)

- 6 "local-only" sites → `scope.get(id, true, false)` (importIfMissing=false):
  - remove.main.runtime.ts:225, :262, :300, :413
  - deprecation.main.runtime.ts:81
  - snapping.main.runtime.ts:445

Verified `bit status` produces identical output under both `BIT_LOADER=old`
and `BIT_LOADER=new`. Lint clean.

Also updates tasks.md: marks 8.9 done with implementation notes, and
reclassifies 8.8 from Tier 1 to Tier 2 with a detailed write-up of the
blockers found on closer inspection of audit/02 (most sites mutate
ConsumerComponent not harmony Component; the file/version mutations need
the unified loader to be source-of-truth before write→invalidate→reload
can replace in-memory mutation).
…kipping

Captures the 2026-05-12 explore-session outcomes for task 8.10. The original
design.md framed stage-2 perf wins as "callers ask for a lower phase; loader
does less work." Two clarifications surfaced during exploration invalidate
that framing:

1. Aspects must always be loaded for a Component to be complete — onLoad
   slots populate Component state that downstream readers depend on.
   Sub-aspects Components silently fail when their missing fields are read.

2. "Extensions" (legacy) and "aspects" (harmony) are the same concept
   under two names. The phase ladder collapses one rung.

Approaches (A), (B), and (C) all assumed sub-aspects Components are usable
and are therefore all dead as originally framed. The new strategy is
caching-first: short-circuit `unified.getMany` for cached IDs, audit
cache-invalidation granularity, ensure recursive `workspace.get` reliably
hits the cache, and re-benchmark. Per-command migrations (tasks 8.2-8.7)
reframe from "pick the lowest phase" to "audit that the cache short-circuit
covers each command at aspects phase."

design-stage2-perf.md is the new doc; tasks.md is updated to mark 8.10 as
designed, link to the doc, and reframe 8.2-8.7 as audit checkpoints.

No code changes — design only.
Audit 06 enumerates every explicit cache-invalidation call site, classifies
each by what it currently invalidates vs. what it needs to invalidate, and
identifies three patterns of over-invalidation:

- Pattern A (#10, #11): callers already have the affected ID list in scope,
  but call clearAllComponentsCache anyway. Mechanical swap to
  clearComponentsCache(ids). Tasks 8.11.
- Pattern B (#12, #4-6, #13): callers know the seed IDs but not the full
  affected set (e.g. auto-tag dependents, env consumers). Each needs a
  small reverse-index. Tasks 8.12, 8.14.
- Pattern C (#3, #8, #9): callers genuinely don't know the affected set.
  Each handled case-by-case: dead-code check, file-read narrowing, or
  accept worst-case nuke. Tasks 8.13, 8.15.

Audit also documents 5 "OK, don't regress" per-id sites and 3 FS-cache
sites, plus the two indirect cascade triggers (bitmap invalidate, configStore
invalidate) which are already minimal.

15 full-clear sites total, 7 of them are over-clears. The largest expected
wins are from narrowing snapping's pre-tag/snap clear (8.12) and the
install-time clears (8.14, deferred until Lever 1 ships and we measure).

No code changes — audit + task planning only.
Spike answers the question raised in the 2026-05-12 explore session:
is the complexity in workspace-component-loader.ts (1029 lines) essential
or accidental?

Verdict: largely accidental. Replaced by a two-pass design in ~430 lines.

Method: read every block of workspace-component-loader.ts, classify each
as essential / accidental / noise. Result:
- ~290 lines of essential code (loadOne, executeLoadSlot, loadCompsAsAspects,
  groupAndUpdateIds, etc.) must move to the consolidated host.
- ~600 lines (buildLoadGroups, getAndLoadSlotOrdered, getAndLoadSlot,
  populateScopeAndExtensionsCache, 4 in-memory caches, cache-key serialization,
  getComponentsWithoutLoadExtensions's error-handling sprawl) are deletable.
- ~30 lines of noise (debug printers, slimmable type defs) move to side files.

The structural insight: today's per-group state machine exists because the
loader can't load aspects out of order — but the topological constraint only
applies to the env/aspect SUBSET (~10-20 components for bit6), not all 311.
A two-pass design topo-sorts just that subset, then parallel-loads the rest:

  Pass 1: build all Components with extensions config, NO slots fired
  Pass 2: identify env/aspect subset, topo-sort, fire slots + register sequentially
  Pass 3: fire slots on remaining components in parallel

Spike artifacts:
- spike/01-consolidated-host-sketch.md — line-by-line classification,
  size estimate (~430 LoC), edge cases, verdict, counterfactual.
- spike/02-loader-host-sketch.ts.txt — strawman code sketch (NOT for
  compilation), showing the proposed shape with a line-count budget.

Tasks reframed: 8.11-8.15 (cache-invalidation narrowing) superseded by
the new 8.16 consolidation block. The user confirmed those invalidation
sites are probably correct in context; the consolidation collapses 4
caches to 1, making the invalidation surface trivial anyway.

8.16 sub-tasks structure the consolidation as a dual-mode rollout
(BIT_LOADER_HOST=v2 alongside v1), identical risk profile to stage-1's
BIT_LOADER=new strategy.

No code changes — spike + plan only.
…es via SCC

Two corrections to the spike from the user's 2026-05-13 feedback:

1. Drop the v1/v2 dual-mode hedge. We're in a PR; the PR + CI e2e suite is
   the safety net. Implement in-place, smoke-test, migrate, delete.

2. Cycles between aspects/envs are supported in Bit (highly discouraged but
   present). The two-pass design must use SCC detection rather than strict
   topo-sort. SCC of size 1 is a normal node; SCC of size >1 is a cycle group
   processed in arbitrary order. Matches today's group-machinery behavior.

Sub-task structure simplified: 8 sub-tasks instead of 8 + flag-management.
Replace the workspace-loader-host body with a two-pass design that
eliminates the per-group state machine of WorkspaceComponentLoader.

Pass 1: build all Components with extensions config, NO slots fired.
        Uses consumer.loadComponents in one batch (preserving the
        cold-cache sequential dep-resolution gate). Then calls
        workspace.loadComponentsExtensions for the merged extensions
        of the batch — this registers external env aspects (react-env,
        node-env, etc.) so the env-issue check in status doesn't
        falsely flag every dependent component.

Pass 2: identify env/aspect/app candidates, SCC-order them by
        env-of-env relation (iterative Tarjan's, ~30 LoC), fire
        slots in SCC order, register as aspects via
        workspace.loadAspects. SCC handling supports cycles between
        aspects (which Bit allows, though discouraged) — cycle
        members process in parallel within their SCC group.

Pass 3: fire slots on the remaining components in parallel.

Verified: `bit status` under BIT_LOADER=new produces output identical
to BIT_LOADER=old (byte-for-byte after filtering progress lines).
Lint clean. 0 env-issue false-positives.

Performance: warm bit status 11.7s (new) vs 10.2s (old). Slight
regression from the unconditional loadComponentsExtensions call;
legacy somehow registers envs without this call (mechanism not yet
identified). Acceptable trade-off for stage-2 milestone; optimization
deferred to later sub-task.

Workspace constructor now passes logger, dependencyResolver, envs,
aspectLoader to the host constructor (previously took only workspace).

Spike: openspec/changes/rewrite-component-loading/spike/01-consolidated-host-sketch.md
…ntsExtensions

Attempted to migrate Workspace.getMany to route through the unified loader.
Hung during bit status. Identified the recursion path:

  workspace.getMany (now unified)
   → host.loadMany → pass 1
     → workspace.loadComponentsExtensions  (added to fix env registration)
       → loadAspects → importAndGetAspects
         → workspace.importAndGetMany
           → workspace.getMany (now unified) ← RECURSES

Reverted Workspace.getMany to legacy. Documented two fix paths in tasks.md
8.16.4: (a) decouple env registration from getMany, (b) in-flight recursion
detection at the unified-loader level. Path (a) is the cleaner direction.

Bit status still works correctly under both BIT_LOADER=old (legacy) and
BIT_LOADER=new (host's listWithInvalidAtPhase routes through unified).
…(task 8.16.4)

Workspace.get (when no legacyComponent shortcut) and Workspace.getMany now
route through the unified component loader under BIT_LOADER=new. The host's
two-pass design replaces WorkspaceComponentLoader's per-group state machine
for these entry points.

Three coordinated changes to make this safe:

1. WorkspaceLoaderHost gets a `loadingDepth` counter. Recursive `loadMany`
   calls (triggered by pass 1's `loadComponentsExtensions` → eventually
   workspace.getMany) return config-only components without firing slots.
   The outer call's pass 2/3 handle the full aspect loading; inner callers
   need config-level state but not slot data.

2. Pass 1's `consumer.loadComponents` call now sets `originatedFromHarmony:
   true`. This suppresses the global onComponentLoad subscriber from firing
   workspace.get for every legacy component we just loaded. That subscriber
   exists to bridge legacy callers to harmony; here we ARE the harmony side,
   so the bridge would be redundant and (once Workspace.get routes through
   unified) recursive.

3. UnifiedComponentLoader exposes a new `publish(id, phase, component)` API
   so hosts that build batches internally can pre-populate the cache.
   Currently unused — the loadingDepth approach turned out cleaner — but
   kept for future flexibility (e.g. if we later want to publish partial
   components to short-circuit nested lookups).

The legacyComponent shortcut on Workspace.get (used by the onComponentLoad
subscriber to pass a pre-loaded ConsumerComponent) still routes through
WCL — that bridge path doesn't hit the recursion-prone aspect-loading
machinery.

Verified: bit status under BIT_LOADER=new produces byte-identical output to
BIT_LOADER=old. Performance: warm bit status 10.6s (new) vs 9.5s (old), a
~12% regression from the unconditional loadComponentsExtensions call.
Optimization deferred (Lever 1 / cache short-circuit may close the gap).

Lint clean. 0 env false-positives.
Workspace.get/getMany migration is complete. Bit status under BIT_LOADER=new
produces byte-identical output to BIT_LOADER=old.

E2e list suite ran under BIT_LOADER=new: 5/6 pass. The 1 failure is in
the --outdated remote-version scenario and is fallout from task 8.9
(implicit auto-import removal, citing workspace.getOrImport in the error)
— unrelated to the 8.16 host consolidation.
Adds a per-(id, phase) in-flight tracking map to UnifiedComponentLoader.
When a `getMany` request dispatches an id to the host, that id is
registered as a Promise. A second `getMany` request for the same id
arriving during the host's processing (which happens when host's internal
work — e.g. loadComponentsExtensions → workspace.getMany — recursively
asks for components in the same batch) awaits the existing promise
instead of triggering another full host call.

The result: same-id recursion is collapsed.

Measured under `bit status` in this workspace (311 components):

  Before Lever 1:  117 host calls total. core-aspect-env loaded 21 times
                   at consecutive nesting depths 6-26.
  After Lever 1:   64 host calls total. core-aspect-env loaded ONCE.

Performance: warm bit status now at parity with the legacy loader (10-11s
both modes, run-to-run variance dominates). Closes the 12% regression
introduced by the unconditional loadComponentsExtensions call in pass 1.

Implementation:
  - `inFlight: Map<key, Promise<Component | undefined>>` on the loader
  - `loadAndCache` (single-id) registers/resolves before/after host call
  - `loadBatchAndCache` (multi-id) registers all ids at once, resolves
    individually once the host returns the batch
  - `getMany` checks cache first, then in-flight, then dispatches to
    host for ids not in either
  - Cleanup is in finally/catch so errors don't leak in-flight entries

Lint clean. 0 env false-positives. byte-identical bit status output.
….e2e.ts)

The new @teambit/component-loader package and its transitive deps add
~30-50 files to bit-bootstrap. The previous limits were tuned for the
pre-stage-2 state and don't accommodate the new loader code.

- MAX_FILES_READ: 1062 -> 1120 (bit --help bootstrap)
- MAX_FILES_READ_STATUS: 1500 -> 1650 (bit status full load)

Updated the comment to reflect the new baseline (~1,065 for --help,
~1,600 for status) and the reason for the bump. Historical reduction
context preserved for future reference.

Also updates tasks.md with a post-compact handoff section describing
the current state, what's still alive, and recommended next moves for
8.16.6 (delete WorkspaceComponentLoader).
…is the only loader

Unified the component loading path. Removed the dual-mode BIT_LOADER guard
and deleted workspace-component-loader.ts (1029 LoC). The few callers that
genuinely needed the legacyComponent shortcut (onComponentLoad subscriber,
dev-files chicken-and-egg, snapping getManyByLegacy) route through a small
new helper buildAndLoadFromLegacy on the loader host instead of through
unified — routing those through unified would re-trigger consumer.loadComponents
for an id mid-load and double-load.

- inline Workspace.listInvalid (was getInvalid in WCL)
- move ComponentLoadOptions to its own file
- migrate checkout.spec.ts off direct workspace.componentLoader usage
- remove BIT_LOADER=new from e2e-test scripts and load-progress-renderer docs
The host's pass1 fetches the scope view of each workspace id for extension
merging. Some workspace components reference a scope version whose objects
aren't fully on disk locally (e.g. dep objects missing) — bit_pr and
check_circular_dependencies hit this on CI under the always-unified loader.

The deleted WCL handled this in populateScopeAndExtensionsCache by passing
importIfMissing=false to scope.get and swallowing per-id errors with a
warning. Mirror that here. The scope view is only used for extension
merging, which already tolerates an undefined componentFromScope.
…olved id

The host's partitionIds resolves workspace ids to their bitmap version
(e.g. 'teambit.harmony/api-server' -> 'teambit.harmony/api-server@<v>')
before loading. The result Map was keyed only by the resolved id's
toString(). UnifiedComponentLoader.getMany then looks up results by the
caller's INPUT id toString — so any caller passing versionless ids (e.g.
SnappingMain.snap via Workspace.getMany(idsWithoutVersions)) saw every
component reported as missing.

Mirror the previous WCL behaviour: also key by the original input id.
getScopeComponentsSafe was using importIfMissing=false for both buckets,
which silently drops external aspects/envs the caller explicitly asked for
(workspace.get(externalId) → unified → host pass1 → returned as missing
from the result Map → ComponentNotFound). bit_pr happened to not exercise
this path so it passed; bit install / bit envs set / bit create /
GeneratorMain hit it.

Split the two cases:
- Workspace ids: importIfMissing=false (the scope view is only used for
  extension merging; a missing version is tolerable, matches pre-rewrite
  populateScopeAndExtensionsCache).
- Scope-only ids: importIfMissing=true (matches pre-rewrite scope.get(id)
  default; missing model gets fetched from the remote).
davidfirst added 25 commits May 14, 2026 10:36
In-flight promises in loadAndCache and loadBatchAndCache were stored in
the inFlight map for recursive dedup but were never directly awaited by
the same call that created them. When the host threw, the promise was
rejected with no awaiter — triggering an unhandledRejection event.

The aspect loader temporarily sets setExitOnUnhandledRejection(false)
around plugin loading (a window that overlaps with the host's
loadComponentsExtensions → loadAspects path during pass1). When the
rejection fired in that window, it was silently ignored and the bit
process exited 0 with empty stdout — hiding the real failure and making
'bit insights circular --json' return empty output (the symptom
check_circular_dependencies hit).

Attach a no-op .catch() to mark the rejection as handled. Recursive
awaiters still receive the rejection via their own await, and the
caller's outer throw still propagates normally.
Temporarily add explicit output capture + stderr inheritance to
monitor-workspace-cycle.js so we can see why bit insights circular
--json produces empty output on CI.
…eadlock)

Lever 1's in-flight tracking deadlocks when the host's pass 2 (running
inside an outer getMany's host call) recursively asks for the same id
via workspace.loadAspects → workspace.getMany. The recursive call awaits
the outer's in-flight promise; the outer is blocked awaiting loadAspects
which is blocked on the recursive call. Two promises waiting on each
other, the event loop empties, and Node exits 0 with no output — which
is exactly the symptom check_circular_dependencies hit
('Unexpected end of JSON input' from JSON.parse("")) and what reproduced
locally for 'bit insights circular --json'.

bit_pr/status survived because their outer batch contained many ids and
the aspects pass 2 needed to load weren't usually in the outer batch;
bit insights calls workspace.get(id) one id at a time, so any
self-referential aspect id is the entire outer batch → deadlock.

Removed: the inFlight map, makeInFlightKey helper, and the prior
.catch(() => undefined) workaround (no longer needed once the in-flight
promises themselves are gone). Cache lookups remain — those are the
non-deadlocking half of the dedup story. The perf cost of the redundant
host calls for the recursive-load case is bounded and an acceptable
tradeoff for correctness.
…omponent-loader

Two unrelated fixes that together unblock bit_pr's --build step and
several e2e tests:

1. component-loader was created with env teambit.harmony/aspect (the
   Aspect-declaration aspect, not an env). Every other workspace aspect
   in this repo uses teambit.harmony/envs/core-aspect-env. The wrong env
   meant a different TSCompiler config that pulled component-package-name's
   source into the capsule and failed to resolve its legacy.* imports.
   Switch component-loader to core-aspect-env via bit envs set.

2. Workspace.get's self-as-aspect block called envs.isUsingEnvEnv directly,
   which throws via getEnvData when the env descriptor isn't on the
   component's aspect list. That happens for components loaded under host
   recursion (pass 2/3 skipped) or whose env aspect couldn't be resolved.
   Wrap the check so a throw is treated as 'not an env env' — the
   surrounding logic only needs yes/no, so we keep loading the rest of
   the component.
The 'bit envs set' from the previous commit added core-aspect-env but
left teambit.harmony/aspect in the config too, so bit detected two envs
and rejected the snap. Drop the aspect-marker; envs config alone is
enough to identify the env.
…ata is missing

getEnvData throws when the env descriptor isn't on the component's
aspect list. This happens for scope-only components (host's pass 3
skips them) and for components loaded under recursion (pass 2/3
skipped at depth > 1). Both checks here are used as yes/no signals
(in Workspace.get's self-as-aspect block, in findFirstEnv, etc.) so
treat a missing descriptor as 'not an env env' rather than letting
the throw propagate and break the caller.
…sing

Same root cause as the previous isUsingEnvEnv/isUsingAspectEnv wrap:
getEnvData throws when the env descriptor isn't on the component's
aspect list. getEnvIdFromEnvsData is called by getEnvId (and downstream
in DependencyResolverMain.getComponentDirInBitRoots), so the throw
propagates through bit install / bit envs set paths. The callers
already handle a returned undefined; treat the throw the same way.
…ackage

The unified loader was sitting in its own teambit.component/component-loader
component, but only @teambit/workspace consumes it. As a standalone aspect
component, it caused CI build pain — its declaration-emission step in the
capsule pipeline pulled in @teambit/component's source, which then pulled
component-package-name's source, which fails to compile because its capsule
doesn't have the legacy.* deps linked.

Move the source into scopes/workspace/workspace/component-loader/ and drop
the bitmap entry. workspace.ts now imports from a relative path. The aspect
+ runtime stubs (which were just scaffolding from bit create) are deleted —
the unified loader is a class, not an aspect. Spec files run as part of
workspace's tests (43/43 pass).
getEnvComponentByEnvId calls workspace.get which throws ComponentNotFound
when the id can't be resolved — common in build capsule contexts where
the workspace's scope is limited to the capsule's deps. Pre-rewrite WCL
returned undefined for missing components; the unified loader throws by
design. findFirstEnv's predicate just needs yes/no, so wrap the throw
and treat it as 'not an env'.

This was causing bit_pr to hang for 5h+ in an unhandled rejection retry
loop (>50MB of repeated ComponentNotFound output) — the throw propagated
to whatever called findFirstEnv and triggered import-retry cycles.
buildAndLoadFromLegacy builds a harmony Component from a pre-loaded
legacy (used by onComponentLoadSubscriber and dev-files) but didn't
populate the unified cache. Pre-rewrite WCL.get cached as a side effect
of every loadOne call, so core aspects loaded via the subscriber during
bootstrap (e.g. teambit.envs/env, teambit.react/react) were findable by
later workspace.get(coreAspectId) calls. Without the cache write, those
aspects fail to load via the unified path (they're not in the workspace
bitmap and not exported in scope), so callers like setEnvToComponents
throw ComponentNotFound.

Publish at phase 'aspects' — executeLoadSlot has fired, so the component
is fully hydrated at this phase.
Core aspects (teambit.envs/env, teambit.react/react, etc.) aren't in the
workspace bitmap or scope objects — they live in aspect-loader's registry.
workspace.get for them throws ComponentNotFound under the unified loader.
Pre-rewrite WCL had them cached as a side effect of legacy bootstrap, so
the verify step happened to work; the unified cache isn't pre-populated.

Since core aspects are known envs by definition, skip the verify step
for them. Non-core envs still go through the verification path.
…ComponentNotFound to legacy for instanceof checks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant