Repository: microsoft/FluidFramework
Pull request #24524: StagingMode: Allow application to control whether or not to squash edits
Author: login: Abe27342
Labels: name: area: runtime
description: Runtime related issues
name: dependencies
description: Pull requests that update a dependency file
name: area: tests
description: Tests to add, test infrastructure improvements, etc
name: public api change
description: Changes to a public API
name: base: main
description: PRs targeted against main branch
Created: 2025-05-05T19:01:17Z | Merged: 2025-05-09T22:56:30Z

## Description

Modifies the (experimental) API for staging mode to allow the application to specify whether or not they want to squash changes they've made.

This also addresses an issue where squashing was not performed when the container was disconnected at staging mode exit time ([AB#37788](https://dev.azure.com/fluidframework/235294da-091d-4c29-84fc-cdfc3d90890b/_workitems/edit/37788)), and adds test coverage for these scenarios.





Diff:
diff --git a/packages/runtime/container-runtime/src/containerRuntime.ts b/packages/runtime/container-runtime/src/containerRuntime.ts
index fc68108335f5..8c1a255cb086 100644
--- a/packages/runtime/container-runtime/src/containerRuntime.ts
+++ b/packages/runtime/container-runtime/src/containerRuntime.ts
@@ -198,7 +198,6 @@ import {
 } from "./messageTypes.js";
 import { ISavedOpMetadata } from "./metadata.js";
 import {
-	BatchId,
 	LocalBatchMessage,
 	BatchStartInfo,
 	DuplicateBatchDetector,
@@ -218,6 +217,7 @@ import {
 	PendingMessageResubmitData,
 	IPendingLocalState,
 	PendingStateManager,
+	type PendingBatchResubmitMetadata,
 } from "./pendingStateManager.js";
 import { RunCounter } from "./runCounter.js";
 import {
@@ -506,6 +506,8 @@ export const defaultRuntimeHeaderData: Required<RuntimeHeaderData> = {
 	allowTombstone: false,
 };
 
+const defaultStagingCommitOptions = { squash: false };
+
 /**
  * @deprecated
  * Untagged logger is unsupported going forward. There are old loaders with old ContainerContexts that only
@@ -3239,26 +3241,10 @@ export class ContainerRuntime
 		}
 	}
 
-	/**
-	 * @privateRemarks
-	 * orderSequentially predates staging mode, but its implementation has been updated to leverage it.
-	 * Public usage of staging mode has more guards around only being used with objects that fully support it (i.e. that support squashing
-	 * as well as generalized rollback which may occur after inbounding further remote ops).
-	 *
-	 * This member allows the container runtime to bypass those guards by opting for legacy behavior when exiting an orderSequentially block (e.g.
-	 * op resubmission will not be squashed).
-	 * This can therefore be removed once staging mode is broadly supported across layers.
-	 */
-	private readonly orderSequentiallyRunner = new RunCounter();
-
 	/**
 	 * {@inheritDoc @fluidframework/runtime-definitions#IContainerRuntimeBase.orderSequentially}
 	 */
 	public orderSequentially<T>(callback: () => T): T {
-		return this.orderSequentiallyRunner.run(() => this.orderSequentiallyCore(callback));
-	}
-
-	private orderSequentiallyCore<T>(callback: () => T): T {
 		let checkpoint: IBatchCheckpoint | undefined;
 		const checkpointDirtyState = this.dirtyContainer;
 		// eslint-disable-next-line import/no-deprecated
@@ -3318,7 +3304,8 @@ export class ContainerRuntime
 				throw error; // throw the original error for the consumer of the runtime
 			}
 		});
-		stageControls?.commitChanges();
+
+		stageControls?.commitChanges({ squash: false });
 
 		// We don't flush on TurnBased since we expect all messages in the same JS turn to be part of the same batch
 		if (this.flushMode !== FlushMode.TurnBased && !this.batchRunner.running) {
@@ -3363,7 +3350,8 @@ export class ContainerRuntime
 			discardOrCommit();
 		};
 
-		const stageControls = {
+		// eslint-disable-next-line import/no-deprecated
+		const stageControls: StageControlsExperimental = {
 			discardChanges: exitStagingMode(() => {
 				// Pop all staged batches from the PSM and roll them back in LIFO order
 				this.pendingStateManager.popStagedBatches(({ runtimeOp, localOpMetadata }) => {
@@ -3377,17 +3365,15 @@ export class ContainerRuntime
 					this.updateDocumentDirtyState(this.pendingMessagesCount !== 0);
 				}
 			}),
-			commitChanges: exitStagingMode(() => {
-				// All staged changes are in the PSM, so just replay them (ignore pre-staging batches)
-				if (this.connected) {
-					this.pendingStateManager.replayPendingStates(true /* onlyStagedBatched */);
-				} else {
-					// TODO:AB#37788: Refactor interplay with pending state manager and staging mode so that even in this case we end up
-					// squashing the ops submitted while in staging mode. This does not happen now due to the following clear without asking
-					// data stores to resubmit.
-					this.pendingStateManager.clearStagingFlags();
-				}
-			}),
+			commitChanges: (optionsParam) => {
+				const options = { ...defaultStagingCommitOptions, ...optionsParam };
+				return exitStagingMode(() => {
+					this.pendingStateManager.replayPendingStates({
+						onlyStagedBatches: true,
+						squash: options.squash ?? false,
+					});
+				})();
+			},
 		};
 
 		return (this.stageControls = stageControls);
@@ -4585,14 +4571,11 @@ export class ContainerRuntime
 	 */
 	private reSubmitBatch(
 		batch: PendingMessageResubmitData[],
-		batchId: BatchId,
-		{ staged, squash }: { staged: boolean; squash: boolean },
+		{ batchId, staged, squash }: PendingBatchResubmitMetadata,
 	): void {
 		this.batchRunner.run(() => {
 			for (const message of batch) {
-				// Note: once squashing is widely supported, squashing even for orderSequentially should be fine.
-				// See remarks on orderSequentiallyRunner for more details.
-				this.reSubmit(message, this.orderSequentiallyRunner.running ? false : squash);
+				this.reSubmit(message, squash);
 			}
 		});
 
diff --git a/packages/runtime/container-runtime/src/pendingStateManager.ts b/packages/runtime/container-runtime/src/pendingStateManager.ts
index afb3fd7fc6b3..f2c7102ce594 100644
--- a/packages/runtime/container-runtime/src/pendingStateManager.ts
+++ b/packages/runtime/container-runtime/src/pendingStateManager.ts
@@ -22,13 +22,13 @@ import {
 } from "./messageTypes.js";
 import { asBatchMetadata, asEmptyBatchLocalOpMetadata } from "./metadata.js";
 import {
-	BatchId,
 	LocalBatchMessage,
 	getEffectiveBatchId,
 	BatchStartInfo,
 	InboundMessageResult,
 	serializeOp,
 	type LocalEmptyBatchPlaceholder,
+	type BatchResubmitInfo,
 } from "./opLifecycle/index.js";
 
 /**
@@ -114,14 +114,20 @@ export type PendingMessageResubmitData = Pick<
 	runtimeOp: LocalContainerRuntimeMessage;
 };
 
+export interface PendingBatchResubmitMetadata extends BatchResubmitInfo {
+	/**
+	 * Whether changes in this batch should be squashed when resubmitting.
+	 */
+	squash: boolean;
+}
+
 export interface IRuntimeStateHandler {
 	connected(): boolean;
 	clientId(): string | undefined;
 	applyStashedOp(serializedOp: string): Promise<unknown>;
 	reSubmitBatch(
 		batch: PendingMessageResubmitData[],
-		batchId: BatchId,
-		stagingInfo: { staged: boolean; squash: boolean },
+		metadata: PendingBatchResubmitMetadata,
 	): void;
 	isActiveConnection: () => boolean;
 	isAttached: () => boolean;
@@ -215,6 +221,24 @@ function toSerializableForm(
 	};
 }
 
+interface ReplayPendingStateOptions {
+	/**
+	 * If true, only replay staged batches. This is used when we are exiting staging mode and want to rebase and submit the staged batches.
+	 * Default: false
+	 */
+	onlyStagedBatches: boolean;
+	/**
+	 * @param squash - If true, edits should be squashed when resubmitting.
+	 * Default: false
+	 */
+	squash: boolean;
+}
+
+const defaultReplayPendingStatesOptions: ReplayPendingStateOptions = {
+	onlyStagedBatches: false,
+	squash: false,
+};
+
 /**
  * PendingStateManager is responsible for maintaining the messages that have not been sent or have not yet been
  * acknowledged by the server. It also maintains the batch information for both automatically and manually flushed
@@ -687,11 +711,12 @@ export class PendingStateManager implements IDisposable {
 	 * Called when the Container's connection state changes. If the Container gets connected, it replays all the pending
 	 * states in its queue. This includes triggering resubmission of unacked ops.
 	 * ! Note: successfully resubmitting an op that has been successfully sequenced is not possible due to checks in the ConnectionStateHandler (Loader layer)
-	 * @param onlyStagedBatches - If true, only replay staged batches. This is used when we are exiting staging mode and want to rebase and submit the staged batches.
 	 */
-	public replayPendingStates(onlyStagedBatches?: boolean): void {
+	public replayPendingStates(optionsParam?: ReplayPendingStateOptions): void {
+		const options = { ...defaultReplayPendingStatesOptions, ...optionsParam };
+		const { onlyStagedBatches, squash } = options;
 		assert(
-			this.stateHandler.connected(),
+			this.stateHandler.connected() || onlyStagedBatches === true,
 			0x172 /* "The connection state is not consistent with the runtime" */,
 		);
 
@@ -723,8 +748,6 @@ export class PendingStateManager implements IDisposable {
 			let pendingMessage = this.pendingMessages.shift()!;
 			remainingPendingMessagesCount--;
 
-			const wasStaged = pendingMessage.batchInfo.staged;
-
 			// Re-queue pre-staging messages if we are only processing staged batches
 			if (onlyStagedBatches) {
 				if (!pendingMessage.batchInfo.staged) {
@@ -747,7 +770,7 @@ export class PendingStateManager implements IDisposable {
 
 			if (asEmptyBatchLocalOpMetadata(pendingMessage.localOpMetadata)?.emptyBatch === true) {
 				// Resubmit no messages, with the batchId. Will result in another empty batch marker.
-				this.stateHandler.reSubmitBatch([], batchId, { staged, squash: wasStaged });
+				this.stateHandler.reSubmitBatch([], { batchId, staged, squash });
 				continue;
 			}
 
@@ -772,8 +795,7 @@ export class PendingStateManager implements IDisposable {
 							opMetadata: pendingMessage.opMetadata,
 						},
 					],
-					batchId,
-					{ staged, squash: wasStaged },
+					{ batchId, staged, squash },
 				);
 				continue;
 			}
@@ -813,7 +835,7 @@ export class PendingStateManager implements IDisposable {
 				);
 			}
 
-			this.stateHandler.reSubmitBatch(batch, batchId, { staged, squash: wasStaged });
+			this.stateHandler.reSubmitBatch(batch, { batchId, staged, squash });
 		}
 
 		// pending ops should no longer depend on previous sequenced local ops after resubmit
@@ -831,17 +853,6 @@ export class PendingStateManager implements IDisposable {
 		}
 	}
 
-	/**
-	 * Clears the 'staged' flag off all pending messages.
-	 */
-	public clearStagingFlags(): void {
-		for (const message of this.pendingMessages.toArray()) {
-			if (message.batchInfo.staged) {
-				message.batchInfo.staged = false;
-			}
-		}
-	}
-
 	/**
 	 * Pops all staged batches, invoking the callback on each one in order (LIFO)
 	 */
diff --git a/packages/runtime/container-runtime/src/test/pendingStateManager.spec.ts b/packages/runtime/container-runtime/src/test/pendingStateManager.spec.ts
index 0c47dce9de14..e690bb65d1da 100644
--- a/packages/runtime/container-runtime/src/test/pendingStateManager.spec.ts
+++ b/packages/runtime/container-runtime/src/test/pendingStateManager.spec.ts
@@ -694,7 +694,7 @@ describe("Pending State Manager", () => {
 
 	describe("replayPendingStates", () => {
 		let pendingStateManager: PendingStateManager;
-		const resubmittedBatchIds: string[] = [];
+		const resubmittedBatchIds: (string | undefined)[] = [];
 		const clientId = "clientId";
 
 		beforeEach(async () => {
@@ -704,7 +704,7 @@ describe("Pending State Manager", () => {
 					applyStashedOp: async () => undefined,
 					clientId: () => clientId,
 					connected: () => true,
-					reSubmitBatch: (batch, batchId) => {
+					reSubmitBatch: (batch, { batchId }) => {
 						resubmittedBatchIds.push(batchId);
 					},
 					isActiveConnection: () => false,
diff --git a/packages/runtime/runtime-definitions/api-report/runtime-definitions.legacy.alpha.api.md b/packages/runtime/runtime-definitions/api-report/runtime-definitions.legacy.alpha.api.md
index e7f2dd53929b..3eef87bac9c8 100644
--- a/packages/runtime/runtime-definitions/api-report/runtime-definitions.legacy.alpha.api.md
+++ b/packages/runtime/runtime-definitions/api-report/runtime-definitions.legacy.alpha.api.md
@@ -16,6 +16,11 @@ export interface AttributionInfo {
 // @alpha @legacy
 export type AttributionKey = OpAttributionKey | DetachedAttributionKey | LocalAttributionKey;
 
+// @alpha @sealed @deprecated @legacy (undocumented)
+export interface CommitStagedChangesOptionsExperimental {
+    squash?: boolean;
+}
+
 // @alpha @legacy (undocumented)
 export type CreateChildSummarizerNodeFn = (summarizeInternal: SummarizeInternalFn, getGCDataFn: (fullGC?: boolean) => Promise<IGarbageCollectionData>,
 getBaseGCDetailsFn?: () => Promise<IGarbageCollectionDetailsBase>) => ISummarizerNodeWithGC;
@@ -404,9 +409,7 @@ export interface OpAttributionKey {
 
 // @alpha @sealed @deprecated @legacy (undocumented)
 export interface StageControlsExperimental {
-    // (undocumented)
-    readonly commitChanges: () => void;
-    // (undocumented)
+    readonly commitChanges: (options?: Partial<CommitStagedChangesOptionsExperimental>) => void;
     readonly discardChanges: () => void;
 }
 
diff --git a/packages/runtime/runtime-definitions/src/dataStoreContext.ts b/packages/runtime/runtime-definitions/src/dataStoreContext.ts
index 4710cefee9a6..bf6d26fcbf4c 100644
--- a/packages/runtime/runtime-definitions/src/dataStoreContext.ts
+++ b/packages/runtime/runtime-definitions/src/dataStoreContext.ts
@@ -310,6 +310,31 @@ export interface IContainerRuntimeBase extends IEventProvider<IContainerRuntimeB
 	): Promise<{ snapshotTree: ISnapshotTree; sequenceNumber: number }>;
 }
 
+/**
+ * @experimental
+ * @deprecated - These APIs are unstable, and can be changed at will. They should only be used with direct agreement with the Fluid Framework.
+ * @legacy
+ * @alpha
+ * @sealed
+ */
+export interface CommitStagedChangesOptionsExperimental {
+	/**
+	 * If true, intermediate states created by changes made while in staging mode will be "squashed" out of the
+	 * ops which were created during staging mode.
+	 * Defaults to false.
+	 * @remarks
+	 * The squash parameter is analogous to `git squash` but differs in a notable way: ops created by a client exiting staging mode
+	 * are not necessarily coalesced into a single op or something like it.
+	 * It still does have the desirable property that "unnecessary changes" (such as inserting some content then removing it) will
+	 * be removed from the set of submitted ops, which means it helps reduce network traffic and the chance of unwanted data being
+	 * persisted--even if only temporarily--in the document.
+	 *
+	 * By not attempting to reduce the set of changes to a single op a la `git squash`, we can better preserve the ordering of
+	 * changes that remote clients see such that they better align with the client which submitted the changes.
+	 */
+	squash?: boolean;
+}
+
 /**
  * @experimental
  * @deprecated - These APIs are unstable, and can be changed at will. They should only be used with direct agreement with the Fluid Framework.
@@ -318,7 +343,16 @@ export interface IContainerRuntimeBase extends IEventProvider<IContainerRuntimeB
  * @sealed
  */
 export interface StageControlsExperimental {
-	readonly commitChanges: () => void;
+	/**
+	 * Exit staging mode and commit to any changes made while in staging mode.
+	 * This will cause them to be sent to the ordering service, and subsequent changes
+	 * made by this container will additionally flow freely to the ordering service.
+	 * @param options - Options when committing changes.
+	 */
+	readonly commitChanges: (options?: Partial<CommitStagedChangesOptionsExperimental>) => void;
+	/**
+	 * Exit staging mode and discard any changes made while in staging mode.
+	 */
 	readonly discardChanges: () => void;
 }
 
@@ -423,6 +457,9 @@ export interface IFluidDataStoreChannel extends IDisposable {
 	 * For example, if this message (call it A) inserts content into a DDS that a subsequent op (call it B) removes,
 	 * resubmission of this message (call it A') should avoid inserting that content, and resubmission of the subsequent op that removed it (B') would
 	 * account for the fact that A' never inserted content.
+	 *
+	 * @privateRemarks
+	 * See remarks about squashing contract on `CommitStagedChangesOptionsExperimental`.
 	 */
 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO (#28746): breaking change
 	reSubmit(type: string, content: any, localOpMetadata: unknown, squash?: boolean);
diff --git a/packages/runtime/runtime-definitions/src/index.ts b/packages/runtime/runtime-definitions/src/index.ts
index 3116345a6557..7dc10fbd610b 100644
--- a/packages/runtime/runtime-definitions/src/index.ts
+++ b/packages/runtime/runtime-definitions/src/index.ts
@@ -23,6 +23,7 @@ export type {
 	IFluidDataStoreContextDetached,
 	IPendingMessagesState,
 	StageControlsExperimental,
+	CommitStagedChangesOptionsExperimental,
 } from "./dataStoreContext.js";
 export { FlushMode, FlushModeExperimental, VisibilityState } from "./dataStoreContext.js";
 export type { IProvideFluidDataStoreFactory } from "./dataStoreFactory.js";
diff --git a/packages/test/local-server-stress-tests/src/stressDataObject.ts b/packages/test/local-server-stress-tests/src/stressDataObject.ts
index 773fe932ba6a..2eba18c470e1 100644
--- a/packages/test/local-server-stress-tests/src/stressDataObject.ts
+++ b/packages/test/local-server-stress-tests/src/stressDataObject.ts
@@ -348,8 +348,6 @@ export const createRuntimeFactory = (): IRuntimeFactory => {
 			return this;
 		},
 		instantiateRuntime: async (context, existing) => {
-			// This can be removed or scoped to options passed to specific data stores once we support squashing more widely.
-			context.options.allowStagingModeWithoutSquashing = true;
 			const runtime = await loadContainerRuntime({
 				context,
 				existing,
diff --git a/packages/test/local-server-stress-tests/src/test/localServerStressOrderSequentially.spec.ts b/packages/test/local-server-stress-tests/src/test/localServerStressOrderSequentially.spec.ts
index 75a9019c3947..4f2e7d7989fe 100644
--- a/packages/test/local-server-stress-tests/src/test/localServerStressOrderSequentially.spec.ts
+++ b/packages/test/local-server-stress-tests/src/test/localServerStressOrderSequentially.spec.ts
@@ -98,7 +98,7 @@ describe("Local Server Stress with rollback", () => {
 			...[76], //  Rollback op does not match last pending
 			...[84, 88], //  Startpoints of interval  different
 			...[12, 28, 32, 36, 44, 45, 55, 60, 89], //  Number of subDirectories not same
-			...[4, 14, 69, 74], // 0xb86 and 0xb89: exit staging mode logic failing due to id compressor allocation ops
+			...[4, 14, 69, 74, 95], // 0xb86 and 0xb89: exit staging mode logic failing due to id compressor allocation ops
 		],
 	});
 });
diff --git a/packages/test/local-server-tests/package.json b/packages/test/local-server-tests/package.json
index 3b3f966e24f5..b0a3d7877dd5 100644
--- a/packages/test/local-server-tests/package.json
+++ b/packages/test/local-server-tests/package.json
@@ -58,6 +58,7 @@
 		"@fluid-experimental/tree": "workspace:~",
 		"@fluid-internal/mocha-test-setup": "workspace:~",
 		"@fluid-private/test-drivers": "workspace:~",
+		"@fluid-private/test-pairwise-generator": "workspace:~",
 		"@fluidframework/aqueduct": "workspace:~",
 		"@fluidframework/build-common": "^2.0.3",
 		"@fluidframework/build-tools": "^0.55.0",
@@ -78,6 +79,7 @@
 		"@fluidframework/runtime-utils": "workspace:~",
 		"@fluidframework/sequence": "workspace:~",
 		"@fluidframework/server-local-server": "^5.0.0",
+		"@fluidframework/shared-object-base": "workspace:~",
 		"@fluidframework/telemetry-utils": "workspace:~",
 		"@fluidframework/test-utils": "workspace:~",
 		"@fluidframework/tree": "workspace:~",
diff --git a/packages/test/local-server-tests/src/test/stagingMode.spec.ts b/packages/test/local-server-tests/src/test/stagingMode.spec.ts
index ba8895bb8d13..e741405aaae0 100644
--- a/packages/test/local-server-tests/src/test/stagingMode.spec.ts
+++ b/packages/test/local-server-tests/src/test/stagingMode.spec.ts
@@ -5,6 +5,7 @@
 
 import { strict as assert } from "assert";
 
+import { generatePairwiseOptions } from "@fluid-private/test-pairwise-generator";
 import { DataObject, DataObjectFactory } from "@fluidframework/aqueduct/internal";
 import {
 	type IContainer,
@@ -33,6 +34,7 @@ import {
 	LocalDeltaConnectionServer,
 	type ILocalDeltaConnectionServer,
 } from "@fluidframework/server-local-server";
+import type { SharedObject } from "@fluidframework/shared-object-base/internal";
 import { LoggingError, wrapError } from "@fluidframework/telemetry-utils/internal";
 
 import { createLoader } from "../utils.js";
@@ -519,4 +521,66 @@ describe("Staging Mode", () => {
 			"Edit submitted while in staging mode should be committed.",
 		);
 	});
+
+	function spyOn(object: object, methodName: string) {
+		const originalMethod = object[methodName];
+		assert(typeof originalMethod === "function", `${methodName} is not a function`);
+		const calls: unknown[][] = [];
+		object[methodName] = (...args: unknown[]) => {
+			calls.push([...args]);
+			const result: unknown = originalMethod.apply(object, args);
+			return result;
+		};
+		return calls;
+	}
+
+	for (const { disconnectBeforeCommit, squash } of generatePairwiseOptions({
+		disconnectBeforeCommit: [false, true],
+		squash: [undefined, false, true],
+	})) {
+		it(`respects squash=${squash} when exiting staging mode ${disconnectBeforeCommit ? "while disconnected" : ""}`, async () => {
+			const deltaConnectionServer = LocalDeltaConnectionServer.create();
+			const clients = await createClients(deltaConnectionServer);
+
+			// eslint-disable-next-line @typescript-eslint/dot-notation
+			const rootMap = clients.original.dataObject["root"] as unknown as SharedObject;
+			const reSubmitSquashedLog = spyOn(rootMap, "reSubmitSquashed");
+			const reSubmitLog = spyOn(rootMap, "reSubmitCore");
+
+			const stagingControls = clients.original.dataObject.enterStagingMode();
+			clients.original.dataObject.makeEdit("branch-only");
+
+			if (disconnectBeforeCommit) {
+				await ensureDisconnected(clients.original);
+			}
+			stagingControls.commitChanges({ squash });
+			if (disconnectBeforeCommit) {
+				await ensureConnected(clients.original);
+			}
+
+			await waitForSave(clients);
+
+			assertConsistent(clients, "States should match after save");
+			assert.equal(
+				reSubmitSquashedLog.length,
+				squash === true ? 1 : 0,
+				"Squashed resubmit should be called iff squash = true.",
+			);
+			assert(
+				JSON.stringify((squash ? reSubmitSquashedLog : reSubmitLog)[0][0]).includes(
+					"branch-only",
+				),
+				"Squashed op should contain the edit prefix.",
+			);
+			if (squash !== true) {
+				assert.equal(
+					reSubmitLog.length,
+					// 2 resubmits when disconnected happens because there is one resubmit upon exiting staging mode (to clear staging flags),
+					// then another when we eventually reconnect.
+					disconnectBeforeCommit ? 2 : 1,
+					"Normal resubmit should be called when squash = false.",
+				);
+			}
+		});
+	}
 });
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 91663f8e98f3..362831d67d10 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -13617,6 +13617,9 @@ importers:
       '@fluid-private/test-drivers':
         specifier: workspace:~
         version: link:../test-drivers
+      '@fluid-private/test-pairwise-generator':
+        specifier: workspace:~
+        version: link:../test-pairwise-generator
       '@fluidframework/aqueduct':
         specifier: workspace:~
         version: link:../../framework/aqueduct
@@ -13677,6 +13680,9 @@ importers:
       '@fluidframework/server-local-server':
         specifier: ^5.0.0
         version: 5.0.0
+      '@fluidframework/shared-object-base':
+        specifier: workspace:~
+        version: link:../../dds/shared-object-base
       '@fluidframework/telemetry-utils':
         specifier: workspace:~
         version: link:../../utils/telemetry-utils
@@ -30298,9 +30304,9 @@ snapshots:
       '@typescript-eslint/parser': 6.7.5(eslint@8.55.0)(typescript@5.4.5)
       eslint-config-biome: 1.9.4
       eslint-config-prettier: 9.0.0(eslint@8.55.0)
-      eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-plugin-i@2.29.1(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint@8.55.0))(eslint@8.55.0)
+      eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-plugin-i@2.29.1)(eslint@8.55.0)
       eslint-plugin-eslint-comments: 3.2.0(eslint@8.55.0)
-      eslint-plugin-import: eslint-plugin-i@2.29.1(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-plugin-i@2.29.1(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint@8.55.0))(eslint@8.55.0))(eslint@8.55.0)
+      eslint-plugin-import: eslint-plugin-i@2.29.1(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3)(eslint@8.55.0)
       eslint-plugin-jsdoc: 46.8.2(eslint@8.55.0)
       eslint-plugin-promise: 6.1.1(eslint@8.55.0)
       eslint-plugin-react: 7.33.2(eslint@8.55.0)
@@ -36143,33 +36149,33 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
-  eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-plugin-i@2.29.1(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint@8.55.0))(eslint@8.55.0):
+  eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-plugin-i@2.29.1)(eslint@8.55.0):
     dependencies:
       '@nolyfill/is-core-module': 1.0.39
       debug: 4.4.0(supports-color@8.1.1)
       enhanced-resolve: 5.17.1
       eslint: 8.55.0
-      eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-plugin-i@2.29.1(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint@8.55.0))(eslint@8.55.0))(eslint@8.55.0)
+      eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.55.0)
       fast-glob: 3.3.3
       get-tsconfig: 4.10.0
       is-bun-module: 1.3.0
       is-glob: 4.0.3
     optionalDependencies:
-      eslint-plugin-import: eslint-plugin-i@2.29.1(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-plugin-i@2.29.1(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint@8.55.0))(eslint@8.55.0))(eslint@8.55.0)
+      eslint-plugin-import: eslint-plugin-i@2.29.1(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3)(eslint@8.55.0)
     transitivePeerDependencies:
       - '@typescript-eslint/parser'
       - eslint-import-resolver-node
       - eslint-import-resolver-webpack
       - supports-color
 
-  eslint-module-utils@2.12.0(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-plugin-i@2.29.1(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint@8.55.0))(eslint@8.55.0))(eslint@8.55.0):
+  eslint-module-utils@2.12.0(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.55.0):
     dependencies:
       debug: 3.2.7
     optionalDependencies:
       '@typescript-eslint/parser': 6.7.5(eslint@8.55.0)(typescript@5.4.5)
       eslint: 8.55.0
       eslint-import-resolver-node: 0.3.9
-      eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-plugin-i@2.29.1(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint@8.55.0))(eslint@8.55.0)
+      eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-plugin-i@2.29.1)(eslint@8.55.0)
     transitivePeerDependencies:
       - supports-color
 
@@ -36183,13 +36189,13 @@ snapshots:
       eslint: 8.55.0
       ignore: 5.3.2
 
-  eslint-plugin-i@2.29.1(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-plugin-i@2.29.1(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint@8.55.0))(eslint@8.55.0))(eslint@8.55.0):
+  eslint-plugin-i@2.29.1(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3)(eslint@8.55.0):
     dependencies:
       debug: 4.4.0(supports-color@8.1.1)
       doctrine: 3.0.0
       eslint: 8.55.0
       eslint-import-resolver-node: 0.3.9
