Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c18b009
fix: cascade lane updateDependents during local bit snap
davidfirst Apr 21, 2026
96b875e
fix: base cascaded updateDependents on main head, not on the prior snap
davidfirst Apr 21, 2026
e301f7f
Merge branch 'master' into fix-update-dependents-cascade-on-snap
davidfirst Apr 21, 2026
de511c9
docs: clarify why updateDependents are excluded from the auto-tag tri…
davidfirst Apr 21, 2026
874148b
fix: re-snap lane.components that depend on new updateDependents in _…
davidfirst Apr 21, 2026
b478402
refactor: push updateDependents directly from export instead of rerou…
davidfirst Apr 21, 2026
13d3a27
Merge branch 'master' into fix-update-dependents-cascade-on-snap
davidfirst Apr 21, 2026
122fec1
fix(snapping): address copilot review — broaden cascade deps, soften …
davidfirst Apr 22, 2026
215e815
refactor(snapping): use compact() and ComponentIdList.searchWithoutVe…
davidfirst Apr 22, 2026
748c4cc
Merge branch 'master' into fix-update-dependents-cascade-on-snap
davidfirst Apr 22, 2026
91ade16
fix(sources): preserve local updateDependents cascade on import via o…
davidfirst Apr 22, 2026
db7d945
Merge branch 'master' into fix-update-dependents-cascade-on-snap
davidfirst Apr 22, 2026
40c396c
feat(snap,export): surface cascaded updateDependents as 'snapped upda…
davidfirst Apr 22, 2026
198df65
Merge branch 'master' into fix-update-dependents-cascade-on-snap
davidfirst Apr 22, 2026
c9c2f7d
fix(reset): revert cascaded updateDependents via LaneHistory baseline
davidfirst Apr 23, 2026
0f863ff
fix(sources.mergeLane): clear overrideUpdateDependents on the remote'…
davidfirst Apr 23, 2026
ac1845d
fix(reset): pick lane-history entry BEFORE earliest reset batch (by d…
davidfirst Apr 23, 2026
0ac9686
Merge branch 'master' into fix-update-dependents-cascade-on-snap
davidfirst Apr 23, 2026
977482d
refactor(snapping): fold cascaded updateDependents into auto-snapped …
davidfirst Apr 23, 2026
33f95c6
perf: cap scope.get concurrency via pMapPool; precompute updateDepend…
davidfirst Apr 24, 2026
77c83ab
fix(reset): always persist lane after applyUpdateDependentsFromHistor…
davidfirst Apr 24, 2026
ab3cba4
feat(merge): refresh lane.updateDependents when merging main into a lane
davidfirst Apr 24, 2026
8362f76
fix(reset): load ModelComponent without version when dropping orphane…
davidfirst Apr 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions scopes/component/snapping/include-update-dependents-in-snap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { ComponentID } from '@teambit/component-id';
import { ComponentIdList } from '@teambit/component-id';
import type { Component } from '@teambit/component';
import type { ScopeMain } from '@teambit/scope';
import type { Logger } from '@teambit/logger';
import type { Lane } from '@teambit/objects';

export type UpdateDependentsForSnap = {
components: Component[];
ids: ComponentIdList;
};

/**
* When snapping on a lane that has `updateDependents`, fold the relevant entries into the same
* snap pass so they land with correct dependency hashes on the first try. Returns the components
* to include as extra snap seeders along with their ids; the caller appends them to the main
* snap's seeds before handing things off to the VersionMaker.
*
* Without this, `updateDependents` go stale as soon as any lane component they depend on is
* re-snapped locally, because the old entries keep pointing at Version objects whose recorded
* dependencies reference outdated lane-component hashes. Re-snapping them in the same pass (rather
* than in a separate post-step) avoids producing two Version objects per cascade and keeps the
* number of downstream Ripple CI builds at one per component, just like a normal snap.
*
* The `cascade set` is computed by a fixed-point expansion: start with the ids the user is
* snapping, then repeatedly add any updateDependent whose recorded dependencies reference an id
* already in the set. This handles transitive cascades (A -> B -> C, edit C → A and B cascade) and
* cycles (A -> B -> C -> A) because hashes are pre-assigned by `setHashes()` before deps are
* rewritten.
*/
export async function includeUpdateDependentsInSnap({
lane,
snapIds,
scope,
logger,
}: {
lane?: Lane;
snapIds: ComponentIdList;
scope: ScopeMain;
logger: Logger;
}): Promise<UpdateDependentsForSnap> {
const empty: UpdateDependentsForSnap = { components: [], ids: new ComponentIdList() };
Comment thread
davidfirst marked this conversation as resolved.
if (!lane) return empty;
const updateDependents = lane.updateDependents;
if (!updateDependents || !updateDependents.length) return empty;

const legacyScope = scope.legacyScope;
const updateDependentsIdList = ComponentIdList.fromArray(updateDependents);

try {
await legacyScope.scopeImporter.importWithoutDeps(updateDependentsIdList, {
cache: true,
includeUpdateDependents: true,
// VersionHistory is needed so that `getDivergeData` can resolve the remote head during
// export. Without it, `bit snap` succeeds locally but `bit export` fails with
// "TargetHeadNotFound" because the old updateDependents hash isn't reachable through
// VersionHistory in workspaces that never imported the updateDependent's objects.
includeVersionHistory: true,
lane,
reason: 'for including updateDependents in the local snap',
});
} catch (err: any) {
logger.debug(`includeUpdateDependentsInSnap: failed to pre-fetch updateDependents Version objects: ${err.message}`);
}

const loaded: Array<{ id: ComponentID; depIds: ComponentID[]; component?: Component }> = [];
for (const updDepId of updateDependents) {
const oldHash = updDepId.version;
if (!oldHash) continue;
const modelComponent = await legacyScope.getModelComponent(updDepId);
const version = await modelComponent.loadVersion(oldHash, legacyScope.objects, false);
if (!version) {
logger.debug(
`includeUpdateDependentsInSnap: Version object for ${updDepId.toString()} is missing locally, skipping`
);
continue;
}
const depIds = [...version.dependencies.get().map((d) => d.id), ...version.devDependencies.get().map((d) => d.id)];
Comment thread
davidfirst marked this conversation as resolved.
Outdated
loaded.push({ id: updDepId, depIds });
Comment thread
davidfirst marked this conversation as resolved.
Outdated
}

if (!loaded.length) return empty;

// Fixed-point expansion: add every updateDependent whose deps point at an id already in the
// cascade set. Repeat until nothing new is added. This handles transitive and cyclic cases.
const cascadeSet = new Set<string>(snapIds.map((id) => id.toStringWithoutVersion()));
let changed = true;
while (changed) {
changed = false;
for (const entry of loaded) {
const key = entry.id.toStringWithoutVersion();
if (cascadeSet.has(key)) continue;
const matches = entry.depIds.some((depId) => cascadeSet.has(depId.toStringWithoutVersion()));
if (matches) {
cascadeSet.add(key);
changed = true;
}
}
}

const toInclude = loaded.filter((entry) => {
const key = entry.id.toStringWithoutVersion();
if (!cascadeSet.has(key)) return false;
// don't include anything the user is already snapping directly.
if (snapIds.searchWithoutVersion(entry.id)) return false;
return true;
});
if (!toInclude.length) return empty;

const components = await Promise.all(
toInclude.map(async (entry) => {
const comp = await scope.get(entry.id);
if (!comp) {
throw new Error(`includeUpdateDependentsInSnap: unable to load component ${entry.id.toString()} from scope`);
}
return comp;
})
);

Comment thread
davidfirst marked this conversation as resolved.
Outdated
const ids = ComponentIdList.fromArray(components.map((c) => c.id));
return { components, ids };
}
18 changes: 17 additions & 1 deletion scopes/component/snapping/snapping.main.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import ResetCmd from './reset-cmd';
import type { NewDependencies } from './generate-comp-from-scope';
import { addDeps, generateCompFromScope } from './generate-comp-from-scope';
import { FlattenedEdgesGetter } from './flattened-edges';
import { includeUpdateDependentsInSnap } from './include-update-dependents-in-snap';
import { SnapDistanceCmd } from './snap-distance-cmd';
import type { ResetResult } from './reset-component';
import {
Expand Down Expand Up @@ -605,6 +606,20 @@ export class SnappingMain {
this.logger.debug(`snapping the following components: ${ids.toString()}`);
const components = await this.loadComponentsForTagOrSnap(ids);
await this.throwForVariousIssues(components, ignoreIssues);

// If the current lane has `updateDependents`, fold the relevant entries into the same snap
// pass so they land with the correct dep hashes on the first try (rather than going stale and
// needing a second snap). See `include-update-dependents-in-snap.ts` for the rationale.
const currentLaneObject = await consumer.scope.getCurrentLaneObject();
const { components: updDepComponents, ids: updDepIds } = await includeUpdateDependentsInSnap({
Comment thread
davidfirst marked this conversation as resolved.
lane: currentLaneObject || undefined,
snapIds: ids,
scope: this.scope,
logger: this.logger,
});
const allComponents = [...components, ...updDepComponents];
const allIds = ComponentIdList.uniqFromArray([...ids, ...updDepIds]);

Comment thread
davidfirst marked this conversation as resolved.
const makeVersionParams = {
editor,
ignoreNewestVersion: false,
Expand All @@ -624,9 +639,10 @@ export class SnappingMain {
detachHead,
loose,
ignoreIssues,
updateDependentIds: updDepIds.length ? updDepIds : undefined,
};
const { taggedComponents, autoTaggedResults, stagedConfig, removedComponents, totalComponentsCount } =
await this.makeVersion(ids, components, makeVersionParams);
await this.makeVersion(allIds, allComponents, makeVersionParams);

const snapResults: Partial<SnapResults> = {
snappedComponents: taggedComponents,
Expand Down
76 changes: 67 additions & 9 deletions scopes/component/snapping/version-maker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ export type VersionMakerParams = {
packageManagerConfigRootDir?: string;
exitOnFirstFailedTask?: boolean;
updateDependentsOnLane?: boolean;
/**
* When the snap pass includes cascaded updateDependents (see
* `include-update-dependents-in-snap.ts`), these ids receive the `addToUpdateDependentsInLane`
* treatment on a per-component basis: their Version object is added to `lane.updateDependents`
* instead of `lane.components`, and the workspace bitmap is left untouched so they stay hidden.
*/
updateDependentIds?: ComponentIdList;
setHeadAsParent?: boolean;
} & BasicTagParams;

Expand Down Expand Up @@ -153,28 +160,38 @@ export class VersionMaker {
};
}

const { rebuildDepsGraph, build, updateDependentsOnLane, setHeadAsParent, detachHead, overrideHead } = this.params;
const {
rebuildDepsGraph,
build,
updateDependentsOnLane,
updateDependentIds,
setHeadAsParent,
detachHead,
overrideHead,
} = this.params;
await this.snapping._addFlattenedDependenciesToComponents(this.allComponentsToTag, rebuildDepsGraph);
await this._addDependenciesGraphToComponents();
await this.snapping.throwForDepsFromAnotherLane(this.allComponentsToTag);
if (!build) this.emptyBuilderData();
this.addBuildStatus(this.allComponentsToTag, BuildStatus.Pending);

const currentLane = this.consumer?.getCurrentLaneId();
const isUpdateDependent = (id: ComponentID) => Boolean(updateDependentIds?.searchWithoutVersion(id));
await mapSeries(this.allComponentsToTag, async (component) => {
const compIsUpdDep = isUpdateDependent(component.id);
const results = await this.snapping._addCompToObjects({
source: component,
lane,
shouldValidateVersion: Boolean(build),
addVersionOpts: {
addToUpdateDependentsInLane: updateDependentsOnLane,
addToUpdateDependentsInLane: updateDependentsOnLane || compIsUpdDep,
setHeadAsParent,
detachHead,
overrideHead: overrideHead,
},
batchId: this.batchId,
});
if (this.workspace) {
if (this.workspace && !compIsUpdDep) {
const modelComponent = component.modelComponent || (await this.legacyScope.getModelComponent(component.id));
await updateVersions(
this.workspace,
Expand All @@ -184,7 +201,7 @@ export class VersionMaker {
results.addedVersionStr,
true
);
} else {
} else if (!this.workspace) {
const tagData = this.params.tagDataPerComp?.find((t) => t.componentId.isEqualWithoutVersion(component.id));
if (tagData?.isNew) results.version.removeAllParents();
}
Expand All @@ -194,7 +211,24 @@ export class VersionMaker {
await this.workspace.scope.legacyScope.stagedSnaps.write();
}
const publishedPackages: string[] = [];
const harmonyCompsToTag = await (this.workspace || this.scope).getManyByLegacy(this.allComponentsToTag);
// Cascaded updateDependents are not tracked in the workspace bitmap, so loading them via
// `workspace.getManyByLegacy` would fail when the workspace tries to resolve their rootDir.
// Route them through `scope.getManyByLegacy` while the real workspace components go through
// the normal workspace path, then merge the results in the original order.
const workspaceComps: ConsumerComponent[] = [];
const scopeOnlyComps: ConsumerComponent[] = [];
this.allComponentsToTag.forEach((comp) => {
if (this.workspace && updateDependentIds?.searchWithoutVersion(comp.id)) {
scopeOnlyComps.push(comp);
} else {
workspaceComps.push(comp);
}
});
const workspaceHarmonyComps = workspaceComps.length
? await (this.workspace || this.scope).getManyByLegacy(workspaceComps)
: [];
const scopeHarmonyComps = scopeOnlyComps.length ? await this.scope.getManyByLegacy(scopeOnlyComps) : [];
const harmonyCompsToTag = [...workspaceHarmonyComps, ...scopeHarmonyComps];
Comment thread
davidfirst marked this conversation as resolved.
// this is not necessarily the same as the previous allComponentsToTag. although it should be, because
// harmonyCompsToTag is created from allComponentsToTag. however, for aspects, the getMany returns them from cache
// and therefore, their instance of ConsumerComponent can be different than the one in allComponentsToTag.
Comment thread
davidfirst marked this conversation as resolved.
Comment thread
davidfirst marked this conversation as resolved.
Expand All @@ -206,7 +240,12 @@ export class VersionMaker {
await this.legacyScope.objects.persist();
await removeMergeConfigFromComponents(unmergedComps, this.allComponentsToTag, this.workspace);
if (this.workspace) {
await linkToNodeModulesByComponents(harmonyCompsToTag, this.workspace);
// Exclude cascaded updateDependents from node_modules linking — they have no rootDir in
// the workspace bitmap, so the linker would throw MissingBitMapComponent.
const compsToLink = updateDependentIds
? harmonyCompsToTag.filter((c) => !updateDependentIds.searchWithoutVersion(c.id))
: harmonyCompsToTag;
await linkToNodeModulesByComponents(compsToLink, this.workspace);
}
// clear all objects. otherwise, ModelComponent has the wrong divergeData
this.legacyScope.objects.clearObjectsFromCache();
Expand Down Expand Up @@ -277,7 +316,13 @@ export class VersionMaker {

private async triggerOnPreSnap(autoTagIds: ComponentIdList) {
const allFunctions = this.snapping.onPreSnapSlot.values();
await mapSeries(allFunctions, (func) => func(this.components, autoTagIds, this.params));
// Exclude cascaded updateDependents: they were loaded from the scope (not the workspace), so
// pre-snap steps that touch workspace files (e.g. formatting) have nothing to act on.
const updateDependentIds = this.params.updateDependentIds;
const components = updateDependentIds
? this.components.filter((c) => !updateDependentIds.searchWithoutVersion(c.id))
: this.components;
await mapSeries(allFunctions, (func) => func(components, autoTagIds, this.params));
}

private async addLaneObject(lane?: Lane) {
Expand Down Expand Up @@ -320,7 +365,12 @@ export class VersionMaker {
const isolateOptions = { packageManagerConfigRootDir, seedersOnly, populateArtifactsIgnorePkgJson };
const builderOptions = { exitOnFirstFailedTask, skipTests, skipTasks: skipTasksParsed, loose };

const componentsToBuild = harmonyCompsToTag.filter((c) => !c.isDeleted());
// Cascaded updateDependents are not part of the workspace and get built by the downstream
// Ripple CI job after export; building them here would require workspace state they don't have.
const updateDependentIds = this.params.updateDependentIds;
const componentsToBuild = harmonyCompsToTag.filter(
(c) => !c.isDeleted() && !updateDependentIds?.searchWithoutVersion(c.id)
);
if (componentsToBuild.length) {
const componentsToBuildLegacy: ConsumerComponent[] = componentsToBuild.map((c) => c.state._consumer);
await this.scope.reloadAspectsWithNewVersion(componentsToBuildLegacy);
Expand Down Expand Up @@ -391,7 +441,15 @@ export class VersionMaker {
if (!this.workspace) return this.getLaneAutoTagIdsFromScope(idsToTag);
// ids without versions are new. it's impossible that tagged (and not-modified) components has
// them as dependencies.
const idsToTriggerAutoTag = idsToTag.filter((id) => id.hasVersion());
// Cascaded updateDependents aren't in the workspace bitmap, so they can't be loaded by the
// auto-tag path (it calls consumer.loadComponents). Exclude them from the trigger set — they
// are already being snapped as part of this pass, and any workspace dependent of theirs must
// be in the seed set (it would have been loaded via the regular modified-files detection).
const updateDependentIds = this.params.updateDependentIds;
const workspaceIds = updateDependentIds
? idsToTag.filter((id) => !updateDependentIds.searchWithoutVersion(id))
: idsToTag;
const idsToTriggerAutoTag = workspaceIds.filter((id) => id.hasVersion());
const autoTagDataWithLocalOnly = await this.workspace.getAutoTagInfo(
ComponentIdList.fromArray(idsToTriggerAutoTag)
);
Expand Down
17 changes: 17 additions & 0 deletions scopes/scope/export/export.main.runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -751,9 +751,18 @@ ${localOnlyExportPending.map((c) => c.toString()).join('\n')}`);
? await componentsList.listNonNewComponentsIds()
: await componentsList.listExportPendingComponentsIds(laneObject);
const removedStagedBitIds = await this.getRemovedStagedBitIds();
// When the lane's updateDependents were cascaded locally (during `bit snap`), the new Version
// objects for those entries are in the local scope, but the per-component "isLocallyChanged"
// heuristic won't detect them because `lane.getComponentHead` only looks at `lane.components`.
// The lane itself sets `overrideUpdateDependents` to signal "I touched updateDependents locally
// — push them". Include those ids explicitly so the export pushes the cascaded Version objects.
const updateDependentsIds = laneObject.shouldOverrideUpdateDependents()
? (laneObject.updateDependents || []).map((id) => id.changeVersion(undefined))
: [];
Comment thread
davidfirst marked this conversation as resolved.
const componentsToExport = ComponentIdList.uniqFromArray([
...componentsToExportWithoutRemoved,
...removedStagedBitIds,
...updateDependentsIds,
]);
return { componentsToExport, laneObject };
}
Expand Down Expand Up @@ -841,6 +850,14 @@ async function updateLanesAfterExport(consumer: Consumer, lane: Lane) {
consumer.setCurrentLane(lane.toLaneId(), true);
consumer.scope.scopeJson.removeLaneFromNew(lane.name);
lane.isNew = false;
// `overrideUpdateDependents` is a request addressed to the remote — once the export is through,
// the remote has already merged our updateDependents, so the local flag must not linger (on a
// user's workspace lane, it must never be `true` at rest, see Lane.setOverrideUpdateDependents).
if (lane.shouldOverrideUpdateDependents()) {
lane.setOverrideUpdateDependents(false);
consumer.scope.objects.add(lane);
await consumer.scope.objects.persist();
}
Comment thread
davidfirst marked this conversation as resolved.
}

export function isUserTryingToExportLanes(consumer: Consumer) {
Expand Down
7 changes: 6 additions & 1 deletion scopes/scope/objects/models/model-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,12 @@ export default class Component extends BitObject {

setLaneHeadLocal(lane?: Lane) {
if (lane) {
this.laneHeadLocal = lane.getComponentHead(this.toComponentId());
// A component on a lane can live under `components` or `updateDependents`; for
// divergence/export purposes both represent "this component's current local head on the
// lane". Without the updateDependents branch here, cascaded updateDependents (see
// include-update-dependents-in-snap.ts) have a fresh Version saved locally but look
// unchanged to divergeData, so the export never includes their Version objects.
this.laneHeadLocal = lane.getCompHeadIncludeUpdateDependents(this.toComponentId()) || null;
}
}

Expand Down