e9d5d493d3
F1 (MEDIUM regression): a collapsed-but-cached branch showed STALE children on re-expand after reload (the cache keeps children of any ever-expanded branch; refreshOpenBranches only refreshes OPEN branches, but the fetch guard skips a branch that has cached children). New pruneCollapsedChildren(tree, openIds) resets children to [] (keeps hasChildren) for every node NOT in the persisted open-set, recursing into open nodes — a once-per-mount boot effect. A pruned collapsed branch is then the 'unloaded' shape handleToggle re-fetches, so its first expand reconciles fresh (as pre-cache). Open branches keep their children (refreshOpenBranches handles them, no double fetch). Test: a collapsed cached branch with a stale child fetches fresh on first expand after boot. F2: gate the >4MB size-guard console.warn behind the writeFailureWarned once-flag (like the quota branch) so editing a huge tree no longer re-warns every ~500ms; test that an oversized tree is not persisted + warns exactly once. F3: narrow the use-auth privacy comment (only tree caches are swept; other localStorage entries remain). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
223 lines
7.3 KiB
TypeScript
223 lines
7.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { createRef } from "react";
|
|
import { render, act, waitFor, cleanup } from "@testing-library/react";
|
|
|
|
// --- Mocks for the heavy / networked module graph ---------------------------
|
|
// Same isolation strategy as space-tree.expand-all.test.tsx: everything that
|
|
// would otherwise need a real server / router / DnD stack is mocked. Here we
|
|
// additionally CAPTURE the DocTree props (onToggle + data) so the test can
|
|
// drive a lazy-load expand exactly as a row click would, and we control
|
|
// fetchAllAncestorChildren to assert the fresh fetch happens.
|
|
|
|
const fetchAllAncestorChildrenMock = vi.fn();
|
|
|
|
// Holder mutated by the DocTree stub each render so the test can read the
|
|
// latest tree it was handed and invoke its onToggle callback.
|
|
const docTree: {
|
|
onToggle?: (id: string, isOpen: boolean) => void | Promise<void>;
|
|
data: unknown[];
|
|
} = { data: [] };
|
|
|
|
vi.mock("@/features/page/services/page-service.ts", () => ({
|
|
getSpaceTree: vi.fn(),
|
|
getPageBreadcrumbs: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
|
// No root pages and no further pages — the server data-load effect stays
|
|
// inert (isDataLoaded never flips), so refreshOpenBranches never runs and the
|
|
// test exercises ONLY the boot-prune + handleToggle lazy-load path against
|
|
// the hydrated cache we seed into the atom below.
|
|
useGetRootSidebarPagesQuery: () => ({
|
|
data: undefined,
|
|
hasNextPage: false,
|
|
fetchNextPage: vi.fn(),
|
|
isFetching: false,
|
|
}),
|
|
usePageQuery: () => ({ data: undefined }),
|
|
fetchAllAncestorChildren: (...args: unknown[]) =>
|
|
fetchAllAncestorChildrenMock(...args),
|
|
}));
|
|
|
|
vi.mock("@/features/page/tree/hooks/use-tree-mutation.ts", () => ({
|
|
useTreeMutation: () => ({ handleMove: vi.fn() }),
|
|
}));
|
|
|
|
vi.mock("@mantine/notifications", () => ({
|
|
notifications: { show: vi.fn() },
|
|
}));
|
|
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({ t: (key: string) => key }),
|
|
}));
|
|
|
|
vi.mock("react-router-dom", () => ({
|
|
useParams: () => ({ pageSlug: undefined }),
|
|
}));
|
|
|
|
vi.mock("@/lib", () => ({
|
|
extractPageSlugId: () => undefined,
|
|
}));
|
|
|
|
vi.mock("@/lib/config.ts", () => ({
|
|
isCompactPageTreeEnabled: () => false,
|
|
}));
|
|
|
|
// Capture the props DocTree is rendered with instead of rendering anything.
|
|
vi.mock("./doc-tree", () => ({
|
|
DocTree: (props: { onToggle: (id: string, isOpen: boolean) => void; data: unknown[] }) => {
|
|
docTree.onToggle = props.onToggle;
|
|
docTree.data = props.data;
|
|
return null;
|
|
},
|
|
ROW_HEIGHT_COMPACT: 28,
|
|
ROW_HEIGHT_STANDARD: 32,
|
|
}));
|
|
vi.mock("./space-tree-row", () => ({
|
|
SpaceTreeRow: () => null,
|
|
}));
|
|
|
|
vi.mock("@mantine/core", () => ({
|
|
Text: ({ children }: { children?: unknown }) => children ?? null,
|
|
}));
|
|
|
|
// In-memory open-map (the real one is localStorage-backed and crashes under the
|
|
// jsdom shim). Empty at start of each test -> every branch is COLLAPSED, which
|
|
// is exactly the state we need to prove the boot-prune. `scopeKeyAtom` is
|
|
// re-exported because the persisted tree-data atom resolves its scope through it.
|
|
vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
|
|
const { atom } = await import("jotai");
|
|
type OpenMap = Record<string, boolean>;
|
|
const base = atom<OpenMap>({});
|
|
const openTreeNodesAtom = atom(
|
|
(get) => get(base),
|
|
(get, set, update: OpenMap | ((prev: OpenMap) => OpenMap)) => {
|
|
const next =
|
|
typeof update === "function"
|
|
? (update as (prev: OpenMap) => OpenMap)(get(base))
|
|
: update;
|
|
set(base, next);
|
|
},
|
|
);
|
|
const scopeKeyAtom = atom(() => "test-workspace:test-user");
|
|
return { openTreeNodesAtom, scopeKeyAtom };
|
|
});
|
|
|
|
import SpaceTree, { SpaceTreeApi } from "./space-tree";
|
|
import {
|
|
treeDataAtom,
|
|
flushPendingTreeDataWrites,
|
|
} from "@/features/page/tree/atoms/tree-data-atom.ts";
|
|
import { createStore, Provider } from "jotai";
|
|
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
|
|
|
// The scopeKeyAtom mock resolves to this fixed scope, so the persisted
|
|
// tree-data atom hydrates from exactly this localStorage key at mount
|
|
// (getOnInit + atomWithStorage's onMount both read it).
|
|
const CACHE_KEY = "treeData:v1:test-workspace:test-user";
|
|
|
|
function child(
|
|
id: string,
|
|
parentPageId: string,
|
|
hasChildren = false,
|
|
): SpaceTreeNode {
|
|
return {
|
|
id,
|
|
slugId: `slug-${id}`,
|
|
name: id,
|
|
position: "a0",
|
|
spaceId: "space-1",
|
|
parentPageId,
|
|
hasChildren,
|
|
children: [],
|
|
};
|
|
}
|
|
|
|
// A hydrated boot cache: a COLLAPSED branch (not in the open-map) that still
|
|
// carries a stale cached child — the exact shape a previous session left behind
|
|
// after the branch was expanded then collapsed then persisted.
|
|
function cachedTreeWithCollapsedBranch(): SpaceTreeNode[] {
|
|
return [
|
|
{
|
|
id: "branch",
|
|
slugId: "slug-branch",
|
|
name: "branch",
|
|
position: "a0",
|
|
spaceId: "space-1",
|
|
parentPageId: null as unknown as string,
|
|
hasChildren: true,
|
|
children: [child("stale", "branch")],
|
|
},
|
|
];
|
|
}
|
|
|
|
beforeEach(() => {
|
|
fetchAllAncestorChildrenMock.mockReset();
|
|
docTree.onToggle = undefined;
|
|
docTree.data = [];
|
|
// Flush any pending debounced write from a previous test before clearing.
|
|
flushPendingTreeDataWrites();
|
|
try {
|
|
localStorage.clear?.();
|
|
} catch {
|
|
/* fresh store per test isolates state */
|
|
}
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
describe("SpaceTree boot-cache prune (#159 #8 stale collapsed children)", () => {
|
|
it("drops a collapsed cached branch's children on boot and fetches fresh on first expand", async () => {
|
|
// Server returns FRESH children on the lazy-load: the stale cached child is
|
|
// gone, a renamed/new one takes its place.
|
|
fetchAllAncestorChildrenMock.mockResolvedValue([child("fresh", "branch")]);
|
|
|
|
// Simulate the localStorage-hydrated boot cache: seed the persisted key
|
|
// BEFORE mount so the atom hydrates it (store.set would be clobbered by
|
|
// atomWithStorage's onMount re-reading storage — this is the real path).
|
|
localStorage.setItem(
|
|
CACHE_KEY,
|
|
JSON.stringify(cachedTreeWithCollapsedBranch()),
|
|
);
|
|
|
|
const store = createStore();
|
|
const ref = createRef<SpaceTreeApi>();
|
|
render(
|
|
<Provider store={store}>
|
|
<SpaceTree ref={ref} spaceId="space-1" readOnly={false} />
|
|
</Provider>,
|
|
);
|
|
|
|
// Boot-prune ran at mount: the COLLAPSED branch's cached children were
|
|
// dropped to the unloaded shape ([]), so the stale child is no longer there.
|
|
const branchAfterBoot = docTree.data.find(
|
|
(n) => (n as SpaceTreeNode).id === "branch",
|
|
) as SpaceTreeNode;
|
|
expect(branchAfterBoot.children).toEqual([]);
|
|
expect(branchAfterBoot.hasChildren).toBe(true);
|
|
|
|
// First expand of the collapsed branch after boot must lazy-load fresh
|
|
// children (before this fix the cached children were kept and the fetch
|
|
// was skipped, showing stale data).
|
|
await act(async () => {
|
|
await docTree.onToggle!("branch", true);
|
|
});
|
|
|
|
expect(fetchAllAncestorChildrenMock).toHaveBeenCalledTimes(1);
|
|
expect(fetchAllAncestorChildrenMock).toHaveBeenCalledWith({
|
|
pageId: "branch",
|
|
spaceId: "space-1",
|
|
});
|
|
|
|
// The fresh children replaced the stale cache in the live tree.
|
|
await waitFor(() => {
|
|
const branch = store
|
|
.get(treeDataAtom)
|
|
.find((n) => n.id === "branch")!;
|
|
expect(branch.children.map((c) => c.id)).toEqual(["fresh"]);
|
|
});
|
|
});
|
|
});
|