-      eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-plugin-i@2.29.1(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint@8.55.0))(eslint@8.55.0))(eslint@8.55.0)
+      eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.7.5(eslint@8.55.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.55.0)
       get-tsconfig: 4.10.0
       is-glob: 4.0.3
       minimatch: 3.1.2


Reviews:
user: login: Abe27342
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T21:14:10Z
user: login: Abe27342
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T23:39:40Z
user: login: Abe27342
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T23:43:19Z
user: login: Abe27342
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T23:51:13Z
user: login: anthony-murphy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T20:05:56Z
user: login: anthony-murphy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T20:09:00Z
user: login: anthony-murphy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T20:11:54Z
user: login: Abe27342
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T22:24:28Z
user: login: Abe27342
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T22:26:10Z
user: login: anthony-murphy
body: looks good to me. would be good to have @markfields look at the psm changes.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T19:58:33Z
user: login: markfields
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T22:30:19Z
user: login: markfields
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T22:32:53Z
user: login: markfields
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-09T22:34:37Z
user: login: Abe27342
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T22:56:11Z
user: login: Abe27342
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-10T00:15:56Z

Review comments:
diff_hunk: @@ -98,7 +98,7 @@ describe("Local Server Stress with rollback", () => {
 			...[76], //  Rollback op does not match last pending
 			...[84, 88], //  Startpoints of interval  different
 			...[12, 28, 32, 36, 44, 45, 55, 60, 89], //  Number of subDirectories not same
-			...[4, 14, 69, 74], // 0xb86 and 0xb89: exit staging mode logic failing due to id compressor allocation ops
+			...[4, 14, 69, 74, 95], // 0xb86 and 0xb89: exit staging mode logic failing due to id compressor allocation ops
path: packages/test/local-server-stress-tests/src/test/localServerStressOrderSequentially.spec.ts
user: login: Abe27342
body: this seed was passing until I merged the change to use small clusters. I also looked at the minimized JSON and it makes sense to be a duplicate of id-compressor issues and not directly related to this change. Minimized json below:

```json
[
    {
        "type": "DDSModelOp",
        "op": {
            "type": "createSubDirectory",
            "name": "dir3",
            "path": "/"
        },
        "clientTag": "client-1",
        "datastoreTag": "datastore-0",
        "channelTag": "root"
    },
    {
        "type": "DDSModelOp",
        "op": {
            "type": "createSubDirectory",
            "name": "dir2",
            "path": "/dir3"
        },
        "clientTag": "client-1",
        "datastoreTag": "datastore-0",
        "channelTag": "root"
    },
    {
        "type": "DDSModelOp",
        "op": {
            "type": "createSubDirectory",
            "name": "dir2",
            "path": "/dir3/dir2"
        },
        "clientTag": "client-1",
        "datastoreTag": "datastore-0",
        "channelTag": "root"
    },
    {
        "type": "orderSequentially",
        "operations": [
            {
                "type": "DDSModelOp",
                "op": {
                    "type": "createSubDirectory",
                    "name": "dir3",
                    "path": "/dir3/dir2/dir2"
                }
            }
        ],
        "rollback": true,
        "clientTag": "client-1",
        "datastoreTag": "datastore-0",
        "channelTag": "root"
    },
    {
        "type": "createChannel",
        "channelType": "https://graph.microsoft.com/types/directory",
        "tag": "channel-1",
        "clientTag": "client-1",
        "datastoreTag": "datastore-0",
        "channelTag": "root"
    },
    {
        "type": "createChannel",
        "channelType": "https://graph.microsoft.com/types/map",
        "tag": "channel-2",
        "clientTag": "client-1",
        "datastoreTag": "datastore-0",
        "channelTag": "root"
    },
    {
        "type": "DDSModelOp",
        "op": {
            "type": "set",
            "key": "prop2",
            "path": "/dir3/dir2/dir2/dir3",
            "value": {
                "tag": "channel-1",
                "absolutePath": "/A/channel-1",
                "isAttached": true
            }
        },
        "clientTag": "client-1",
        "datastoreTag": "datastore-0",
        "channelTag": "root"
    },
    {
        "type": "attach"
    },
    {
        "type": "DDSModelOp",
        "op": {
            "type": "set",
            "key": "prop3",
            "path": "/",
            "value": {
                "tag": "channel-2",
                "absolutePath": "/A/channel-2",
                "isAttached": true
            }
        },
        "clientTag": "client-1",
        "datastoreTag": "datastore-0",
        "channelTag": "channel-1"
    },
    {
        "type": "enterStagingMode",
        "clientTag": "client-1",
        "datastoreTag": "datastore-0",
        "channelTag": "root"
    },
    {
        "type": "DDSModelOp",
        "op": {
            "type": "setKey",
            "key": "11",
            "value": "1VWZocU"
        },
        "clientTag": "client-1",
        "datastoreTag": "datastore-0",
        "channelTag": "channel-2"
    },
    {
        "type": "createDataStore",
        "asChild": true,
        "tag": "datastore-1",
        "clientTag": "client-1",
        "datastoreTag": "datastore-0",
        "channelTag": "root"
    },
    {
        "type": "exitStagingMode",
        "commit": true,
        "clientTag": "client-1",
        "datastoreTag": "datastore-0",
        "channelTag": "root"
    }
]
```

and when debugging the original test, I can clearly see that the container isn't connected (so before it wasn't going through the replayPendingStates block that is problematic for id-compressor, and now it is)
created_at: 2025-05-05T21:14:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -114,14 +114,20 @@ export type PendingMessageResubmitData = Pick<
 	runtimeOp: LocalContainerRuntimeMessage;
 };
 
