Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
146 changes: 146 additions & 0 deletions apps/web/src/components/files/useDirectoryEntries.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { EnvironmentId, type ProjectEntry } from "@t3tools/contracts";
import { act, useEffect } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";

Check failure on line 3 in apps/web/src/components/files/useDirectoryEntries.test.tsx

View workflow job for this annotation

GitHub Actions / Test Web

[unit] src/components/files/useDirectoryEntries.test.tsx

Error: Cannot find package 'react-test-renderer' imported from /home/runner/_work/t3code/t3code/apps/web/src/components/files/useDirectoryEntries.test.tsx ❯ reviveInvokeError ../../node_modules/.pnpm/@voidzero-dev+vite-plus-core@1.0.0_@types+node@24.12.4_esbuild@0.28.2_jiti@2.7.0_terser_128cd438c7eeb8a6a649ebd7f051e315/node_modules/@voidzero-dev/vite-plus-core/dist/vite/node/module-runner.js:542:14 ❯ Object.invoke ../../node_modules/.pnpm/@voidzero-dev+vite-plus-core@1.0.0_@types+node@24.12.4_esbuild@0.28.2_jiti@2.7.0_terser_128cd438c7eeb8a6a649ebd7f051e315/node_modules/@voidzero-dev/vite-plus-core/dist/vite/node/module-runner.js:558:33 ❯ VitestModuleRunner.getModuleInformation ../../node_modules/.pnpm/@voidzero-dev+vite-plus-core@1.0.0_@types+node@24.12.4_esbuild@0.28.2_jiti@2.7.0_terser_128cd438c7eeb8a6a649ebd7f051e315/node_modules/@voidzero-dev/vite-plus-core/dist/vite/node/module-runner.js:1212:7 ❯ request ../../node_modules/.pnpm/@voidzero-dev+vite-plus-core@1.0.0_@types+node@24.12.4_esbuild@0.28.2_jiti@2.7.0_terser_128cd438c7eeb8a6a649ebd7f051e315/node_modules/@voidzero-dev/vite-plus-core/dist/vite/node/module-runner.js:1229:83 ❯ src/components/files/useDirectoryEntries.test.tsx:3:1 ❯ VitestModuleRunner.directRequest ../../node_modules/.pnpm/@voidzero-dev+vite-plus-core@1.0.0_@types+node@24.12.4_esbuild@0.28.2_jiti@2.7.0_terser_128cd438c7eeb8a6a649ebd7f051e315/node_modules/@voidzero-dev/vite-plus-core/dist/vite/node/module-runner.js:1272:59 ❯ VitestModuleRunner.cachedRequest ../../node_modules/.pnpm/@voidzero-dev+vite-plus-core@1.0.0_@types+node@24.12.4_esbuild@0.28.2_jiti@2.7.0_terser_128cd438c7eeb8a6a649ebd7f051e315/node_modules/@voidzero-dev/vite-plus-core/dist/vite/node/module-runner.js:1179:73 ❯ VitestModuleRunner.import ../../node_modules/.pnpm/@voidzero-dev+vite-plus-core@1.0.0_@types+node@24.12.4_esbuild@0.28.2_jiti@2.7.0_terser_128cd438c7eeb8a6a649ebd7f051e315/node_modules/@voidzero-dev/vite-plus-core/dist/vite/node/module-runner.js:1134:10 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND' }
import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";

const { listEntries } = vi.hoisted(() => ({ listEntries: vi.fn() }));
vi.mock("~/state/projects", async () => {
const { Atom } = await import("effect/reactivity");
const Effect = await import("effect/Effect");
return {
projectEnvironment: {
listEntries: (request: unknown) => Atom.make(Effect.promise(() => listEntries(request))),
},
};
});

import { useDirectoryEntries } from "./useDirectoryEntries";

let renderer: ReactTestRenderer | null;
let view: ReturnType<typeof useDirectoryEntries>;
let calls: Array<{ path: string; resolve: (entries: readonly ProjectEntry[]) => void }>;
let activeQueries: number;
let peakQueries: number;

function Probe() {
const result = useDirectoryEntries(EnvironmentId.make("files-test"), "/workspace");
useEffect(() => {
view = result;
});
return null;
}

async function mount() {
await act(async () => {
renderer = create(<Probe />);
});
}

async function settle(index: number, entries: readonly ProjectEntry[] = []) {
await act(async () => calls[index]!.resolve(entries));
}

