@tuturuuu/ui 0.37.1 → 0.37.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { renderHook, waitFor } from '@testing-library/react';
3
+ import type { ReactNode } from 'react';
4
+ import { beforeEach, expect, it, vi } from 'vitest';
5
+ import { useVisibleWorkspaces } from '../use-visible-workspaces';
6
+ import { WorkspaceVisibilityProvider } from '../use-workspace-visibility';
7
+
8
+ const mocks = vi.hoisted(() => ({ list: vi.fn(), hidden: vi.fn() }));
9
+ vi.mock('@tuturuuu/internal-api/workspaces', () => ({
10
+ listWorkspaces: mocks.list,
11
+ }));
12
+ vi.mock('@tuturuuu/internal-api/users', () => ({
13
+ getCurrentUserHiddenWorkspaces: mocks.hidden,
14
+ updateCurrentUserHiddenWorkspace: vi.fn(),
15
+ }));
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ mocks.list.mockResolvedValue([{ id: 'visible' }, { id: 'hidden' }]);
19
+ mocks.hidden.mockResolvedValue({ hiddenWorkspaceIds: ['hidden'] });
20
+ });
21
+ function fixture() {
22
+ const client = new QueryClient({
23
+ defaultOptions: { queries: { retry: false, retryDelay: 0 } },
24
+ });
25
+ const wrapper = ({ children }: { children: ReactNode }) => (
26
+ <QueryClientProvider client={client}>
27
+ <WorkspaceVisibilityProvider actorId="A">
28
+ {children}
29
+ </WorkspaceVisibilityProvider>
30
+ </QueryClientProvider>
31
+ );
32
+ return { client, wrapper };
33
+ }
34
+ it('retains an explicit zero bound and only exposes known visible choices', async () => {
35
+ const { client, wrapper } = fixture();
36
+ const { result } = renderHook(() => useVisibleWorkspaces(true, 0), {
37
+ wrapper,
38
+ });
39
+ await waitFor(() => expect(result.current.data).toEqual([{ id: 'visible' }]));
40
+ expect(mocks.list).toHaveBeenCalledWith({ limit: 0 });
41
+ expect(client.getQueryData(['workspace-ui-list', 'A', 0])).toHaveLength(2);
42
+ });
43
+ it('settled private discovery failure is an error rather than permanent loading', async () => {
44
+ mocks.hidden.mockRejectedValue(new Error('offline'));
45
+ const { client, wrapper } = fixture();
46
+ const { result } = renderHook(() => useVisibleWorkspaces(), { wrapper });
47
+ await waitFor(() =>
48
+ expect(client.getQueryState(['workspace-hidden', 'A'])?.status).toBe(
49
+ 'error'
50
+ )
51
+ );
52
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
53
+ expect(result.current.isError).toBe(true);
54
+ expect(result.current.error?.message).toBe('offline');
55
+ expect(result.current.data).toBeUndefined();
56
+ });
57
+
58
+ it('distinguishes an absent personal workspace from one excluded by Hidden preferences', async () => {
59
+ mocks.list.mockResolvedValue([{ id: 'team', personal: false }]);
60
+ const { client, wrapper } = fixture();
61
+ const { result } = renderHook(() => useVisibleWorkspaces(), { wrapper });
62
+ await waitFor(() =>
63
+ expect(result.current.personalWorkspaceMissing).toBe(true)
64
+ );
65
+ mocks.list.mockResolvedValue([
66
+ { id: 'team', personal: false },
67
+ { id: 'personal', personal: true },
68
+ ]);
69
+ mocks.hidden.mockResolvedValue({ hiddenWorkspaceIds: ['personal'] });
70
+ await result.current.refetch();
71
+ await waitFor(() =>
72
+ expect(result.current.data).toEqual([{ id: 'team', personal: false }])
73
+ );
74
+ expect(result.current.personalWorkspaceMissing).toBe(false);
75
+ expect(client.getQueryData(['workspace-ui-list', 'A'])).toHaveLength(2);
76
+ });
@@ -0,0 +1,155 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { act, renderHook, waitFor } from '@testing-library/react';
3
+ import type { ReactNode } from 'react';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import {
6
+ useWorkspaceVisibility,
7
+ WorkspaceVisibilityProvider,
8
+ } from '../use-workspace-visibility';
9
+
10
+ const mocks = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn() }));
11
+ vi.mock('@tuturuuu/internal-api/users', () => ({
12
+ getCurrentUserHiddenWorkspaces: mocks.get,
13
+ updateCurrentUserHiddenWorkspace: mocks.put,
14
+ }));
15
+ function deferred<T>() {
16
+ let resolve!: (v: T) => void;
17
+ let reject!: (e: Error) => void;
18
+ const promise = new Promise<T>((a, b) => {
19
+ resolve = a;
20
+ reject = b;
21
+ });
22
+ return { promise, resolve, reject };
23
+ }
24
+ function setup(
25
+ actor = 'A',
26
+ client = new QueryClient({
27
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
28
+ })
29
+ ) {
30
+ const wrapper = ({ children }: { children: ReactNode }) => (
31
+ <QueryClientProvider client={client}>
32
+ <WorkspaceVisibilityProvider actorId={actor}>
33
+ {children}
34
+ </WorkspaceVisibilityProvider>
35
+ </QueryClientProvider>
36
+ );
37
+ return { ...renderHook(() => useWorkspaceVisibility(), { wrapper }), client };
38
+ }
39
+ beforeEach(() => {
40
+ vi.clearAllMocks();
41
+ mocks.get.mockResolvedValue({ hiddenWorkspaceIds: [] });
42
+ mocks.put.mockResolvedValue({});
43
+ });
44
+ describe('private owner-scoped workspace visibility', () => {
45
+ it('optimistically hides and rolls back only the failed workspace', async () => {
46
+ const write = deferred<object>();
47
+ mocks.put.mockReturnValue(write.promise);
48
+ const hook = setup();
49
+ await waitFor(() => expect(hook.result.current.known).toBe(true));
50
+ let operation!: Promise<void>;
51
+ act(() => {
52
+ operation = hook.result.current.setHidden('ws-1', true);
53
+ });
54
+ await waitFor(() =>
55
+ expect(hook.result.current.hiddenIds).toEqual(['ws-1'])
56
+ );
57
+ await act(async () => {
58
+ write.reject(new Error('offline'));
59
+ await expect(operation).rejects.toThrow('offline');
60
+ });
61
+ expect(hook.result.current.hiddenIds).toEqual([]);
62
+ expect(hook.result.current.updateError).not.toBeNull();
63
+ expect(mocks.put).toHaveBeenCalledWith('ws-1', true, 'A');
64
+ await act(async () => {
65
+ await hook.result.current.refetch();
66
+ });
67
+ expect(hook.result.current.updateError).toBeNull();
68
+ });
69
+ it('a stale refresh cannot replace optimistic state', async () => {
70
+ const hook = setup();
71
+ await waitFor(() => expect(hook.result.current.known).toBe(true));
72
+ const read = deferred<{ hiddenWorkspaceIds: string[] }>();
73
+ mocks.get
74
+ .mockImplementationOnce(() => read.promise)
75
+ .mockResolvedValue({ hiddenWorkspaceIds: ['ws-1'] });
76
+ act(() => {
77
+ void hook.result.current.refetch();
78
+ });
79
+ await act(async () => {
80
+ await hook.result.current.setHidden('ws-1', true);
81
+ });
82
+ await act(async () => {
83
+ read.resolve({ hiddenWorkspaceIds: [] });
84
+ });
85
+ expect(hook.result.current.hiddenIds).toEqual(['ws-1']);
86
+ });
87
+ it('suppresses duplicate writes for the same pending workspace', async () => {
88
+ const write = deferred<object>();
89
+ mocks.put.mockReturnValue(write.promise);
90
+ const hook = setup();
91
+ await waitFor(() => expect(hook.result.current.known).toBe(true));
92
+ let operation!: Promise<void>;
93
+ act(() => {
94
+ operation = hook.result.current.setHidden('ws-1', true);
95
+ });
96
+ await waitFor(() =>
97
+ expect(hook.result.current.pending.has('ws-1')).toBe(true)
98
+ );
99
+ await act(async () => {
100
+ await hook.result.current.setHidden('ws-1', true);
101
+ });
102
+ expect(mocks.put).toHaveBeenCalledTimes(1);
103
+ await act(async () => {
104
+ write.resolve({});
105
+ await operation;
106
+ });
107
+ });
108
+ it('removes private owner caches on logout and ignores late response', async () => {
109
+ const hook = setup();
110
+ await waitFor(() => expect(hook.result.current.known).toBe(true));
111
+ const write = deferred<object>();
112
+ mocks.put.mockReturnValue(write.promise);
113
+ let operation!: Promise<void>;
114
+ act(() => {
115
+ operation = hook.result.current.setHidden('ws-1', true);
116
+ });
117
+ await waitFor(() =>
118
+ expect(hook.result.current.hiddenIds).toEqual(['ws-1'])
119
+ );
120
+ hook.unmount();
121
+ write.resolve({});
122
+ await operation;
123
+ expect(hook.client.getQueryData(['workspace-hidden', 'A'])).toBeUndefined();
124
+ expect(hook.client.getQueryData(['workspace-hidden', 'B'])).toBeUndefined();
125
+ });
126
+ it('two actors use independent hidden cache keys', async () => {
127
+ mocks.get.mockImplementation(async (actor: string) => ({
128
+ hiddenWorkspaceIds: actor === 'A' ? ['ws-1'] : [],
129
+ }));
130
+ const client = new QueryClient({
131
+ defaultOptions: { queries: { retry: false } },
132
+ });
133
+ const a = setup('A', client);
134
+ const b = setup('B', client);
135
+ await waitFor(() => expect(a.result.current.hiddenIds).toEqual(['ws-1']));
136
+ await waitFor(() => expect(b.result.current.known).toBe(true));
137
+ expect(b.result.current.hiddenIds).toEqual([]);
138
+ });
139
+ });
140
+
141
+ it('settles a refetch safely after logout without returning private old-actor data', async () => {
142
+ const hook = setup();
143
+ await waitFor(() => expect(hook.result.current.known).toBe(true));
144
+ const read = deferred<{ hiddenWorkspaceIds: string[] }>();
145
+ mocks.get.mockReturnValue(read.promise);
146
+ let operation!: ReturnType<typeof hook.result.current.refetch>;
147
+ act(() => {
148
+ operation = hook.result.current.refetch();
149
+ });
150
+ await waitFor(() => expect(mocks.get).toHaveBeenCalledTimes(2));
151
+ hook.unmount();
152
+ read.resolve({ hiddenWorkspaceIds: ['old-private'] });
153
+ await expect(operation).resolves.toBeNull();
154
+ expect(hook.client.getQueryData(['workspace-hidden', 'A'])).toBeUndefined();
155
+ });
@@ -0,0 +1,103 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { act, render, screen, waitFor } from '@testing-library/react';
3
+ import { beforeEach, expect, it, vi } from 'vitest';
4
+ import { useWorkspaceUser } from '../use-workspace-user';
5
+ import {
6
+ useWorkspaceVisibility,
7
+ WorkspaceVisibilityProvider,
8
+ } from '../use-workspace-visibility';
9
+
10
+ const mocks = vi.hoisted(() => ({ hidden: vi.fn(), profile: vi.fn() }));
11
+ vi.mock('@tuturuuu/internal-api/users', () => ({
12
+ getCurrentUserHiddenWorkspaces: mocks.hidden,
13
+ getCurrentUserProfile: mocks.profile,
14
+ updateCurrentUserHiddenWorkspace: vi.fn(),
15
+ }));
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ mocks.hidden.mockResolvedValue({ hiddenWorkspaceIds: ['private'] });
19
+ mocks.profile.mockResolvedValue({ id: 'A' });
20
+ });
21
+ function Probe() {
22
+ const hidden = useWorkspaceVisibility();
23
+ const user = useWorkspaceUser();
24
+ return (
25
+ <output>
26
+ {JSON.stringify({
27
+ known: hidden.known,
28
+ ids: hidden.hiddenIds,
29
+ user: user.data?.id,
30
+ })}
31
+ </output>
32
+ );
33
+ }
34
+ function fixture() {
35
+ return new QueryClient({
36
+ defaultOptions: { queries: { retry: false, retryDelay: 0 } },
37
+ });
38
+ }
39
+ it('retains the root owner cache across same-actor route unmount and remount', async () => {
40
+ const client = fixture();
41
+ function Tree({ route }: { route: boolean }) {
42
+ return (
43
+ <QueryClientProvider client={client}>
44
+ <WorkspaceVisibilityProvider actorId="A">
45
+ {route && (
46
+ <WorkspaceVisibilityProvider actorId="A">
47
+ <Probe />
48
+ </WorkspaceVisibilityProvider>
49
+ )}
50
+ </WorkspaceVisibilityProvider>
51
+ </QueryClientProvider>
52
+ );
53
+ }
54
+ const tree = render(<Tree route />);
55
+ await waitFor(() =>
56
+ expect(screen.getByRole('status').textContent).toContain('"known":true')
57
+ );
58
+ await waitFor(() =>
59
+ expect(client.getQueryData(['workspace-user', 'A'])).toMatchObject({
60
+ id: 'A',
61
+ })
62
+ );
63
+ tree.rerender(<Tree route={false} />);
64
+ expect(client.getQueryData(['workspace-hidden', 'A'])).toEqual(['private']);
65
+ tree.rerender(<Tree route />);
66
+ await waitFor(() =>
67
+ expect(screen.getByRole('status').textContent).toContain('private')
68
+ );
69
+ expect(mocks.hidden).toHaveBeenCalledTimes(1);
70
+ expect(mocks.profile).toHaveBeenCalledTimes(1);
71
+ expect(client.getQueryData(['workspace-user'])).toBeUndefined();
72
+ tree.unmount();
73
+ expect(client.getQueryData(['workspace-hidden', 'A'])).toBeUndefined();
74
+ expect(client.getQueryData(['workspace-user', 'A'])).toBeUndefined();
75
+ });
76
+ it('does not read profiles or private preferences without verified identity', async () => {
77
+ const client = fixture();
78
+ render(
79
+ <QueryClientProvider client={client}>
80
+ <Probe />
81
+ </QueryClientProvider>
82
+ );
83
+ await act(async () => {});
84
+ expect(mocks.profile).not.toHaveBeenCalled();
85
+ expect(mocks.hidden).not.toHaveBeenCalled();
86
+ expect(screen.getByRole('status').textContent).toContain('"known":false');
87
+ });
88
+ it('fails a mismatched route actor closed under a verified root', async () => {
89
+ const client = fixture();
90
+ render(
91
+ <QueryClientProvider client={client}>
92
+ <WorkspaceVisibilityProvider actorId="B">
93
+ <WorkspaceVisibilityProvider actorId="A">
94
+ <Probe />
95
+ </WorkspaceVisibilityProvider>
96
+ </WorkspaceVisibilityProvider>
97
+ </QueryClientProvider>
98
+ );
99
+ await act(async () => {});
100
+ expect(mocks.profile).not.toHaveBeenCalled();
101
+ expect(mocks.hidden).not.toHaveBeenCalled();
102
+ expect(screen.getByRole('status').textContent).toContain('"known":false');
103
+ });
@@ -0,0 +1,57 @@
1
+ 'use client';
2
+
3
+ import { useQuery } from '@tanstack/react-query';
4
+ import { listWorkspaces } from '@tuturuuu/internal-api/workspaces';
5
+ import {
6
+ useWorkspaceActor,
7
+ useWorkspaceVisibility,
8
+ } from './use-workspace-visibility';
9
+
10
+ /** UI choices only. Do not use this hook for access or membership reconciliation. */
11
+ export function useVisibleWorkspaces(
12
+ enabled = true,
13
+ limit?: number,
14
+ options?: { refetchInterval?: number }
15
+ ) {
16
+ const actor = useWorkspaceActor();
17
+ const visibility = useWorkspaceVisibility(enabled);
18
+ const query = useQuery({
19
+ queryKey: [
20
+ 'workspace-ui-list',
21
+ actor?.actorId,
22
+ ...(limit !== undefined ? [limit] : []),
23
+ ],
24
+ enabled: enabled && Boolean(actor),
25
+ refetchInterval: options?.refetchInterval,
26
+ queryFn: async () => {
27
+ actor!.assertActive();
28
+ const result = await listWorkspaces(
29
+ limit !== undefined ? { limit } : undefined
30
+ );
31
+ actor!.assertActive();
32
+ return result;
33
+ },
34
+ });
35
+ const data = visibility.known
36
+ ? query.data?.filter(
37
+ (workspace) => !visibility.hiddenIds.includes(workspace.id)
38
+ )
39
+ : undefined;
40
+ return {
41
+ ...query,
42
+ personalWorkspaceMissing:
43
+ visibility.known &&
44
+ query.data !== undefined &&
45
+ !query.data.some((workspace) => workspace.personal),
46
+ data,
47
+ isLoading:
48
+ enabled &&
49
+ (query.isLoading || (!visibility.known && !visibility.isError)),
50
+ isError: query.isError || visibility.isError,
51
+ error: query.error ?? visibility.error,
52
+ refetch: async () => {
53
+ const visibilityResult = await visibility.refetch();
54
+ return visibilityResult ? query.refetch() : null;
55
+ },
56
+ };
57
+ }
@@ -3,6 +3,7 @@
3
3
  import { useQuery } from '@tanstack/react-query';
