From b5c6efb3c39ca96bb89719015c4e361c19e7f534 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Tue, 18 Aug 2026 03:09:53 -0400 Subject: [PATCH] feat(frontend): add workspace-aware server state --- frontend/src/hooks/useServerQuery.js | 107 ++++++++++++ frontend/src/pages/Workspaces.jsx | 42 ++--- .../services/__tests__/queryClient.test.mjs | 80 +++++++++ frontend/src/services/api/servers.js | 4 +- frontend/src/services/queryClient.js | 153 ++++++++++++++++++ 5 files changed, 366 insertions(+), 20 deletions(-) create mode 100644 frontend/src/hooks/useServerQuery.js create mode 100644 frontend/src/services/__tests__/queryClient.test.mjs create mode 100644 frontend/src/services/queryClient.js diff --git a/frontend/src/hooks/useServerQuery.js b/frontend/src/hooks/useServerQuery.js new file mode 100644 index 00000000..98960e92 --- /dev/null +++ b/frontend/src/hooks/useServerQuery.js @@ -0,0 +1,107 @@ +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { useWorkspace } from '../contexts/WorkspaceContext'; +import { queryClient, serializeQueryKey } from '../services/queryClient'; + +const workspaceKey = (workspaceId, queryKey) => ['workspace', workspaceId, ...queryKey]; + +export function useServerQuery(queryKey, queryFn, options = {}) { + const { + enabled = true, + staleTime = 0, + onError, + } = options; + const { activeWorkspaceId } = useWorkspace(); + const localKeyHash = serializeQueryKey(queryKey); + const scopedKey = useMemo( + () => workspaceKey(activeWorkspaceId, queryKey), + // queryKey is commonly declared inline; its serialized value is the + // semantic dependency and avoids refetching on every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + [activeWorkspaceId, localKeyHash], + ); + const scopedKeyHash = serializeQueryKey(scopedKey); + const queryFnRef = useRef(queryFn); + const onErrorRef = useRef(onError); + queryFnRef.current = queryFn; + onErrorRef.current = onError; + + const subscribe = useCallback( + (listener) => queryClient.subscribe(scopedKey, listener), + // eslint-disable-next-line react-hooks/exhaustive-deps + [scopedKeyHash], + ); + const getSnapshot = useCallback( + () => queryClient.getSnapshot(scopedKey), + // eslint-disable-next-line react-hooks/exhaustive-deps + [scopedKeyHash], + ); + const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + useEffect(() => { + if (!enabled) return undefined; + let active = true; + queryClient.fetchQuery( + scopedKey, + (context) => queryFnRef.current(context), + { staleTime }, + ).catch((error) => { + if (active) onErrorRef.current?.(error); + }); + return () => { active = false; }; + }, [enabled, scopedKeyHash, staleTime, state.revision, scopedKey]); + + const refetch = useCallback( + () => queryClient.fetchQuery( + scopedKey, + (context) => queryFnRef.current(context), + { force: true, staleTime }, + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [scopedKeyHash, staleTime], + ); + + return { + ...state, + isLoading: state.status === 'idle' || state.status === 'loading', + isError: state.status === 'error', + refetch, + }; +} + +export function useServerMutation(mutationFn, options = {}) { + const { invalidate = [], onSuccess, onError } = options; + const { activeWorkspaceId } = useWorkspace(); + const mutationFnRef = useRef(mutationFn); + const optionsRef = useRef({ invalidate, onSuccess, onError }); + mutationFnRef.current = mutationFn; + optionsRef.current = { invalidate, onSuccess, onError }; + const [state, setState] = useState({ status: 'idle', data: undefined, error: null }); + + const mutate = useCallback(async (variables) => { + setState((previous) => ({ ...previous, status: 'loading', error: null })); + try { + const data = await mutationFnRef.current(variables); + setState({ status: 'success', data, error: null }); + optionsRef.current.invalidate.forEach((prefix) => { + queryClient.invalidateQueries(workspaceKey(activeWorkspaceId, prefix)); + }); + await optionsRef.current.onSuccess?.(data, variables); + return data; + } catch (error) { + setState({ status: 'error', data: undefined, error }); + optionsRef.current.onError?.(error, variables); + throw error; + } + }, [activeWorkspaceId]); + + const reset = useCallback(() => { + setState({ status: 'idle', data: undefined, error: null }); + }, []); + + return { + ...state, + isPending: state.status === 'loading', + mutate, + reset, + }; +} diff --git a/frontend/src/pages/Workspaces.jsx b/frontend/src/pages/Workspaces.jsx index 7026bf6b..e1f4e378 100644 --- a/frontend/src/pages/Workspaces.jsx +++ b/frontend/src/pages/Workspaces.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import api from '../services/api'; import { useToast } from '../contexts/ToastContext'; @@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { useWorkspace } from '../contexts/WorkspaceContext'; +import { useServerMutation, useServerQuery } from '../hooks/useServerQuery'; // Preset views. `servers` and `users` are quota CEILINGS, not usage, so there // is no column to express "near capacity" against. @@ -83,8 +84,6 @@ const Workspaces = () => { const toast = useToast(); const navigate = useNavigate(); const { activeWorkspaceId } = useWorkspace(); - const [workspaces, setWorkspaces] = useState([]); - const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [selectedIds, setSelectedIds] = useState(new Set()); const [showCreateModal, setShowCreateModal] = useState(false); @@ -92,18 +91,21 @@ const Workspaces = () => { useFocusParam('create', () => setShowCreateModal(true)); const [form, setForm] = useState({ name: '', description: '', max_servers: 0, max_users: 0, primary_color: '#6d7cff' }); - const loadWorkspaces = useCallback(async () => { - try { - const data = await api.getWorkspaces(); - setWorkspaces(data.workspaces || []); - } catch (err) { - toast.error('Failed to load workspaces'); - } finally { - setLoading(false); - } - }, [toast]); - - useEffect(() => { loadWorkspaces(); }, [loadWorkspaces]); + const loadWorkspaces = useCallback( + ({ signal }) => api.getWorkspaces({}, { signal }).then((data) => data.workspaces || []), + [], + ); + const { + data: workspaces = [], + isLoading: loading, + } = useServerQuery(['workspaces'], loadWorkspaces, { + staleTime: 30_000, + onError: () => toast.error('Failed to load workspaces'), + }); + const createWorkspace = useServerMutation( + (values) => api.createWorkspace(values), + { invalidate: [['workspaces']] }, + ); useTopbarActions(() => ( <> @@ -121,11 +123,10 @@ const Workspaces = () => { const handleCreate = async () => { try { - await api.createWorkspace(form); + await createWorkspace.mutate(form); toast.success('Workspace created'); setShowCreateModal(false); setForm({ name: '', description: '', max_servers: 0, max_users: 0, primary_color: '#6d7cff' }); - loadWorkspaces(); } catch (err) { toast.error(err.message); } @@ -256,7 +257,12 @@ const Workspaces = () => { footer={( <> - + )} > diff --git a/frontend/src/services/__tests__/queryClient.test.mjs b/frontend/src/services/__tests__/queryClient.test.mjs new file mode 100644 index 00000000..4f5138ed --- /dev/null +++ b/frontend/src/services/__tests__/queryClient.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createQueryClient } from '../queryClient.js'; + + +test('deduplicates concurrent requests for one scoped key', async () => { + const client = createQueryClient(); + let calls = 0; + let resolveRequest; + const request = () => { + calls += 1; + return new Promise((resolve) => { resolveRequest = resolve; }); + }; + + const first = client.fetchQuery(['workspace', 'alpha', 'apps'], request); + const second = client.fetchQuery(['workspace', 'alpha', 'apps'], request); + await Promise.resolve(); + resolveRequest(['one']); + + assert.deepEqual(await first, ['one']); + assert.deepEqual(await second, ['one']); + assert.equal(calls, 1); + assert.equal(client.getSnapshot(['workspace', 'alpha', 'apps']).status, 'success'); +}); + + +test('isolates identical resources between workspaces', async () => { + const client = createQueryClient(); + + await Promise.all([ + client.fetchQuery(['workspace', 'alpha', 'servers'], async () => ['alpha-server']), + client.fetchQuery(['workspace', 'beta', 'servers'], async () => ['beta-server']), + ]); + + assert.deepEqual( + client.getSnapshot(['workspace', 'alpha', 'servers']).data, + ['alpha-server'], + ); + assert.deepEqual( + client.getSnapshot(['workspace', 'beta', 'servers']).data, + ['beta-server'], + ); +}); + + +test('cancels an in-flight request after its last observer leaves', async () => { + const client = createQueryClient(); + const key = ['workspace', 'alpha', 'jobs']; + let signal; + const unsubscribe = client.subscribe(key, () => {}); + const pending = client.fetchQuery(key, ({ signal: requestSignal }) => { + signal = requestSignal; + return new Promise(() => {}); + }); + + await Promise.resolve(); + unsubscribe(); + + assert.equal(signal.aborted, true); + assert.equal(client.getSnapshot(key).isFetching, false); + void pending; +}); + + +test('prefix invalidation targets one workspace and advances revision', async () => { + const client = createQueryClient(); + const alpha = ['workspace', 'alpha', 'apps']; + const beta = ['workspace', 'beta', 'apps']; + await client.fetchQuery(alpha, async () => ['a']); + await client.fetchQuery(beta, async () => ['b']); + + const alphaRevision = client.getSnapshot(alpha).revision; + const betaRevision = client.getSnapshot(beta).revision; + client.invalidateQueries(['workspace', 'alpha']); + + assert.equal(client.getSnapshot(alpha).revision, alphaRevision + 1); + assert.equal(client.getSnapshot(alpha).updatedAt, 0); + assert.equal(client.getSnapshot(beta).revision, betaRevision); +}); diff --git a/frontend/src/services/api/servers.js b/frontend/src/services/api/servers.js index dfe4f8e0..52db1432 100644 --- a/frontend/src/services/api/servers.js +++ b/frontend/src/services/api/servers.js @@ -916,12 +916,12 @@ export async function getServerTemplateAssignments(serverId) { } // Workspaces endpoints -export async function getWorkspaces(params = {}) { +export async function getWorkspaces(params = {}, options = {}) { const query = new URLSearchParams(); if (params.all) query.append('all', 'true'); if (params.include_archived) query.append('include_archived', 'true'); const qs = query.toString(); - return this.request(`/workspaces/${qs ? '?' + qs : ''}`); + return this.request(`/workspaces/${qs ? '?' + qs : ''}`, options); } export async function getWorkspace(id) { diff --git a/frontend/src/services/queryClient.js b/frontend/src/services/queryClient.js new file mode 100644 index 00000000..5dc3de96 --- /dev/null +++ b/frontend/src/services/queryClient.js @@ -0,0 +1,153 @@ +const IDLE_STATE = Object.freeze({ + status: 'idle', + data: undefined, + error: null, + updatedAt: 0, + revision: 0, + isFetching: false, +}); + +export const serializeQueryKey = (queryKey) => JSON.stringify(queryKey); + +const keyStartsWith = (candidate, prefix) => ( + prefix.length <= candidate.length + && prefix.every((part, index) => Object.is(part, candidate[index])) +); + +const isAbortError = (error) => error?.name === 'AbortError'; + +/** + * Small framework-neutral server-state cache. + * + * It deliberately owns only lifecycle concerns shared by every resource: + * key isolation, in-flight deduplication, cancellation, freshness and + * invalidation. Domain hooks still own response interpretation and live-event + * semantics. + */ +export function createQueryClient() { + const entries = new Map(); + + const ensureEntry = (queryKey) => { + const hash = serializeQueryKey(queryKey); + let entry = entries.get(hash); + if (!entry) { + entry = { + hash, + queryKey: [...queryKey], + state: IDLE_STATE, + listeners: new Set(), + controller: null, + promise: null, + }; + entries.set(hash, entry); + } + return entry; + }; + + const notify = (entry) => entry.listeners.forEach((listener) => listener()); + + const updateState = (entry, patch) => { + entry.state = { ...entry.state, ...patch }; + notify(entry); + }; + + const cancelEntry = (entry) => { + entry.controller?.abort(); + entry.controller = null; + entry.promise = null; + if (entry.state.isFetching) updateState(entry, { isFetching: false }); + }; + + const subscribe = (queryKey, listener) => { + const entry = ensureEntry(queryKey); + entry.listeners.add(listener); + return () => { + entry.listeners.delete(listener); + if (entry.listeners.size === 0) cancelEntry(entry); + }; + }; + + const getSnapshot = (queryKey) => ensureEntry(queryKey).state; + + const fetchQuery = (queryKey, queryFn, { force = false, staleTime = 0 } = {}) => { + const entry = ensureEntry(queryKey); + if (entry.promise && !force) return entry.promise; + + const isFresh = entry.state.status === 'success' + && Date.now() - entry.state.updatedAt < staleTime; + if (isFresh && !force) return Promise.resolve(entry.state.data); + if (force) cancelEntry(entry); + + const controller = new AbortController(); + entry.controller = controller; + updateState(entry, { + status: entry.state.data === undefined ? 'loading' : entry.state.status, + error: null, + isFetching: true, + }); + + const promise = Promise.resolve().then(() => queryFn({ + signal: controller.signal, + queryKey: entry.queryKey, + })); + entry.promise = promise; + + return promise.then((data) => { + if (entry.controller !== controller || controller.signal.aborted) return data; + entry.controller = null; + entry.promise = null; + updateState(entry, { + status: 'success', + data, + error: null, + updatedAt: Date.now(), + isFetching: false, + }); + return data; + }).catch((error) => { + if (entry.controller !== controller || controller.signal.aborted || isAbortError(error)) { + return undefined; + } + entry.controller = null; + entry.promise = null; + updateState(entry, { + status: 'error', + error, + isFetching: false, + }); + throw error; + }); + }; + + const invalidateQueries = (queryKeyPrefix = []) => { + entries.forEach((entry) => { + if (!keyStartsWith(entry.queryKey, queryKeyPrefix)) return; + updateState(entry, { + updatedAt: 0, + revision: entry.state.revision + 1, + }); + }); + }; + + const cancelQueries = (queryKeyPrefix = []) => { + entries.forEach((entry) => { + if (keyStartsWith(entry.queryKey, queryKeyPrefix)) cancelEntry(entry); + }); + }; + + const clear = () => { + entries.forEach(cancelEntry); + entries.clear(); + }; + + return { + subscribe, + getSnapshot, + fetchQuery, + invalidateQueries, + cancelQueries, + clear, + }; +} + +export const queryClient = createQueryClient();