beforeEach(() => {
renderer = null;
calls = [];
activeQueries = 0;
peakQueries = 0;
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
listEntries.mockReset().mockImplementation((request) => {
activeQueries++;
peakQueries = Math.max(peakQueries, activeQueries);
return new Promise((resolve) => {
calls.push({
path: request.input.directoryPath,
resolve: (entries) => {
activeQueries--;
resolve({ entries });
},
});
});
});
});

afterEach(async () => {
await act(async () => renderer?.unmount());
vi.unstubAllGlobals();
});

it.each([1, 2, 3, 4, 5])("coalesces 20 overlapping refreshes (sample %s)", async () => {
await mount();
await settle(0);
await act(async () => {
for (let index = 0; index < 20; index++) view.refresh();
});
for (let index = 1; index < calls.length; index++) await settle(index);
console.log(`20 overlapping refreshes: ${calls.length - 1} root listing requests`);
expect(calls).toHaveLength(3); // Initial load, active refresh, one final rescan.
expect(view.isPending).toBe(false);
});

it("rescans mutations during both the initial listing and the follow-up", async () => {
await mount();
await act(async () => view.refresh());
await settle(0);
expect(calls).toHaveLength(2);
await act(async () => {
view.refresh();
view.refresh();
});
await settle(1);
expect(calls).toHaveLength(3);
const entry = { path: "latest.txt", kind: "file" } as const;
await settle(2, [entry]);
expect(view.entries).toEqual([entry]);
expect(view.isPending).toBe(false);
});

it("shares the follow-up promise and does not resolve callers before the final listing", async () => {
await mount();
let completed = false;
const first = view.load("", true);
const second = view.load("", true);
expect(second).toBe(first);
void first.then(() => {
completed = true;
});
await settle(0);
expect(completed).toBe(false);
await settle(1);
expect(completed).toBe(true);
});

it("retains the four-query concurrency bound across directories and refreshes", async () => {
const folders = Array.from({ length: 8 }, (_, index) => ({
path: `folder-${index}`,
kind: "directory" as const,
}));
await mount();
await settle(0, folders);
await act(async () => {
for (const folder of folders) void view.load(folder.path);
for (let index = 0; index < 20; index++) view.refresh();
});
for (let index = 1; index < calls.length; index++) {
await settle(index, calls[index]!.path === "" ? folders : []);
expect(activeQueries).toBeLessThanOrEqual(4);
}
console.log(
`8 folders with 20 overlapping refreshes: ${calls.length} total listing requests, peak ${peakQueries} active queries`,
);
expect(peakQueries).toBe(4);
expect(new Set(calls.map((call) => call.path)).size).toBe(9);
for (const path of ["", ...folders.map((folder) => folder.path)]) {
expect(calls.filter((call) => call.path === path).length).toBeLessThanOrEqual(3);
}
expect(view.isPending).toBe(false);
});

it("does not start a queued follow-up after unmount", async () => {
await mount();
await act(async () => view.refresh());
await act(async () => renderer?.unmount());
renderer = null;
await settle(0);
expect(calls).toHaveLength(1);
});
28 changes: 17 additions & 11 deletions apps/web/src/components/files/useDirectoryEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {
() => new Set([""]),
);
const requests = useRef(new Map<string, Promise<void>>());
const followUpRefreshes = useRef(new Map<string, Promise<void>>());
const loaded = useRef(new Set<string>());
const requested = useRef(new Set<string>());
const active = useRef(true);
Expand Down Expand Up @@ -87,8 +88,18 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {

const load = function loadDirectory(directoryPath: string, refresh = false): Promise<void> {
const existing = requests.current.get(directoryPath);
if (existing)
return refresh ? existing.then(() => loadDirectory(directoryPath, true)) : existing;
if (existing) {
if (!refresh) return existing;
let followUp = followUpRefreshes.current.get(directoryPath);
if (!followUp) {
followUp = existing.then(() => {
followUpRefreshes.current.delete(directoryPath);
if (active.current) return loadDirectory(directoryPath, true);
});
followUpRefreshes.current.set(directoryPath, followUp);
}
return followUp;
}
if (!refresh && loaded.current.has(directoryPath)) return Promise.resolve();
setPending((count) => count + 1);
if (!refresh) setLoadingDirectories((previous) => new Set(previous).add(directoryPath));
Expand Down Expand Up @@ -119,15 +130,10 @@ export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) {

const refresh = () => {
const paths = [...requested.current].filter((path) => reachableDirectories.has(path));
const workerCount = Math.min(4, paths.length);
const worker = async () => {
while (active.current) {
const path = paths.shift();
if (path === undefined) return;
await load(path, true);
}
};
for (let index = 0; index < workerCount; index++) void worker();
for (const path of paths) {
if (!active.current) break;
void load(path, true);
}
};

return {
Expand Down
Loading