+export interface PendingBatchResubmitMetadata extends BatchResubmitInfo {
path: packages/runtime/container-runtime/src/pendingStateManager.ts
user: login: Abe27342
body: it seemed better to bundle all of these arguments together to align with #24516, though it does add a little more churn from my previous PR (sorry!)
created_at: 2025-05-06T23:39:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -688,10 +699,19 @@ export class PendingStateManager implements IDisposable {
 	 * states in its queue. This includes triggering resubmission of unacked ops.
 	 * ! Note: successfully resubmitting an op that has been successfully sequenced is not possible due to checks in the ConnectionStateHandler (Loader layer)
 	 * @param onlyStagedBatches - If true, only replay staged batches. This is used when we are exiting staging mode and want to rebase and submit the staged batches.
+	 * @param squash - If true, edits should be squashed when resubmitting.
+	 * @privateRemarks
+	 * parameter doc above is named. TSDoc doesn't support dot syntax, see https://github.com/microsoft/tsdoc/issues/19
 	 */
-	public replayPendingStates(onlyStagedBatches?: boolean): void {
+	public replayPendingStates(
+		options: {
+			onlyStagedBatches: boolean;
+			squash: boolean;
+		} = defaultReplayPendingStatesOptions,
+	): void {
+		const { onlyStagedBatches, squash } = options;
 		assert(
-			this.stateHandler.connected(),
+			this.stateHandler.connected() || onlyStagedBatches === true,
path: packages/runtime/container-runtime/src/pendingStateManager.ts
user: login: Abe27342
body: this is really the only "fix" that was needed AFAICT to support the "commit staging mode changes while disconnected" case. I audited the general op flow in that case and things looked fine, and didn't see anything troublesome from the local server test cases in this PR. Having a slightly more critical eye on if weakening this assertion could cause us problems would be good though :)
created_at: 2025-05-06T23:43:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -5,12 +5,14 @@
 
 import { strict as assert } from "assert";
 
+import { generatePairwiseOptions } from "@fluid-private/test-pairwise-generator";
path: packages/test/local-server-tests/src/test/stagingMode.spec.ts
user: login: Abe27342
body: I split out most of the changes in this file into #24535 as I was initially thinking the rest of this PR would be trivial, but it's gotten big enough now I want to make it more obvious to reviewer which parts are actually adding coverage :)
created_at: 2025-05-06T23:51:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3377,17 +3381,13 @@ export class ContainerRuntime
 					this.updateDocumentDirtyState(this.pendingMessagesCount !== 0);
 				}
 			}),
-			commitChanges: exitStagingMode(() => {
-				// All staged changes are in the PSM, so just replay them (ignore pre-staging batches)
-				if (this.connected) {
-					this.pendingStateManager.replayPendingStates(true /* onlyStagedBatched */);
-				} else {
-					// TODO:AB#37788: Refactor interplay with pending state manager and staging mode so that even in this case we end up
-					// squashing the ops submitted while in staging mode. This does not happen now due to the following clear without asking
-					// data stores to resubmit.
-					this.pendingStateManager.clearStagingFlags();
-				}
-			}),
+			commitChanges: (options = defaultStagingCommitOptions) =>
path: packages/runtime/container-runtime/src/containerRuntime.ts
user: login: anthony-murphy
body: Right now there is only a single option, so this works, but if we even had more options the behavior could change. i think it would be better to merge these options objects `{...defaultStagingCommitOptions, options}`, so default for unset values don't change if any value is set
created_at: 2025-05-07T20:05:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4585,14 +4585,11 @@ export class ContainerRuntime
 	 */
 	private reSubmitBatch(
 		batch: PendingMessageResubmitData[],
-		batchId: BatchId,
-		{ staged, squash }: { staged: boolean; squash: boolean },
+		{ batchId, staged, squash }: PendingBatchResubmitMetadata,
 	): void {
 		this.batchRunner.run(() => {
 			for (const message of batch) {
-				// Note: once squashing is widely supported, squashing even for orderSequentially should be fine.
-				// See remarks on orderSequentiallyRunner for more details.
-				this.reSubmit(message, this.orderSequentiallyRunner.running ? false : squash);
path: packages/runtime/container-runtime/src/containerRuntime.ts
user: login: anthony-murphy
body: do we even need orderSequentiallyRunner anymore? i would also consider explicting setting squash to false in order sequentially controlled staging mode. it will current inherit the false from the default, but if we change that, order sequentially should probably stay the way it is
created_at: 2025-05-07T20:08:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -215,6 +221,11 @@ function toSerializableForm(
 	};
 }
 
+const defaultReplayPendingStatesOptions = {
path: packages/runtime/container-runtime/src/pendingStateManager.ts
user: login: anthony-murphy
body: probably worth it to add a type here, and use it below
created_at: 2025-05-07T20:11:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4585,14 +4585,11 @@ export class ContainerRuntime
 	 */
 	private reSubmitBatch(
 		batch: PendingMessageResubmitData[],
-		batchId: BatchId,
-		{ staged, squash }: { staged: boolean; squash: boolean },
+		{ batchId, staged, squash }: PendingBatchResubmitMetadata,
 	): void {
 		this.batchRunner.run(() => {
 			for (const message of batch) {
-				// Note: once squashing is widely supported, squashing even for orderSequentially should be fine.
-				// See remarks on orderSequentiallyRunner for more details.
-				this.reSubmit(message, this.orderSequentiallyRunner.running ? false : squash);
path: packages/runtime/container-runtime/src/containerRuntime.ts
user: login: Abe27342
body: correct, we don't. I made that change somewhere but lost it in some merging I guess. Will fix :)
created_at: 2025-05-07T22:24:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3377,17 +3381,13 @@ export class ContainerRuntime
 					this.updateDocumentDirtyState(this.pendingMessagesCount !== 0);
 				}
 			}),
-			commitChanges: exitStagingMode(() => {
-				// All staged changes are in the PSM, so just replay them (ignore pre-staging batches)
-				if (this.connected) {
-					this.pendingStateManager.replayPendingStates(true /* onlyStagedBatched */);
-				} else {
-					// TODO:AB#37788: Refactor interplay with pending state manager and staging mode so that even in this case we end up
-					// squashing the ops submitted while in staging mode. This does not happen now due to the following clear without asking
-					// data stores to resubmit.
-					this.pendingStateManager.clearStagingFlags();
-				}
-			}),
+			commitChanges: (options = defaultStagingCommitOptions) =>
path: packages/runtime/container-runtime/src/containerRuntime.ts
user: login: Abe27342
body: thanks, good catch.
created_at: 2025-05-07T22:26:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -747,7 +770,7 @@ export class PendingStateManager implements IDisposable {
 
 			if (asEmptyBatchLocalOpMetadata(pendingMessage.localOpMetadata)?.emptyBatch === true) {
 				// Resubmit no messages, with the batchId. Will result in another empty batch marker.
-				this.stateHandler.reSubmitBatch([], batchId, { staged, squash: wasStaged });
+				this.stateHandler.reSubmitBatch([], { batchId, staged, squash });
path: packages/runtime/container-runtime/src/pendingStateManager.ts
user: login: markfields
body: So this means you can squash independent of staging mode, right?
created_at: 2025-05-09T22:30:18Z
author_association: MEMBER
diff_hunk: @@ -519,4 +521,66 @@ describe("Staging Mode", () => {
 			"Edit submitted while in staging mode should be committed.",
 		);
 	});
+
+	function spyOn(object: object, methodName: string) {
path: packages/test/local-server-tests/src/test/stagingMode.spec.ts
user: login: markfields
body: Tip: Ask Copilot to reimplement this using Sinon! I'd like to see us using Sinon more and more, it's a good package IMO, but kind of hard to learn, but Copilot knows what to do.
created_at: 2025-05-09T22:32:52Z
author_association: MEMBER
diff_hunk: @@ -747,7 +770,7 @@ export class PendingStateManager implements IDisposable {
 
 			if (asEmptyBatchLocalOpMetadata(pendingMessage.localOpMetadata)?.emptyBatch === true) {
 				// Resubmit no messages, with the batchId. Will result in another empty batch marker.
-				this.stateHandler.reSubmitBatch([], batchId, { staged, squash: wasStaged });
+				this.stateHandler.reSubmitBatch([], { batchId, staged, squash });
path: packages/runtime/container-runtime/src/pendingStateManager.ts
user: login: Abe27342
body: yep. We don't use that anywhere right now but as discussed offline I can add a unit test for that behavior in a follow-up.
created_at: 2025-05-09T22:56:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -519,4 +521,66 @@ describe("Staging Mode", () => {
 			"Edit submitted while in staging mode should be committed.",
 		);
 	});
+
+	function spyOn(object: object, methodName: string) {
path: packages/test/local-server-tests/src/test/stagingMode.spec.ts
user: login: Abe27342
body: Yeah, using sinon given we already have usage in our repo makes sense. #24574 for that.

I'm hesitant to over-rely on it: ideally most of the time your code/APIs are structured so that you don't need to heavily rely on glass-box testing which is frequently the type of thing it's used for (i.e. what I'm doing here). But it's really nice in a pinch
created_at: 2025-05-10T00:15:56Z
author_association: CONTRIBUTOR

PR comments:
user: login: Abe27342
created_at: 2025-05-05T19:01:26Z
body: leaving this as a draft until #24493 goes in
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-08T16:23:09Z
body: 🔗 No broken links found! ✅

Your attention to detail is admirable.

#### linkcheck output

```

> fluid-framework-docs-site@0.0.0 ci:check-links /home/runner/work/FluidFramework/FluidFramework/docs
> start-server-and-test "npm run serve -- --no-open" 3000 check-links

1: starting server using command "npm run serve -- --no-open"
and when url "[ 'http://127.0.0.1:3000' ]" is responding with HTTP status code 200
running tests using command "npm run check-links"


> fluid-framework-docs-site@0.0.0 serve
> docusaurus serve --no-open

[SUCCESS] Serving "build" directory at: http://localhost:3000/

> fluid-framework-docs-site@0.0.0 check-links
> linkcheck http://localhost:3000 --skip-file skipped-urls.txt

Crawling...

Stats:
  195689 links
    1565 destination URLs
    1797 URLs ignored
       0 warnings
       0 errors


```

<!-- Sticky Pull Request Commentlinkreport -->
author_association: CONTRIBUTOR