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();