4
4
  import { getCurrentUserProfile } from '@tuturuuu/internal-api/users';
5
5
  import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser';
6
+ import { useWorkspaceActor } from './use-workspace-visibility';
6
7
 
7
8
  /**
8
9
  * Hook to fetch the current workspace user with their private details
@@ -10,11 +11,18 @@ import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser';
10
11
  *
11
12
  * @returns Query object with user data, loading state, and error info
12
13
  */
13
- export function useWorkspaceUser() {
14
- return useQuery({
15
- queryKey: ['workspace-user'],
14
+ export function useWorkspaceUser(actorId?: string) {
15
+ const actor = useWorkspaceActor();
16
+ const resolvedActorId = actorId ?? actor?.actorId;
17
+ const query = useQuery({
18
+ queryKey: ['workspace-user', resolvedActorId],
19
+ enabled: Boolean(actor) && actor?.actorId === resolvedActorId,
16
20
  queryFn: async (): Promise<WorkspaceUser> => {
21
+ actor!.assertActive();
17
22
  const data = await getCurrentUserProfile();
23
+ actor!.assertActive();
24
+ if (data.id !== resolvedActorId)
25
+ throw new Error('Workspace account changed');
18
26
  return {
19
27
  id: data.id,
20
28
  email: data.email,
@@ -30,4 +38,8 @@ export function useWorkspaceUser() {
30
38
  gcTime: 10 * 60 * 1000, // Keep cached data for 10 minutes
31
39
  retry: 1, // Retry once on failure
32
40
  });
41
+ return {
42
+ ...query,
43
+ data: actor?.actorId === resolvedActorId ? query.data : undefined,
44
+ };
33
45
  }
@@ -0,0 +1,234 @@
1
+ 'use client';
2
+
3
+ import {
4
+ type QueryClient,
5
+ useQuery,
6
+ useQueryClient,
7
+ } from '@tanstack/react-query';
8
+ import { InternalApiError } from '@tuturuuu/internal-api/client';
9
+ import {
10
+ getCurrentUserHiddenWorkspaces,
11
+ updateCurrentUserHiddenWorkspace,
12
+ } from '@tuturuuu/internal-api/users';
13
+ import {
14
+ createContext,
15
+ createElement,
16
+ type ReactNode,
17
+ useContext,
18
+ useEffect,
19
+ useMemo,
20
+ useRef,
21
+ useState,
22
+ } from 'react';
23
+
24
+ const controls = new WeakMap<
25
+ QueryClient,
26
+ Map<string, { revision: number; pending: Set<string> }>
27
+ >();
28
+ function ownerControl(client: QueryClient, actorId: string) {
29
+ let actors = controls.get(client);
30
+ if (!actors) {
31
+ actors = new Map();
32
+ controls.set(client, actors);
33
+ }
34
+ let control = actors.get(actorId);
35
+ if (!control) {
36
+ control = { revision: 0, pending: new Set() };
37
+ actors.set(actorId, control);
38
+ }
39
+ return control;
40
+ }
41
+
42
+ type ActorScope = {
43
+ actorId: string;
44
+ lifetime: { active: boolean };
45
+ assertActive: () => void;
46
+ };
47
+ const ActorContext = createContext<ActorScope | null>(null);
48
+
49
+ /** Feed only a server-verified actor; preferences never become membership data. */
50
+ export function WorkspaceVisibilityProvider({
51
+ actorId,
52
+ children,
53
+ }: {
54
+ actorId: string;
55
+ children: ReactNode;
56
+ }) {
57
+ const parent = useContext(ActorContext);
58
+ const current = useRef(actorId);
59
+ current.current = actorId;
60
+ const scope = useMemo(() => {
61
+ if (parent?.actorId === actorId) return parent;
62
+ const lifetime = { active: true };
63
+ return {
64
+ actorId,
65
+ lifetime,
66
+ assertActive() {
67
+ parent?.assertActive();
68
+ if (
69
+ !lifetime.active ||
70
+ current.current !== actorId ||
71
+ (parent && parent.actorId !== actorId)
72
+ ) {
73
+ throw new Error('Workspace account changed');
74
+ }
75
+ },
76
+ };
77
+ }, [actorId, parent]);
78
+ const client = useQueryClient();
79
+ useEffect(() => {
80
+ if (scope === parent) return;
81
+ scope.lifetime.active = true;
82
+ return () => {
83
+ scope.lifetime.active = false;
84
+ controls.get(client)?.delete(scope.actorId);
85
+ // A private cache is never retained across logout/account replacement.
86
+ client.removeQueries({ queryKey: ['workspace-hidden', scope.actorId] });
87
+ client.removeQueries({ queryKey: ['workspace-ui-list', scope.actorId] });
88
+ client.removeQueries({ queryKey: ['user-workspaces', scope.actorId] });
89
+ client.removeQueries({ queryKey: ['all-user-boards', scope.actorId] });
90
+ client.removeQueries({ queryKey: ['chat-workspaces', scope.actorId] });
91
+ client.removeQueries({ queryKey: ['task-source-boards', scope.actorId] });
92
+ client.removeQueries({ queryKey: ['workspace-user', scope.actorId] });
93
+ client.removeQueries({
94
+ queryKey: ['workspace-invitations', scope.actorId],
95
+ });
96
+ client.removeQueries({
97
+ predicate: (query) =>
98
+ query.queryKey[0] === 'global-command-launcher' &&
99
+ query.queryKey.includes(scope.actorId),
100
+ });
101
+ client.removeQueries({
102
+ queryKey: ['workspace-select-current-workspace', scope.actorId],
103
+ });
104
+ };
105
+ }, [scope, parent, client]);
106
+ return createElement(
107
+ ActorContext.Provider,
108
+ {
109
+ key: actorId,
110
+ value: parent && parent.actorId !== actorId ? null : scope,
111
+ },
112
+ children
113
+ );
114
+ }
115
+
116
+ export function useWorkspaceActor() {
117
+ return useContext(ActorContext);
118
+ }
119
+
120
+ export function useWorkspaceVisibility(enabled = true) {
121
+ const scope = useWorkspaceActor();
122
+ const actorId = scope?.actorId;
123
+ const client = useQueryClient();
124
+ const key = ['workspace-hidden', actorId] as const;
125
+ const control = ownerControl(client, actorId ?? 'signed-out');
126
+ const [pending, setPending] = useState<Set<string>>(new Set());
127
+ const [updateError, setUpdateError] = useState<Error | null>(null);
128
+ const query = useQuery({
129
+ queryKey: key,
130
+ enabled: enabled && Boolean(scope),
131
+ queryFn: async () => {
132
+ scope!.assertActive();
133
+ if (control.pending.size > 0) {
134
+ const optimistic = client.getQueryData<string[]>(key);
135
+ if (optimistic) return optimistic;
136
+ }
137
+ const requestRevision = control.revision;
138
+ const response = await getCurrentUserHiddenWorkspaces(actorId!);
139
+ scope!.assertActive();
140
+ // A refresh started before a mutation cannot overwrite its optimistic state.
141
+ if (requestRevision !== control.revision) {
142
+ return (
143
+ client.getQueryData<string[]>(key) ?? response.hiddenWorkspaceIds
144
+ );
145
+ }
146
+ return response.hiddenWorkspaceIds;
147
+ },
148
+ staleTime: 30_000,
149
+ retry: 1,
150
+ });
151
+ const accountDenied =
152
+ query.error instanceof InternalApiError &&
153
+ [401, 403, 409].includes(query.error.status);
154
+ const ids = accountDenied ? undefined : query.data;
155
+ async function setHidden(workspaceId: string, hidden: boolean) {
156
+ if (!scope || !ids || control.pending.has(workspaceId)) return;
157
+ scope.assertActive();
158
+ const ownerKey = ['workspace-hidden', scope.actorId] as const;
159
+ const wasHidden = ids.includes(workspaceId);
160
+ control.pending.add(workspaceId);
161
+ setPending(new Set(control.pending));
162
+ setUpdateError(null);
163
+ control.revision++;
164
+ await client.cancelQueries({ queryKey: ownerKey });
165
+ scope.assertActive();
166
+ client.setQueryData<string[]>(ownerKey, (current = []) =>
167
+ hidden
168
+ ? [...new Set([...current, workspaceId])]
169
+ : current.filter((id) => id !== workspaceId)
170
+ );
171
+ try {
172
+ await updateCurrentUserHiddenWorkspace(
173
+ workspaceId,
174
+ hidden,
175
+ scope.actorId
176
+ );
177
+ scope.assertActive();
178
+ } catch (error) {
179
+ try {
180
+ scope.assertActive();
181
+ } catch {
182
+ return;
183
+ }
184
+ client.setQueryData<string[]>(ownerKey, (current = []) =>
185
+ wasHidden
186
+ ? [...new Set([...current, workspaceId])]
187
+ : current.filter((id) => id !== workspaceId)
188
+ );
189
+ setUpdateError(
190
+ error instanceof Error
191
+ ? error
192
+ : new Error('Unable to update Hidden workspaces')
193
+ );
194
+ throw error;
195
+ } finally {
196
+ control.pending.delete(workspaceId);
197
+ try {
198
+ scope.assertActive();
199
+ setPending(new Set(control.pending));
200
+ if (control.pending.size === 0) {
201
+ void client.invalidateQueries({ queryKey: ownerKey });
202
+ }
203
+ } catch {
204
+ /* An old account cannot publish into the next account. */
205
+ }
206
+ }
207
+ }
208
+ return {
209
+ ...query,
210
+ refetch: async () => {
211
+ if (!scope) return null;
212
+ try {
213
+ scope.assertActive();
214
+ } catch {
215
+ return null;
216
+ }
217
+ const result = await query.refetch();
218
+ try {
219
+ scope.assertActive();
220
+ } catch {
221
+ return null;
222
+ }
223
+ if (!result.error) setUpdateError(null);
224
+ return result;
225
+ },
226
+ accountDenied,
227
+ actorId,
228
+ hiddenIds: ids ?? [],
229
+ known: ids !== undefined,
230
+ pending,
231
+ updateError,
232
+ setHidden,
233
+ };
234
+ }