authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 17:10:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 17:10:52-07:00
log252df7742047af8a1b859dc652b2f40b639d16b3
treeef4367e7292b1bdc1f22858d167b8e9f68d02276
parent5b0c923ea3413c038f32ac4c523ae96863443f9f
signature Signed by PGP key B5690EEEBB952194

perf(web): keep the sidebar responsive during bulk thread updates (#10413)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

5 files changed, 184 insertions(+), 38 deletions(-)

apps/web/src/components/Sidebar.motion.test.ts+25
......@@ -358,6 +358,31 @@ describe("sidebar list motion", () => {
358358 expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce();
359359 });
360360
361 it("skips fades when a large list change would clone too many rows", () => {
362 const rows = Array.from({ length: 41 }, (_, index) => new TestRow(`row-${index}`));
363 const { motion, layout, parent } = fixture(rows);
364 motion.update(true);
365 layout([]);
366 motion.update(true);
367 expect(rows.every((row) => row.clones.length === 0)).toBe(true);
368 expect(parent.children).toHaveLength(0);
369 const entering = Array.from({ length: 41 }, (_, index) => new TestRow(`new-${index}`));
370 layout(entering);
371 motion.update(true);
372 expect(entering.every((row) => row.animate.mock.calls.length === 0)).toBe(true);
373 });
374
375 it("still slides rows when one removal displaces a large list", () => {
376 const rows = Array.from({ length: 60 }, (_, index) => new TestRow(`row-${index}`));
377 const { motion, layout } = fixture(rows);
378 motion.update(true);
379 const [removed, ...rest] = rows;
380 layout(rest);
381 motion.update(true);
382 expect(removed!.clones).toHaveLength(1);
383 expect(rest.every((row) => row.animations.length === 1)).toBe(true);
384 });
385
361386 it("respects reduced motion while keeping the next baseline fresh", () => {
362387 const a = new TestRow("a");
363388 const b = new TestRow("b");
apps/web/src/components/Sidebar.motion.ts+20-1
......@@ -1,4 +1,10 @@
11const motionTiming = { duration: 150, easing: "ease-out" };
2// A project filter change or a bulk snooze swaps a large part of the list at
3// once. Fades are the expensive part: every removed row gets a deep clone and
4// every clone and entering row gets its own animation, and the layout reads
5// in between force synchronous reflows. Translating displaced rows is cheap,
6// so only the fade count decides whether an update animates.
7const MAX_FADED_ROWS_PER_UPDATE = 40;
28
39type RowPosition = { top: number; left: number; width: number; height: number };
410
......@@ -124,7 +130,20 @@ export function createSidebarListMotion(parent: HTMLUListElement) {
124130 },
125131 ]),
126132 );
127 const shouldAnimate = animate && positions !== null && !reducedMotion?.matches;
133 let fadeCount = 0;
134 if (positions !== null) {
135 for (const [node, position] of positions) {
136 if (!next.has(node) && position.height > 0) fadeCount++;
137 }
138 for (const [node, position] of next) {
139 if (!positions.has(node) && position.height > 0) fadeCount++;
140 }
141 }
142 const shouldAnimate =
143 animate &&
144 positions !== null &&
145 !reducedMotion?.matches &&
146 fadeCount <= MAX_FADED_ROWS_PER_UPDATE;
128147 if (!shouldAnimate) clearFades();
129148 else {
130149 for (const [node, position] of positions!) {
apps/web/src/components/Sidebar.tsx+7-1
......@@ -502,7 +502,13 @@ function SortableThreadRow(props: {
502502 disabled: { draggable: props.disabled },
503503 animateLayoutChanges: animateSidebarLayoutChanges,
504504 });
505 return props.children({ listeners, setNodeRef, transform, transition, isDragging });
505 // dnd-kit memoizes each field but not the bag, so the memoized row would
506 // rerender on every shell update without this.
507 const bag = useMemo(
508 () => ({ listeners, setNodeRef, transform, transition, isDragging }),
509 [listeners, setNodeRef, transform, transition, isDragging],
510 );
511 return props.children(bag);
506512}
507513
508514// Unsent work shares one look: the new-thread draft rows and thread rows
packages/client-runtime/src/state/shell-sync.test.ts+89
......@@ -6,6 +6,7 @@ import {
66} from "@t3tools/contracts";
77import { describe, expect, it } from "@effect/vitest";
88import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
910import * as Option from "effect/Option";
1011import * as Queue from "effect/Queue";
1112import * as Ref from "effect/Ref";
......@@ -151,6 +152,94 @@ describe("environment shell synchronization", () => {
151152 }),
152153 );
153154
155 it.live.each([
156 { bufferSize: Infinity, expectedSequences: [51] },
157 // RpcClient defaults to a 16-event buffer, which splits larger server chunks.
158 { bufferSize: 16, expectedSequences: [17, 33, 49, 51] },
159 ])("batches live events with a $bufferSize event buffer", ({ bufferSize, expectedSequences }) =>
160 Effect.gen(function* () {
161 const events = yield* Queue.bounded<OrchestrationShellStreamItem>(bufferSize);
162 const client = {
163 [ORCHESTRATION_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events),
164 } as unknown as WsRpcProtocolClient;
165 const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE);
166 const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(
167 Option.some(session(client)),
168 );
169 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
170 target: TARGET,
171 state: supervisorState,
172 session: activeSession,
173 prepared: yield* SubscriptionRef.make(Option.some(PREPARED)),
174 connect: Effect.void,
175 disconnect: Effect.void,
176 retryNow: Effect.void,
177 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
178 const cache = Persistence.EnvironmentCacheStore.of({
179 loadShell: () => Effect.succeed(Option.none()),
180 saveShell: () => Effect.void,
181 loadThread: () => Effect.succeed(Option.none()),
182 saveThread: () => Effect.void,
183 removeThread: () => Effect.void,
184 loadServerConfig: () => Effect.succeed(Option.none()),
185 saveServerConfig: () => Effect.void,
186 loadVcsRefs: () => Effect.succeed(Option.none()),
187 saveVcsRefs: () => Effect.void,
188 removeVcsRefs: () => Effect.void,
189 clearVcsRefs: () => Effect.void,
190 clear: () => Effect.void,
191 });
192 const shellState = yield* makeEnvironmentShellState().pipe(
193 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
194 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
195 Effect.provideService(
196 ShellSnapshotLoader,
197 ShellSnapshotLoader.of({ load: () => Effect.succeed(Option.none()) }),
198 ),
199 );
200 yield* SubscriptionRef.set(supervisorState, {
201 desired: true,
202 network: "online",
203 phase: "connected",
204 stage: null,
205 attempt: 1,
206 generation: 1,
207 lastFailure: null,
208 retryAt: null,
209 });
210 yield* Queue.offer(events, { kind: "snapshot", snapshot: LIVE_SHELL_SNAPSHOT });
211 yield* Queue.offer(events, { kind: "synchronized" });
212 yield* SubscriptionRef.changes(shellState).pipe(
213 Stream.filter((state) => state.status === "live"),
214 Stream.runHead,
215 );
216
217 // Observe before publishing so no batch can arrive before the subscription.
218 const observed = yield* SubscriptionRef.changes(shellState).pipe(
219 Stream.drop(1),
220 Stream.takeUntil(
221 (state) => Option.isSome(state.snapshot) && state.snapshot.value.threads.length === 50,
222 ),
223 Stream.runCollect,
224 Effect.forkScoped({ startImmediately: true }),
225 );
226 yield* Queue.offerAll(
227 events,
228 Array.from({ length: 50 }, (_, index) => ({
229 kind: "thread-upserted" as const,
230 sequence: 2 + index,
231 thread: { id: `thread-${index}` } as never,
232 })),
233 );
234 const states = yield* Fiber.join(observed);
235 const snapshots = states.map((state) => Option.getOrThrow(state.snapshot));
236 expect(snapshots.map((snapshot) => snapshot.snapshotSequence)).toEqual(expectedSequences);
237 expect(snapshots.at(-1)!.threads.map((thread) => thread.id)).toEqual(
238 Array.from({ length: 50 }, (_, index) => `thread-${index}`),
239 );
240 }).pipe(Effect.scoped),
241 );
242
154243 it.effect("requests a full socket snapshot when the HTTP refresh fails", () =>
155244 Effect.gen(function* () {
156245 const cachedSnapshot: OrchestrationShellSnapshot = {
packages/client-runtime/src/state/shell.ts+43-36
......@@ -135,47 +135,54 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
135135 ),
136136 );
137137
138 const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* (
139 item: OrchestrationShellStreamItem,
138 // Apply each received batch with one state write. The RPC client's bounded
139 // buffer can split a server chunk, so a bulk action can still need several
140 // writes, but each write includes every event in that batch.
141 const applyItems = Effect.fn("EnvironmentShellState.applyItems")(function* (
142 items: ReadonlyArray<OrchestrationShellStreamItem>,
140143 ) {
141 if (item.kind === "synchronized") {
142 yield* Ref.set(awaitingCompletion, false);
143 yield* SubscriptionRef.update(state, (current) =>
144 Option.isSome(current.snapshot)
145 ? { ...current, status: "live" as const, error: Option.none() }
146 : current,
147 );
148 return;
149 }
150
151 const current = yield* SubscriptionRef.get(state);
152 const nextSnapshot =
153 item.kind === "snapshot"
154 ? item.snapshot
155 : Option.match(current.snapshot, {
156 onNone: () => null,
157 onSome: (snapshot) =>
158 item.sequence > snapshot.snapshotSequence
159 ? applyShellStreamEvent(snapshot, item)
160 : snapshot,
161 });
162 if (nextSnapshot === null) {
163 return;
144 const initial = yield* SubscriptionRef.get(state);
145 let waiting = yield* Ref.get(awaitingCompletion);
146 let next = initial;
147 let receivedSnapshot = false;
148 for (const item of items) {
149 if (item.kind === "synchronized") {
150 waiting = false;
151 if (Option.isSome(next.snapshot)) {
152 next = { ...next, status: "live", error: Option.none() };
153 }
154 continue;
155 }
156 const nextSnapshot =
157 item.kind === "snapshot"
158 ? item.snapshot
159 : Option.match(next.snapshot, {
160 onNone: () => null,
161 onSome: (snapshot) =>
162 item.sequence > snapshot.snapshotSequence
163 ? applyShellStreamEvent(snapshot, item)
164 : snapshot,
165 });
166 if (nextSnapshot === null) continue;
167 receivedSnapshot ||= item.kind === "snapshot";
168 next = {
169 snapshot: Option.some(nextSnapshot),
170 status: waiting ? "synchronizing" : "live",
171 error: Option.none(),
172 };
164173 }
165
166 const waiting = yield* Ref.get(awaitingCompletion);
167 yield* SubscriptionRef.set(state, {
168 snapshot: Option.some(nextSnapshot),
169 status: waiting ? "synchronizing" : "live",
170 error: Option.none(),
171 });
172 if (item.kind === "snapshot") {
174 yield* Ref.set(awaitingCompletion, waiting);
175 if (next === initial) return;
176 yield* SubscriptionRef.set(state, next);
177 if (receivedSnapshot) {
173178 const session = yield* Ref.get(activeSubscriptionSession);
174179 if (session !== null) {
175180 yield* Ref.set(lastAuthoritativeSession, session);
176181 }
177182 }
178 yield* Queue.offer(persistence, nextSnapshot);
183 if (next.snapshot !== initial.snapshot && Option.isSome(next.snapshot)) {
184 yield* Queue.offer(persistence, next.snapshot.value);
185 }
179186 });
180187
181188 const foregroundResubscriptions = Option.match(wakeups, {
......@@ -220,7 +227,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
220227 );
221228 const httpSnapshot = yield* snapshotLoader.load(prepared);
222229 if (Option.isSome(httpSnapshot)) {
223 yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value });
230 yield* applyItems([{ kind: "snapshot", snapshot: httpSnapshot.value }]);
224231 canResume = true;
225232 current = yield* SubscriptionRef.get(state);
226233 }
......@@ -250,7 +257,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
250257 retryExpectedFailureAfter: "250 millis",
251258 resubscribe: foregroundResubscriptions,
252259 },
253 ).pipe(Stream.runForEach(applyItem)),
260 ).pipe(Stream.runForEachArray(applyItems)),
254261 );
255262 yield* SubscriptionRef.changes(supervisor.state).pipe(
256263 Stream.runForEach((connectionState) => {