Skip to content

Commit 9bb4a82

Browse files
committed
fix(workspaces): serialize same-workspace pin toggles and tolerate replays
Splitting pins into rows removed the lost-update race between *different* workspaces but not the one on a single row: pin then unpin the same workspace and the DELETE could overtake its INSERT, delete nothing, and leave the workspace pinned. A mutation scope serializes them; TanStack runs onMutate before the scope gate, so the optimistic update is still immediate. Both duplicate-click replays now resolve to their end state rather than erroring — a repeat pin answers 409, a repeat unpin 404, and each means the row is already how the caller wants it. Rollback undoes its own toggle instead of restoring a snapshot, so a sibling toggle's optimistic state survives. Also: cap the switcher to the height Radix measured, since six rows can push the footer actions off a short viewport with nothing able to scroll to them; drop a dead pinned-item invalidation and a redundant ref; return the pin set from the hook to match usePinnedIds; and exclude workspace pins from the unscoped pinned-items listing, where they would read as a resource inside themselves.
1 parent 55287e0 commit 9bb4a82

8 files changed

Lines changed: 137 additions & 99 deletions

File tree

apps/sim/app/api/pinned-items/route.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { db, pinnedItem } from '@sim/db'
22
import { createLogger } from '@sim/logger'
33
import { getPostgresErrorCode } from '@sim/utils/errors'
44
import { generateId } from '@sim/utils/id'
5-
import { and, eq } from 'drizzle-orm'
5+
import { and, eq, ne } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import {
88
createPinnedItemContract,
@@ -59,7 +59,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
5959
and(
6060
eq(pinnedItem.userId, session.user.id),
6161
eq(pinnedItem.workspaceId, workspaceId),
62-
resourceType ? eq(pinnedItem.resourceType, resourceType) : undefined
62+
/**
63+
* A `workspace` pin stores `workspaceId === resourceId`, so it would otherwise
64+
* appear in this workspace's unscoped listing as a resource *inside* itself.
65+
* It is read from the workspace-list payload instead, so it is excluded here
66+
* rather than left for a future unscoped caller to mistake for a real resource.
67+
*/
68+
resourceType
69+
? eq(pinnedItem.resourceType, resourceType)
70+
: ne(pinnedItem.resourceType, 'workspace')
6371
)
6472
)
6573

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,11 @@ function WorkspaceHeaderImpl({
170170
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
171171
const [menuOpenWorkspaceId, setMenuOpenWorkspaceId] = useState<string | null>(null)
172172
const contextMenuRef = useRef<HTMLDivElement | null>(null)
173+
/**
174+
* The row a context-menu action targets. Set alongside `menuOpenWorkspaceId` in
175+
* {@link openContextMenuAt}, but only that state re-renders — so anything the menu
176+
* *renders* must read the state, and only handlers may read this ref.
177+
*/
173178
const capturedWorkspaceRef = useRef<Workspace | null>(null)
174179
/**
175180
* Set by context-menu actions whose result is only visible in the still-open
@@ -389,10 +394,6 @@ function WorkspaceHeaderImpl({
389394
}
390395
}
391396

392-
/**
393-
* Pinning leaves the switcher open: the row jumps to the pinned group, and
394-
* closing the menu would hide the only feedback that the action landed.
395-
*/
396397
const handleTogglePinAction = () => {
397398
const target = capturedWorkspaceRef.current
398399
if (!target) return
@@ -540,7 +541,11 @@ function WorkspaceHeaderImpl({
540541
align='start'
541542
side={isCollapsed ? 'right' : 'bottom'}
542543
sideOffset={isCollapsed ? 16 : 8}
543-
className='flex max-h-none flex-col overflow-hidden'
544+
/* Overrides the 240px default cap so the six-row list is not clipped, but
545+
still bounded by the space Radix measured — at six rows the menu is tall
546+
enough that a short viewport would otherwise push the footer actions off
547+
screen with nothing able to scroll to them. */
548+
className='flex max-h-[var(--radix-dropdown-menu-content-available-height,400px)] flex-col overflow-y-auto'
544549
style={{
545550
width: `${SIDEBAR_WIDTH.DEFAULT}px`,
546551
maxWidth: 'calc(100vw - 24px)',
@@ -743,8 +748,6 @@ function WorkspaceHeaderImpl({
743748
{workspace.name}
744749
</span>
745750
{pinnedWorkspaceIds.has(workspace.id) && (
746-
/* `Pin` hardcodes `aria-hidden` ahead of its prop spread,
747-
so un-hiding it is what makes the label announce. */
748751
<Pin
749752
aria-hidden={false}
750753
role='img'
@@ -887,11 +890,6 @@ function WorkspaceHeaderImpl({
887890
onTogglePin={handleTogglePinAction}
888891
onUploadLogo={handleUploadLogoAction}
889892
showPin={true}
890-
/**
891-
* Read from state, not `capturedWorkspaceRef` — both are set together in
892-
* `openContextMenuAt`, but only the state re-renders, so the ref would
893-
* leave the Pin/Unpin label showing the previous row's value.
894-
*/
895893
isPinned={Boolean(menuOpenWorkspaceId && pinnedWorkspaceIds.has(menuOpenWorkspaceId))}
896894
showRename={true}
897895
showUploadLogo={!!onUploadLogo}

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ vi.mock('@/hooks/queries/workspace', () => ({
3838
useWorkspaceCreationPolicy: mockUseWorkspaceCreationPolicy,
3939
useWorkspacesQuery: mockUseWorkspacesQuery,
4040
/** No pins: this suite is about the deep-link guard, not switcher ordering. */
41-
usePinnedWorkspaceIds: () => ({ data: [] }),
41+
EMPTY_PINNED_WORKSPACE_IDS: new Set<string>(),
42+
usePinnedWorkspaceIds: () => ({ data: new Set<string>() }),
4243
useToggleWorkspacePin: () => ({ mutate: vi.fn() }),
4344
}))
4445

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workspace-management.ts

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { updateUserSettingsContract } from '@/lib/api/contracts'
66
import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage'
77
import { useLeaveWorkspace } from '@/hooks/queries/invitations'
88
import {
9+
EMPTY_PINNED_WORKSPACE_IDS,
910
useCreateWorkspace,
1011
useDeleteWorkspace,
1112
usePinnedWorkspaceIds,
@@ -47,7 +48,9 @@ export function useWorkspaceManagement({
4748
const { data: workspaceCreationPolicy = null } = useWorkspaceCreationPolicy(
4849
Boolean(sessionUserId)
4950
)
50-
const { data: pinnedWorkspaceIdList } = usePinnedWorkspaceIds(Boolean(sessionUserId))
51+
const { data: pinnedWorkspaceIds = EMPTY_PINNED_WORKSPACE_IDS } = usePinnedWorkspaceIds(
52+
Boolean(sessionUserId)
53+
)
5154
const { mutate: toggleWorkspacePinMutate } = useToggleWorkspacePin()
5255

5356
const leaveWorkspaceMutation = useLeaveWorkspace()
@@ -91,11 +94,6 @@ export function useWorkspaceManagement({
9194
}, 1000)
9295
}, [])
9396

94-
const pinnedWorkspaceIds = useMemo(
95-
() => new Set(pinnedWorkspaceIdList ?? []),
96-
[pinnedWorkspaceIdList]
97-
)
98-
9997
/**
10098
* Pinned workspaces float to the top, recency ordering them within each group.
10199
* Matches `resource-sort.ts`: pinning is a user-declared priority layered over
@@ -114,18 +112,11 @@ export function useWorkspaceManagement({
114112
// eslint-disable-next-line react-hooks/exhaustive-deps
115113
}, [workspaces, recencySortKey, pinnedWorkspaceIds])
116114

117-
const pinnedWorkspaceIdsRef = useRef<Set<string>>(pinnedWorkspaceIds)
118-
pinnedWorkspaceIdsRef.current = pinnedWorkspaceIds
119-
120-
/** Reads the current pins through a ref so the callback identity stays stable for the memoized switcher. */
121115
const toggleWorkspacePin = useCallback(
122116
(workspaceId: string) => {
123-
toggleWorkspacePinMutate({
124-
workspaceId,
125-
pinned: !pinnedWorkspaceIdsRef.current.has(workspaceId),
126-
})
117+
toggleWorkspacePinMutate({ workspaceId, pinned: !pinnedWorkspaceIds.has(workspaceId) })
127118
},
128-
[toggleWorkspacePinMutate]
119+
[pinnedWorkspaceIds, toggleWorkspacePinMutate]
129120
)
130121

131122
const activeWorkspace = useMemo(() => {

apps/sim/hooks/queries/workspace.test.tsx

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ function readPins(queryClient: QueryClient): string[] | undefined {
7878
?.pinnedWorkspaceIds
7979
}
8080

81+
function apiError(status: number) {
82+
return new ApiClientError({ status, message: `status ${status}`, body: {} })
83+
}
84+
8185
afterEach(() => {
8286
act(() => {
8387
for (const root of mountedRoots.splice(0)) root.unmount()
@@ -121,10 +125,11 @@ describe('useToggleWorkspacePin', () => {
121125
})
122126

123127
/**
124-
* Two rapid toggles write different rows, so neither can overwrite the other and
125-
* both survive regardless of the order the requests land in.
128+
* Pin then unpin the same workspace race on the same row: an unpin that overtook
129+
* its pin would delete nothing and leave the workspace pinned. The mutation scope
130+
* must hold the second request until the first resolves.
126131
*/
127-
it('keeps both pins when two toggles overlap', async () => {
132+
it('serializes toggles of the same workspace so the last one wins', async () => {
128133
const resolvers: Array<() => void> = []
129134
mockRequestJson.mockImplementation(
130135
() => new Promise((resolve) => resolvers.push(() => resolve({ pinnedItem: {} })))
@@ -134,29 +139,44 @@ describe('useToggleWorkspacePin', () => {
134139

135140
act(() => {
136141
getResult().mutate({ workspaceId: 'ws-a', pinned: true })
137-
getResult().mutate({ workspaceId: 'ws-b', pinned: true })
142+
getResult().mutate({ workspaceId: 'ws-a', pinned: false })
138143
})
139144
await flush()
140145

141-
expect(readPins(queryClient)).toEqual(['ws-a', 'ws-b'])
146+
// The unpin must not be on the wire while the pin is still outstanding.
147+
expect(mockRequestJson).toHaveBeenCalledOnce()
148+
expect(mockRequestJson.mock.calls[0][0]).toBe(createPinnedItemContract)
149+
// Optimistic state already reflects the user's last click.
150+
expect(readPins(queryClient)).toEqual([])
151+
152+
act(() => resolvers[0]())
153+
await flush()
154+
155+
expect(mockRequestJson).toHaveBeenCalledTimes(2)
156+
expect(mockRequestJson.mock.calls[1][0]).toBe(deletePinnedItemContract)
157+
expect(readPins(queryClient)).toEqual([])
158+
})
159+
160+
/** Both duplicate-click outcomes mean the row is already in the requested end state. */
161+
it.each([
162+
['pin', true, 409],
163+
['unpin', false, 404],
164+
])('treats a duplicate %s (%s) as success', async (_label, pinned, status) => {
165+
mockRequestJson.mockRejectedValue(apiError(status as number))
166+
const { getResult, queryClient } = renderHookWithClient(() => useToggleWorkspacePin())
167+
seedList(queryClient, pinned ? [] : ['ws-a'])
142168

143-
// Resolve out of order — the older request landing last must not undo the newer.
144169
act(() => {
145-
resolvers[1]()
146-
resolvers[0]()
170+
getResult().mutate({ workspaceId: 'ws-a', pinned: pinned as boolean })
147171
})
148172
await flush()
149173

150-
const bodies = mockRequestJson.mock.calls.map((call) => call[1].body.resourceId)
151-
expect(bodies).toEqual(['ws-a', 'ws-b'])
152-
expect(readPins(queryClient)).toEqual(['ws-a', 'ws-b'])
174+
expect(getResult().isError).toBe(false)
175+
expect(readPins(queryClient)).toEqual(pinned ? ['ws-a'] : [])
153176
})
154177

155-
/** Re-pinning an already-pinned workspace is the desired end state, not a failure. */
156-
it('treats a 409 from a duplicate pin as success', async () => {
157-
mockRequestJson.mockRejectedValue(
158-
new ApiClientError({ status: 409, message: 'This item is already pinned', body: {} })
159-
)
178+
it('rolls the optimistic pin back when the write fails', async () => {
179+
mockRequestJson.mockRejectedValue(apiError(500))
160180
const { getResult, queryClient } = renderHookWithClient(() => useToggleWorkspacePin())
161181
seedList(queryClient, [])
162182

@@ -165,22 +185,28 @@ describe('useToggleWorkspacePin', () => {
165185
})
166186
await flush()
167187

168-
expect(getResult().isError).toBe(false)
169-
expect(readPins(queryClient)).toEqual(['ws-a'])
188+
expect(readPins(queryClient)).toEqual([])
170189
})
171190

172-
it('rolls the optimistic pin back when the write fails', async () => {
173-
mockRequestJson.mockRejectedValue(
174-
new ApiClientError({ status: 500, message: 'boom', body: {} })
191+
/**
192+
* The rollback undoes its own toggle rather than restoring a snapshot, so a
193+
* concurrent toggle's optimistic state survives a sibling's failure.
194+
*/
195+
it('does not drop a concurrent toggle when one fails', async () => {
196+
mockRequestJson.mockImplementation((contract: unknown) =>
197+
contract === createPinnedItemContract && mockRequestJson.mock.calls.length === 1
198+
? Promise.reject(apiError(500))
199+
: Promise.resolve({ pinnedItem: {} })
175200
)
176201
const { getResult, queryClient } = renderHookWithClient(() => useToggleWorkspacePin())
177202
seedList(queryClient, [])
178203

179204
act(() => {
180205
getResult().mutate({ workspaceId: 'ws-a', pinned: true })
206+
getResult().mutate({ workspaceId: 'ws-b', pinned: true })
181207
})
182208
await flush()
183209

184-
expect(readPins(queryClient)).toEqual([])
210+
expect(readPins(queryClient)).toEqual(['ws-b'])
185211
})
186212
})

apps/sim/hooks/queries/workspace.ts

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import {
2020
type WorkspaceQueryScope,
2121
type WorkspacesResponse,
2222
} from '@/lib/api/contracts'
23-
import { pinnedItemKeys } from '@/hooks/queries/utils/pinned-item-keys'
2423
import {
2524
normalizeWorkspace,
2625
normalizeWorkspacesResponse,
@@ -101,13 +100,16 @@ export function useWorkspaceCreationPolicy(enabled = true) {
101100
})
102101
}
103102

104-
const selectPinnedWorkspaceIds = (data: WorkspacesResponse): string[] => data.pinnedWorkspaceIds
103+
export const EMPTY_PINNED_WORKSPACE_IDS: ReadonlySet<string> = new Set()
104+
105+
const selectPinnedWorkspaceIds = (data: WorkspacesResponse): ReadonlySet<string> =>
106+
data.pinnedWorkspaceIds.length ? new Set(data.pinnedWorkspaceIds) : EMPTY_PINNED_WORKSPACE_IDS
105107

106108
/**
107-
* The viewer's pinned workspace ids, read off the workspace list the switcher
108-
* already loads — pins ride along on that payload rather than costing a second
109-
* request, which is also what lets the server prefetch hydrate them in the same
110-
* pass and keeps pinned-first ordering from re-sorting after hydration.
109+
* The viewer's pinned workspace ids, as a `Set` so a row resolves its pin state in
110+
* O(1) — mirroring {@link usePinnedIds} for the workspace-scoped kinds. Sourced
111+
* from the workspace list rather than `/api/pinned-items`; see
112+
* `pinnedResourceTypeSchema` for why that is the one kind read this way.
111113
*/
112114
export function usePinnedWorkspaceIds(enabled = true) {
113115
return useQuery({
@@ -119,58 +121,75 @@ export function usePinnedWorkspaceIds(enabled = true) {
119121
})
120122
}
121123

124+
/** Applies one toggle to a pin list. Idempotent, so replaying it cannot double-apply. */
125+
function applyPinToggle(pinnedWorkspaceIds: string[], workspaceId: string, pinned: boolean) {
126+
const without = pinnedWorkspaceIds.filter((id) => id !== workspaceId)
127+
return pinned ? [...without, workspaceId] : without
128+
}
129+
122130
/**
123131
* Pins or unpins a workspace in the switcher.
124132
*
125-
* Writes one `pinned_item` row per pin, so a pin is an insert and an unpin is a
126-
* delete. Two rapid toggles touch different rows and cannot overwrite each other,
127-
* which is why nothing here serializes or debounces. Re-pinning an already-pinned
128-
* workspace answers 409 and unpinning an absent pin is a no-op, so a duplicate
129-
* click is idempotent rather than an error.
133+
* A pin is one `pinned_item` row, so pinning inserts and unpinning deletes. Both
134+
* are idempotent against their own end state — a duplicate pin answers 409 and a
135+
* duplicate unpin answers 404, and each means the row is already how the caller
136+
* wants it, so neither is a failure to roll back.
130137
*
131-
* Optimistic against the workspace list, because that is where the pins are read
132-
* and the toggle re-sorts the row under the user's cursor.
138+
* Ordering still matters, because pinning and unpinning the *same* workspace race
139+
* on the *same* row: an unpin that overtook its pin would delete nothing and leave
140+
* the workspace pinned. `scope` serializes them, and because TanStack runs
141+
* `onMutate` before the scope gate, the optimistic update is still immediate.
133142
*/
134143
export function useToggleWorkspacePin() {
135144
const queryClient = useQueryClient()
136145
const queryKey = workspaceKeys.list('active')
137146

138147
return useMutation({
148+
scope: { id: 'workspace-pin' },
139149
mutationFn: async ({ workspaceId, pinned }: { workspaceId: string; pinned: boolean }) => {
140-
if (!pinned) {
141-
await requestJson(deletePinnedItemContract, {
142-
params: { resourceType: 'workspace', resourceId: workspaceId },
143-
})
144-
return
145-
}
146150
try {
147-
await requestJson(createPinnedItemContract, {
148-
body: { workspaceId, resourceType: 'workspace', resourceId: workspaceId },
149-
})
151+
if (pinned) {
152+
await requestJson(createPinnedItemContract, {
153+
body: { workspaceId, resourceType: 'workspace', resourceId: workspaceId },
154+
})
155+
} else {
156+
await requestJson(deletePinnedItemContract, {
157+
params: { resourceType: 'workspace', resourceId: workspaceId },
158+
})
159+
}
150160
} catch (error) {
151-
/** Already pinned — the desired state, so not a failure to roll back. */
152-
if (error instanceof ApiClientError && error.status === 409) return
161+
const alreadyInEndState = pinned ? 409 : 404
162+
if (error instanceof ApiClientError && error.status === alreadyInEndState) return
153163
throw error
154164
}
155165
},
156166
onMutate: async ({ workspaceId, pinned }) => {
157167
await queryClient.cancelQueries({ queryKey })
158-
const previous = queryClient.getQueryData<WorkspacesResponse>(queryKey)
159-
queryClient.setQueryData<WorkspacesResponse>(queryKey, (old) => {
160-
if (!old) return old
161-
const next = pinned
162-
? [...old.pinnedWorkspaceIds.filter((id) => id !== workspaceId), workspaceId]
163-
: old.pinnedWorkspaceIds.filter((id) => id !== workspaceId)
164-
return { ...old, pinnedWorkspaceIds: next }
165-
})
166-
return { previous }
168+
queryClient.setQueryData<WorkspacesResponse>(queryKey, (old) =>
169+
old
170+
? {
171+
...old,
172+
pinnedWorkspaceIds: applyPinToggle(old.pinnedWorkspaceIds, workspaceId, pinned),
173+
}
174+
: old
175+
)
167176
},
168-
onError: (_error, _variables, context) => {
169-
if (context?.previous) queryClient.setQueryData(queryKey, context.previous)
177+
/**
178+
* Undoes this toggle rather than restoring a snapshot: a snapshot taken before
179+
* a concurrent toggle would silently drop that one's optimistic state too.
180+
*/
181+
onError: (_error, { workspaceId, pinned }) => {
182+
queryClient.setQueryData<WorkspacesResponse>(queryKey, (old) =>
183+
old
184+
? {
185+
...old,
186+
pinnedWorkspaceIds: applyPinToggle(old.pinnedWorkspaceIds, workspaceId, !pinned),
187+
}
188+
: old
189+
)
170190
},
171191
onSettled: () => {
172192
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
173-
queryClient.invalidateQueries({ queryKey: pinnedItemKeys.all })
174193
},
175194
})
176195
}

0 commit comments

Comments
 (0)