From ce54b59c40f197ce76118388da400de1dbd8ae97 Mon Sep 17 00:00:00 2001 From: Chris van der Loo Date: Sat, 5 Sep 2026 14:09:39 -0400 Subject: [PATCH] feat(web): use an account-scoped cancellable query cache for files --- packages/web/src/hooks/useFiles.test.tsx | 121 +++++++++++++++++++++++ packages/web/src/hooks/useFiles.ts | 49 +++++++++ packages/web/src/hooks/usePreference.ts | 20 ++++ packages/web/src/hooks/useStoredState.ts | 11 --- 4 files changed, 190 insertions(+), 11 deletions(-) create mode 100644 packages/web/src/hooks/useFiles.test.tsx create mode 100644 packages/web/src/hooks/useFiles.ts create mode 100644 packages/web/src/hooks/usePreference.ts delete mode 100644 packages/web/src/hooks/useStoredState.ts diff --git a/packages/web/src/hooks/useFiles.test.tsx b/packages/web/src/hooks/useFiles.test.tsx new file mode 100644 index 0000000..a67e998 --- /dev/null +++ b/packages/web/src/hooks/useFiles.test.tsx @@ -0,0 +1,121 @@ +// @vitest-environment jsdom +import { QueryClientProvider } from "@tanstack/preact-query" +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { actions } from "../lib/actions" +import { filesKey, sessionKey, type Session } from "../lib/api" +import { createQueryClient } from "../lib/query" +import { file, session } from "../test/fixtures" +import { useFiles } from "./useFiles" + +const fetchMock = vi.fn() +beforeEach(() => vi.stubGlobal("fetch", fetchMock)) +afterEach(() => { + cleanup() + vi.unstubAllGlobals() + fetchMock.mockReset() +}) + +function Harness({ account = session }: { account?: Session }) { + const { query, execute, refresh } = useFiles(account, undefined, false) + return ( + <> + {query.data?.files.map((entry) => entry.name).join(",") ?? "Loading"} + + + + ) +} +const createClient = createQueryClient + +describe("account-scoped file cache", () => { + it("refetches changed root contents even when stable IDs are unchanged", async () => { + let entry = file() + fetchMock.mockImplementation(async () => Response.json({ files: [entry] })) + const client = createClient() + render( + + + , + ) + await screen.findByText("Notes.txt") + entry = { ...entry, name: "Updated.txt", revision: "new-revision" } + fireEvent.click(screen.getByText("Refresh")) + await screen.findByText("Updated.txt") + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + it("sends a revision-checked rename and invalidates the actual listing", async () => { + let entry = file() + const writes: unknown[] = [] + fetchMock.mockImplementation(async (input) => { + const request = input instanceof Request ? input : new Request(input) + if (request.method === "PUT") { + const body = await request.json() + writes.push(body) + entry = { ...entry, name: body.name, revision: "next-revision" } + return Response.json(entry) + } + return Response.json({ files: [entry] }) + }) + render( + + + , + ) + await screen.findByText("Notes.txt") + fireEvent.click(screen.getByText("Rename")) + await screen.findByText("Renamed.txt") + expect(writes).toEqual([{ revision: "revision-one", name: "Renamed.txt" }]) + }) + it("separates account and space keys and clears cached files on unmount", async () => { + expect(filesKey("did:a", "space:a")).not.toEqual(filesKey("did:b", "space:a")) + expect(filesKey("did:a", "space:a")).not.toEqual(filesKey("did:a", "space:b")) + fetchMock.mockResolvedValue(Response.json({ files: [file()] })) + const client = createClient() + const { unmount } = render( + + + , + ) + await screen.findByText("Notes.txt") + unmount() + expect( + client.getQueriesData({ queryKey: filesKey(session.user.did, session.space.uri) }), + ).toEqual([]) + }) + it("clears the authenticated session after an expired-session response", async () => { + fetchMock.mockResolvedValue( + Response.json({ code: "unauthorized", message: "Please log in." }, { status: 401 }), + ) + const client = createClient() + client.setQueryData(sessionKey, session) + render( + + + , + ) + await waitFor(() => expect(client.getQueryData(sessionKey)).toBeNull()) + }) + it("aborts an in-flight request on unmount", async () => { + let signal: AbortSignal | undefined + fetchMock.mockImplementation(async (input) => { + if (input instanceof Request) signal = input.signal + return new Promise(() => {}) + }) + const { unmount } = render( + + + , + ) + await waitFor(() => expect(signal).toBeDefined()) + unmount() + expect(signal?.aborted).toBe(true) + }) +}) diff --git a/packages/web/src/hooks/useFiles.ts b/packages/web/src/hooks/useFiles.ts new file mode 100644 index 0000000..bac6ec4 --- /dev/null +++ b/packages/web/src/hooks/useFiles.ts @@ -0,0 +1,49 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/preact-query" +import { useEffect, useMemo, useState } from "preact/hooks" +import { api, filesKey, unwrap, type Session } from "../lib/api" +import type { Command } from "../lib/actions" + +export function useFiles(session: Session, parentId: string | undefined, trash: boolean) { + const client = useQueryClient() + const did = session.user.did + const spaceUri = session.space.uri + const prefix = useMemo(() => filesKey(did, spaceUri), [did, spaceUri]) + const [controller] = useState(() => new AbortController()) + const query = useQuery({ + queryKey: [...prefix, "directory", parentId ?? null, trash], + queryFn: async ({ signal }) => + unwrap( + await api.GET("/api/v1/files", { + params: { query: { parentId, trash } }, + signal, + }), + ), + }) + const mutation = useMutation({ + mutationFn: (command: Command) => command(controller.signal), + onSettled: () => client.invalidateQueries({ queryKey: prefix }), + }) + useEffect( + () => () => { + controller.abort() + void client.cancelQueries({ queryKey: prefix }) + client.removeQueries({ queryKey: prefix }) + }, + [client, controller, prefix], + ) + return { + query, + pending: mutation.isPending, + error: mutation.error, + dismissError: mutation.reset, + execute: async (command: Command) => { + try { + await mutation.mutateAsync(command) + return true + } catch { + return false + } + }, + refresh: () => client.invalidateQueries({ queryKey: prefix }), + } +} diff --git a/packages/web/src/hooks/usePreference.ts b/packages/web/src/hooks/usePreference.ts new file mode 100644 index 0000000..83a5754 --- /dev/null +++ b/packages/web/src/hooks/usePreference.ts @@ -0,0 +1,20 @@ +import { useState } from "preact/hooks" + +export function usePreference(key: string, options: readonly T[], fallback: T) { + const [value, setValue] = useState(() => { + try { + return options.find((value) => value === localStorage.getItem(key)) ?? fallback + } catch { + return fallback + } + }) + const update = (next: T) => { + setValue(next) + try { + localStorage.setItem(key, next) + } catch { + /* Preferences remain usable without storage. */ + } + } + return [value, update] as const +} diff --git a/packages/web/src/hooks/useStoredState.ts b/packages/web/src/hooks/useStoredState.ts deleted file mode 100644 index 8e27dd7..0000000 --- a/packages/web/src/hooks/useStoredState.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Dispatch, StateUpdater, useEffect, useState } from "preact/hooks" - -export default function useStoredState(defaultValue: T, id: string) { - const [val, setVal] = useState((localStorage.getItem(id) as T) || defaultValue) - - useEffect(() => { - localStorage.setItem(id, val) - }, [id, val]) - - return [val, setVal] as [T, Dispatch>] -} -- 2.51.2