diff --git a/console/web/src/hooks/use-workspace-tabs.ts b/console/web/src/hooks/use-workspace-tabs.ts index c2de1c98c..bb82501c2 100644 --- a/console/web/src/hooks/use-workspace-tabs.ts +++ b/console/web/src/hooks/use-workspace-tabs.ts @@ -16,7 +16,7 @@ */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { type ConsoleConfigValue, fetchConsoleConfigValue, @@ -29,6 +29,7 @@ import { parseActiveTabId, parseWorkspaceTabs, resolveActiveTab, + shouldFlushPendingWrite, type TabScreen, tabColumns, type WorkspaceLayoutSource, @@ -161,8 +162,22 @@ export function useWorkspaceTabs(): UseWorkspaceTabsReturn { }, }) + // A mutation fired before the first server answer arrives (a keyboard + // shortcut, a worker's panel-open) must not be lost when `tabs` flips from + // the local copy to the server layout. The latest such write is held and + // replayed through the server path once hydrated. + const pendingWriteRef = useRef<{ + tabs: WorkspaceTab[] + activeTabId: string + } | null>(null) + const persist = useCallback( (nextTabs: WorkspaceTab[], nextActiveId: string) => { + if (layoutSource === 'pending') { + pendingWriteRef.current = { tabs: nextTabs, activeTabId: nextActiveId } + setLocal({ tabs: nextTabs, activeTabId: nextActiveId }) + return + } if (available) { const transform = (value: ConsoleConfigValue) => withActiveTabId(withWorkspaceTabs(value, nextTabs), nextActiveId) @@ -179,9 +194,16 @@ export function useWorkspaceTabs(): UseWorkspaceTabsReturn { persistLocal(nextState) } }, - [available, mutation, qc], + [available, layoutSource, mutation, qc], ) + useEffect(() => { + const pending = pendingWriteRef.current + if (!shouldFlushPendingWrite(layoutSource, pending !== null)) return + pendingWriteRef.current = null + if (pending !== null) persist(pending.tabs, pending.activeTabId) + }, [layoutSource, persist]) + const activateTab = useCallback( (id: string) => { setChosenTabId(id) diff --git a/console/web/src/lib/workspace-tabs.test.ts b/console/web/src/lib/workspace-tabs.test.ts index ba75a9408..d333c9b06 100644 --- a/console/web/src/lib/workspace-tabs.test.ts +++ b/console/web/src/lib/workspace-tabs.test.ts @@ -9,6 +9,7 @@ import { resolveActiveTab, screenForView, screenLabel, + shouldFlushPendingWrite, tabColumns, tabLabel, tabSizes, @@ -450,3 +451,13 @@ describe('workspaceLayoutSource', () => { ]) }) }) + +describe('shouldFlushPendingWrite', () => { + it('flushes only once hydrated and something is queued', () => { + expect(shouldFlushPendingWrite('pending', true)).toBe(false) + expect(shouldFlushPendingWrite('pending', false)).toBe(false) + expect(shouldFlushPendingWrite('server', false)).toBe(false) + expect(shouldFlushPendingWrite('server', true)).toBe(true) + expect(shouldFlushPendingWrite('local', true)).toBe(true) + }) +}) diff --git a/console/web/src/lib/workspace-tabs.ts b/console/web/src/lib/workspace-tabs.ts index 6933be18c..02f3fe940 100644 --- a/console/web/src/lib/workspace-tabs.ts +++ b/console/web/src/lib/workspace-tabs.ts @@ -360,6 +360,16 @@ export function withActiveTabId( it is not (the localStorage copy). */ export type WorkspaceLayoutSource = 'pending' | 'server' | 'local' +/** A queued pre-hydration write should replay once the layout source is no + longer `pending` (server hydrated, or known unavailable) and something is + actually queued. */ +export function shouldFlushPendingWrite( + source: WorkspaceLayoutSource, + hasPending: boolean, +): boolean { + return source !== 'pending' && hasPending +} + export function workspaceLayoutSource( fetched: boolean, available: boolean,