Skip to content

Commit 59c355f

Browse files
SawyerHoodclaude
andcommitted
Share plugin sidebar DTO cache across consumers, provide route navigation in Ladle
- plugin-sidebar-hooks: derive the host-name map per hosts payload at module level so two useSidebarThreads callers keep the same WeakMap DTO entries instead of evicting each other's; test covers two consumers. - plugin-slots: batch closer never drives the depth below zero after a test reset. - Ladle global provider mounts RouteNavigationProvider so sidebar/thread stories that now navigate through useRouteNavigate do not throw on click. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 2cfb419 commit 59c355f

4 files changed

Lines changed: 72 additions & 24 deletions

File tree

apps/app/.ladle/components.tsx

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { WorkerPoolContextProvider } from "@pierre/diffs/react";
66
import { Provider as JotaiProvider, createStore } from "jotai";
77
import { MemoryRouter } from "react-router-dom";
88
import { AppToaster } from "../src/components/AppToaster";
9+
import { RouteNavigationProvider } from "../src/components/ui/app-route-anchor";
910
import { TooltipProvider } from "@bb/shared-ui/tooltip";
1011
import { setPreferredTheme } from "../src/hooks/useTheme";
1112
import {
@@ -57,24 +58,28 @@ export const Provider: GlobalProvider = ({ globalState, children }) => {
5758

5859
return (
5960
<MemoryRouter initialEntries={["/"]}>
60-
<JotaiProvider store={store}>
61-
<QueryClientProvider client={queryClient}>
62-
<WorkerPoolContextProvider
63-
poolOptions={{
64-
workerFactory: createDiffWorker,
65-
poolSize: getDiffWorkerPoolSize(),
66-
}}
67-
highlighterOptions={{}}
68-
>
69-
<TooltipProvider delayDuration={300} disableHoverableContent>
70-
<div className="min-h-screen text-foreground">
71-
{children}
72-
<AppToaster position="bottom-right" />
73-
</div>
74-
</TooltipProvider>
75-
</WorkerPoolContextProvider>
76-
</QueryClientProvider>
77-
</JotaiProvider>
61+
{/* Sidebar rows, thread actions and the fork handler navigate through
62+
useRouteNavigate, which throws at the click without this provider. */}
63+
<RouteNavigationProvider>
64+
<JotaiProvider store={store}>
65+
<QueryClientProvider client={queryClient}>
66+
<WorkerPoolContextProvider
67+
poolOptions={{
68+
workerFactory: createDiffWorker,
69+
poolSize: getDiffWorkerPoolSize(),
70+
}}
71+
highlighterOptions={{}}
72+
>
73+
<TooltipProvider delayDuration={300} disableHoverableContent>
74+
<div className="min-h-screen text-foreground">
75+
{children}
76+
<AppToaster position="bottom-right" />
77+
</div>
78+
</TooltipProvider>
79+
</WorkerPoolContextProvider>
80+
</QueryClientProvider>
81+
</JotaiProvider>
82+
</RouteNavigationProvider>
7883
</MemoryRouter>
7984
);
8085
};

apps/app/src/lib/plugin-sidebar-hooks.test.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,22 @@ describe("useSidebarThreads", () => {
7070
expect(after[1]).not.toBe(before[1]);
7171
expect(after[1]?.title).toBe("Two");
7272
});
73+
74+
it("shares DTO identity between two consumers of the same payload", () => {
75+
const stable = makeThreadListEntry({ id: "thr_stable", title: "Stable" });
76+
state.data = payload([stable]);
77+
const first = renderHook(() => useSidebarThreads());
78+
const second = renderHook(() => useSidebarThreads());
79+
// Two plugin lists mounted at once (or one list plus the built-in
80+
// sidebar's plugin surfaces): each derives the host-name map from the
81+
// same hosts payload, so they must not evict each other's cached DTOs.
82+
expect(second.result.current.threads[0]).toBe(
83+
first.result.current.threads[0],
84+
);
85+
const before = first.result.current.threads[0];
86+
first.rerender();
87+
second.rerender();
88+
expect(first.result.current.threads[0]).toBe(before);
89+
expect(second.result.current.threads[0]).toBe(before);
90+
});
7391
});

apps/app/src/lib/plugin-sidebar-hooks.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { useCallback, useMemo } from "react";
22
import { useStore } from "jotai";
3-
import { PERSONAL_PROJECT_ID, type ThreadListEntry } from "@bb/domain";
3+
import {
4+
PERSONAL_PROJECT_ID,
5+
type Host,
6+
type ThreadListEntry,
7+
} from "@bb/domain";
48
import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport";
59
import type {
610
PluginSidebarProject,
@@ -31,6 +35,29 @@ import {
3135
const EMPTY_THREADS: readonly PluginSidebarThread[] = [];
3236
const EMPTY_PROJECTS: readonly PluginSidebarProject[] = [];
3337
const EMPTY_ENTRIES: ReadonlyMap<string, ThreadListEntry> = new Map();
38+
const EMPTY_HOST_NAMES: ReadonlyMap<string, string> = new Map();
39+
40+
/**
41+
* Host-name map per hosts payload. Module-level (not `useMemo`) so every
42+
* `useSidebarThreads` caller derives the same map object from the same React
43+
* Query result; a per-hook map would give two plugin lists two keys and make
44+
* them evict each other's entries from {@link pluginSidebarThreadByEntry}.
45+
*/
46+
const hostNamesByHosts = new WeakMap<
47+
readonly Host[],
48+
ReadonlyMap<string, string>
49+
>();
50+
51+
function hostNamesFor(
52+
hosts: readonly Host[] | undefined,
53+
): ReadonlyMap<string, string> {
54+
if (hosts === undefined) return EMPTY_HOST_NAMES;
55+
const cached = hostNamesByHosts.get(hosts);
56+
if (cached !== undefined) return cached;
57+
const names = new Map(hosts.map((host) => [host.id, host.name] as const));
58+
hostNamesByHosts.set(hosts, names);
59+
return names;
60+
}
3461

3562
/**
3663
* Per-entry DTO memo. React Query structurally shares the sidebar payload, so
@@ -74,10 +101,7 @@ export function useSidebarThreads(): PluginSidebarThreadsState {
74101
// The sidebar already subscribes to host updates; this reads the same
75102
// cached list so a row can print a machine name instead of a host id.
76103
const { data: hosts } = useHosts();
77-
const hostNamesById = useMemo(
78-
() => new Map((hosts ?? []).map((host) => [host.id, host.name] as const)),
79-
[hosts],
80-
);
104+
const hostNamesById = hostNamesFor(hosts);
81105

82106
return useMemo<PluginSidebarThreadsState>(() => {
83107
if (data === undefined) {

apps/app/src/lib/plugin-slots.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,8 @@ export function beginPluginSlotBatch(options: {
336336
return () => {
337337
if (closed) return;
338338
closed = true;
339-
openBatchDepth -= 1;
339+
// Never below zero: a test reset can zero the depth under an open batch.
340+
openBatchDepth = Math.max(0, openBatchDepth - 1);
340341
if (openBatchDepth === 0) flushChange();
341342
};
342343
}

0 commit comments

Comments
 (0)