From c45467596ec755bd5f0cfb1937625e7952549556 Mon Sep 17 00:00:00 2001 From: Wibus Wu <62133302+wibus-wee@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:37:49 +0800 Subject: [PATCH 1/4] fix(components): fence workspace sidebar scope Publish workspace identity atomically and require route, runtime, and metadata ownership to agree before starting or rendering workspace-scoped sidebar data. Model: GPT-5 --- packages/components/src/atoms/doc-meta.ts | 25 ++++- .../components/src/atoms/workspace-context.ts | 48 +++++++++- .../src/components/loro-app-sidebar.tsx | 57 ++++++++--- .../onboarding/screens/workspace-screen.tsx | 13 +-- .../src/hooks/use-session-sharing.ts | 21 +++- .../src/hooks/use-visible-local-projects.ts | 19 +++- .../src/hooks/use-visible-machine-metas.ts | 32 ++++--- .../src/hooks/use-visible-session-metas.ts | 74 +++++++++----- .../src/hooks/use-workspace-context-atoms.ts | 36 +++---- .../components/src/hooks/useOrganization.ts | 67 +++++++------ .../src/lib/workspace-data-scope.ts | 45 +++++++++ packages/components/src/providers/AGENTS.md | 8 ++ .../src/providers/workspace-route-target.tsx | 21 ++++ .../components/src/routes/$workspaceName.tsx | 15 +-- packages/components/src/routes/__root.tsx | 12 +-- .../tests/doc-meta-subscription.test.ts | 10 +- .../tests/use-session-sharing.test.tsx | 6 ++ .../tests/workspace-context.test.ts | 96 +++++++++++++++++++ .../tests/workspace-data-scope.test.ts | 93 ++++++++++++++++++ 19 files changed, 569 insertions(+), 129 deletions(-) create mode 100644 packages/components/src/lib/workspace-data-scope.ts create mode 100644 packages/components/src/providers/workspace-route-target.tsx create mode 100644 packages/components/tests/workspace-context.test.ts create mode 100644 packages/components/tests/workspace-data-scope.test.ts diff --git a/packages/components/src/atoms/doc-meta.ts b/packages/components/src/atoms/doc-meta.ts index 0c8778b36..ee58040bf 100644 --- a/packages/components/src/atoms/doc-meta.ts +++ b/packages/components/src/atoms/doc-meta.ts @@ -13,7 +13,7 @@ import { type AgentConfigMeta, type SessionId, } from '@lody/shared'; -import { activeWorkspaceRuntimeAtom } from './runtime'; +import { activeWorkspaceRuntimeAtom, type WorkspaceRuntime } from './runtime'; import { mergeBootstrapMetaCache } from '@/lib/doc-meta-bootstrap'; import { listDocMetaEntries } from '@/lib/doc-meta-batch'; import { getDocMetaRoomKind, withDerivedDocMetaId } from '@/lib/doc-meta-room'; @@ -209,6 +209,16 @@ export const machineMetaCacheAtom = atom>({}); export const agentConfigMetaCacheAtom = atom>({}); export const docMetaCacheReadyAtom = atom(false); +export type DocMetaCacheScope = { + runtime: WorkspaceRuntime; + workspaceId: WorkspaceRuntime['workspaceId']; + workspaceSlug: string; + ready: boolean; +}; + +/** Identifies which runtime owns the current singleton metadata projection. */ +export const docMetaCacheScopeAtom = atom(null); + // 兼容层 // Doc-meta atoms expose durable CRDT state only. Live signals (machine online, // session working state) come from the presence atoms; the old @@ -500,11 +510,18 @@ export const docMetaSubscriptionAtom = atomEffect((get, set) => { if (!runtime) { set(clearDocMetaCacheAtom); set(docMetaCacheReadyAtom, false); + set(docMetaCacheScopeAtom, null); return undefined; } let cancelled = false; set(docMetaCacheReadyAtom, false); + set(docMetaCacheScopeAtom, { + runtime, + workspaceId: runtime.workspaceId, + workspaceSlug: runtime.workspaceSlug, + ready: false, + }); // Track active docs whose initial fetchDocMeta returned null (metadata not // yet synced). When a subsequent doc-metadata patch event arrives for one of @@ -804,6 +821,12 @@ export const docMetaSubscriptionAtom = atomEffect((get, set) => { mergeBootstrapMetaCache(cache.agents, prev, existenceStateByDocId) ); set(docMetaCacheReadyAtom, true); + set(docMetaCacheScopeAtom, { + runtime, + workspaceId: runtime.workspaceId, + workspaceSlug: runtime.workspaceSlug, + ready: true, + }); }); return () => { diff --git a/packages/components/src/atoms/workspace-context.ts b/packages/components/src/atoms/workspace-context.ts index 88897962e..cb9f6a740 100644 --- a/packages/components/src/atoms/workspace-context.ts +++ b/packages/components/src/atoms/workspace-context.ts @@ -1,5 +1,49 @@ import { atom } from 'jotai'; import type { WorkspaceId } from '@lody/shared'; -export const currentWorkspaceIdAtom = atom(null); -export const currentWorkspaceSlugAtom = atom(null); +export type WorkspaceContext = { + slug: string | null; + workspaceId: WorkspaceId | null; +}; + +const workspaceContextAtom = atom({ + slug: null, + workspaceId: null, +}); + +/** Publish a route target and its resolved id as one observable state change. */ +export const setWorkspaceContextAtom = atom(null, (_get, set, context: WorkspaceContext) => + set(workspaceContextAtom, context) +); + +/** Clear only the route scope that is actually unmounting. */ +export const clearWorkspaceContextForSlugAtom = atom(null, (get, set, slug: string) => { + if (get(workspaceContextAtom).slug === slug) { + set(workspaceContextAtom, { slug: null, workspaceId: null }); + } +}); + +// Compatibility views for existing consumers. A route slug change clears the +// previous route's id in the same Jotai transaction, while an initial slug write +// preserves an id staged by legacy setup code. +export const currentWorkspaceIdAtom = atom( + (get) => get(workspaceContextAtom).workspaceId, + (get, set, workspaceId: WorkspaceId | null) => { + const current = get(workspaceContextAtom); + set(workspaceContextAtom, { ...current, workspaceId }); + } +); + +export const currentWorkspaceSlugAtom = atom( + (get) => get(workspaceContextAtom).slug, + (get, set, slug: string | null) => { + const current = get(workspaceContextAtom); + set(workspaceContextAtom, { + slug, + workspaceId: + slug === null || (current.slug !== null && current.slug !== slug) + ? null + : current.workspaceId, + }); + } +); diff --git a/packages/components/src/components/loro-app-sidebar.tsx b/packages/components/src/components/loro-app-sidebar.tsx index e69333595..9557e4925 100644 --- a/packages/components/src/components/loro-app-sidebar.tsx +++ b/packages/components/src/components/loro-app-sidebar.tsx @@ -14,6 +14,7 @@ import { type SessionId, type SessionMeta, type SessionStatus, + type WorkspaceId, } from '@lody/shared'; import { useTranslation } from 'react-i18next'; import { cloudOperations } from '@/lib/cloud-api-operations'; @@ -43,8 +44,11 @@ import { bugReportDialogOpenAtom, currentWorkspaceIdAtom, currentWorkspaceSlugAtom, + setWorkspaceContextAtom, } from '@/atoms'; -import { docMetaCacheReadyAtom } from '@/atoms/doc-meta'; +import { docMetaCacheScopeAtom } from '@/atoms/doc-meta'; +import { useWorkspaceRouteTargetSlug } from '@/providers/workspace-route-target'; +import { resolveWorkspaceDataScope } from '@/lib/workspace-data-scope'; import { tasksFeatureEnabledAtom } from '@/atoms/settings'; import { taskQuickAddOpenAtom, taskQuickAddStatusAtom } from '@/atoms/tasks'; @@ -986,7 +990,10 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) { const user = useAtomValue(userAtom); const userId = user?.id ?? null; const workspaceId = useAtomValue(currentWorkspaceIdAtom); - const workspaceSlug = useAtomValue(currentWorkspaceSlugAtom); + const atomWorkspaceSlug = useAtomValue(currentWorkspaceSlugAtom); + const routeTargetSlug = useWorkspaceRouteTargetSlug(); + const workspaceSlug = routeTargetSlug ?? atomWorkspaceSlug; + const setWorkspaceContext = useSetAtom(setWorkspaceContextAtom); const connectionUiState = useAtomValue(lodyConnectionUiStateAtom); const setMobileDrawerOpen = useSetAtom(setMobileDrawerOpenAtom); const language = useAtomValue(languageAtom); @@ -1026,7 +1033,7 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) { const { organizations, activeOrganization, switchOrganization } = useOrganization(); const runtime = useAtomValue(activeWorkspaceRuntimeAtom); - const docMetaCacheReady = useAtomValue(docMetaCacheReadyAtom); + const docMetaScope = useAtomValue(docMetaCacheScopeAtom); const organizationsReady = Array.isArray(organizations); const expectedWorkspace = useMemo(() => { if (!organizationsReady) { @@ -1040,22 +1047,38 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) { }, [organizations, organizationsReady, workspaceSlug]); const expectedWorkspaceId = expectedWorkspace?.id ?? null; const expectedWorkspaceName = expectedWorkspace?.name ?? null; - const runtimeWorkspaceId = runtime?.workspaceId ?? null; - const { sessions, allActiveSessions } = useVisibleSessionMetas(); + const workspaceDataScope = useMemo( + () => + workspaceSlug + ? resolveWorkspaceDataScope({ + targetSlug: workspaceSlug, + runtime, + docMetaScope, + organizationsReady, + expectedWorkspaceId, + }) + : null, + [docMetaScope, expectedWorkspaceId, organizationsReady, runtime, workspaceSlug] + ); + const workspaceDataReady = workspaceDataScope?.status === 'ready'; + const scopedWorkspaceId = workspaceDataReady ? workspaceDataScope.workspaceId : null; + const { sessions, allActiveSessions } = useVisibleSessionMetas({ + workspaceId: scopedWorkspaceId, + enabled: workspaceDataReady, + }); useReportVisibleSessionsForEagerSync('loro-app-sidebar', sessions, allActiveSessions); - const sessionsListLoading = - Boolean(workspaceSlug) && - (!docMetaCacheReady || - !runtimeWorkspaceId || - (organizationsReady && - (expectedWorkspaceId === null || runtimeWorkspaceId !== expectedWorkspaceId))); + const sessionsListLoading = Boolean(workspaceSlug) && !workspaceDataReady; const { machines: machineMetaMap, projects: visibleLocalProjectMap, showSessionSharing, resolve: resolveSessionSharing, shareWithTeam: shareSessionWithTeam, - } = useSessionSharing({ includeLocalProjectDetails: true }); + } = useSessionSharing({ + includeLocalProjectDetails: true, + workspaceId: scopedWorkspaceId, + enabled: workspaceDataReady, + }); const localMachineId = useAtomValue(localMachineIdAtom); const onlineMachineIds = useOnlineMachineIds(); @@ -1563,6 +1586,8 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) { ); const localProjectSections = useMemo(() => { + if (sessionsListLoading) return []; + const localMachineMeta = localMachineId ? machineMetaMap.get(localMachineId) : undefined; const localProjects = localMachineId ? Array.from(visibleLocalProjectMap.values()).filter( @@ -1636,7 +1661,7 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) { return a.name.localeCompare(b.name); }), })); - }, [localMachineId, machineMetaMap, t, userId, visibleLocalProjectMap]); + }, [localMachineId, machineMetaMap, sessionsListLoading, t, userId, visibleLocalProjectMap]); // Build one complete, mode-independent row model first. Pinned sessions are // split from this model below so Workspace and Updated cannot accidentally @@ -2186,11 +2211,15 @@ export function LoroAppSidebar({ className }: LoroAppSidebarProps) { return; } writePreferredWorkspaceSlug(slug); + setWorkspaceContext({ + slug, + workspaceId: target.id as WorkspaceId, + }); void switchOrganization(target.id); closeMobileDrawer(); void router.navigate({ to: '/$workspaceName/chat', params: { workspaceName: slug } }); }, - [closeMobileDrawer, organizations, router, switchOrganization] + [closeMobileDrawer, organizations, router, setWorkspaceContext, switchOrganization] ); const labels: Partial = useMemo(() => { diff --git a/packages/components/src/components/onboarding/screens/workspace-screen.tsx b/packages/components/src/components/onboarding/screens/workspace-screen.tsx index 7dc91f07d..8e17976f1 100644 --- a/packages/components/src/components/onboarding/screens/workspace-screen.tsx +++ b/packages/components/src/components/onboarding/screens/workspace-screen.tsx @@ -4,7 +4,7 @@ import { useSetAtom } from 'jotai'; import { motion, AnimatePresence } from 'framer-motion'; import { ArrowRight, Building2, Check, Loader2, Plus } from 'lucide-react'; import type { WorkspaceId } from '@lody/shared'; -import { currentWorkspaceIdAtom, currentWorkspaceSlugAtom } from '@/atoms/workspace-context'; +import { setWorkspaceContextAtom } from '@/atoms/workspace-context'; import { cloudOperations } from '@/lib/cloud-api-operations'; import { toast } from 'sonner'; import { useCloudQuery, usePlatform, usePlatformWorkspaces } from '@lody/platform/react'; @@ -355,8 +355,7 @@ export function WorkspaceScreen({ onBack, onNext }: WorkspaceScreenProps) { const { t } = useTranslation(); const platform = usePlatform(); const workspaceState = usePlatformWorkspaces(); - const setCurrentWorkspaceId = useSetAtom(currentWorkspaceIdAtom); - const setCurrentWorkspaceSlug = useSetAtom(currentWorkspaceSlugAtom); + const setWorkspaceContext = useSetAtom(setWorkspaceContextAtom); const workspaces = useMemo( () => @@ -374,10 +373,12 @@ export function WorkspaceScreen({ onBack, onNext }: WorkspaceScreenProps) { const commitWorkspaceContext = useCallback( (workspace: WorkspaceListEntry | null) => { - setCurrentWorkspaceId(workspace ? (workspace.id as WorkspaceId) : null); - setCurrentWorkspaceSlug(workspace?.slug || null); + setWorkspaceContext({ + slug: workspace?.slug || null, + workspaceId: workspace ? (workspace.id as WorkspaceId) : null, + }); }, - [setCurrentWorkspaceId, setCurrentWorkspaceSlug] + [setWorkspaceContext] ); const [creating, setCreating] = useState(workspaces.length === 0); diff --git a/packages/components/src/hooks/use-session-sharing.ts b/packages/components/src/hooks/use-session-sharing.ts index 20f4ab0ef..e48676fe9 100644 --- a/packages/components/src/hooks/use-session-sharing.ts +++ b/packages/components/src/hooks/use-session-sharing.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import { useAtomValue } from 'jotai'; import { useCloudMutation } from '@lody/platform/react'; import { cloudOperations } from '@/lib/cloud-api-operations'; -import type { SessionMeta } from '@lody/shared'; +import type { SessionMeta, WorkspaceId } from '@lody/shared'; import { currentWorkspaceIdAtom, userAtom } from '@/atoms'; import { useVisibleLocalProjects } from '@/hooks/use-visible-local-projects'; import { useVisibleMachineMetas } from '@/hooks/use-visible-machine-metas'; @@ -17,6 +17,8 @@ import { useAppCapability } from '@/lib/app-platform'; type UseSessionSharingOptions = { includeLocalProjectDetails?: boolean; + workspaceId?: WorkspaceId | null; + enabled?: boolean; }; /** @@ -40,21 +42,32 @@ function useSessionSharingActiveOrganization(teamSharingAvailable: boolean) { export function useSessionSharing(options: UseSessionSharingOptions = {}) { const teamSharingAvailable = useAppCapability('teamSharing'); const currentUserId = useAtomValue(userAtom)?.id ?? null; - const workspaceId = useAtomValue(currentWorkspaceIdAtom); + const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom); + const workspaceId = options.workspaceId === undefined ? currentWorkspaceId : options.workspaceId; + const enabled = options.enabled ?? true; const activeOrganization = useSessionSharingActiveOrganization(teamSharingAvailable); - const machineIndex = useVisibleMachineMetas({ includeMachineFlock: false }); + const machineIndex = useVisibleMachineMetas({ + includeMachineFlock: false, + workspaceId, + enabled, + }); const projectIndex = useVisibleLocalProjects({ includeMachineFlock: options.includeLocalProjectDetails ?? false, syncMachineFlock: false, + workspaceId, + enabled, }); const { machines, accessByMachineId, isLoading: machineVisibilityLoading } = machineIndex; const { projects, accessByProjectKey, isLoading: localProjectVisibilityLoading } = projectIndex; - const setMachineSharedWithTeam = useCloudMutation(cloudOperations.machines.setMachineSharedWithTeam); + const setMachineSharedWithTeam = useCloudMutation( + cloudOperations.machines.setMachineSharedWithTeam + ); const setLocalProjectSharedWithTeam = useCloudMutation( cloudOperations.localProjects.setLocalProjectSharedWithTeam ); const isLoading = machineVisibilityLoading || localProjectVisibilityLoading; const showSessionSharing = + enabled && teamSharingAvailable && shouldShowSessionSharing({ workspaceId, diff --git a/packages/components/src/hooks/use-visible-local-projects.ts b/packages/components/src/hooks/use-visible-local-projects.ts index 18d3896ae..a27a80e5b 100644 --- a/packages/components/src/hooks/use-visible-local-projects.ts +++ b/packages/components/src/hooks/use-visible-local-projects.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useAtomValue } from 'jotai'; import { cloudOperations } from '@/lib/cloud-api-operations'; +import type { WorkspaceId } from '@lody/shared'; import { userAtom } from '@/atoms'; import { currentWorkspaceIdAtom } from '@/atoms/workspace-context'; import { @@ -24,6 +25,8 @@ const EMPTY_ACCESS_ROWS: LocalProjectVisibilityAccess[] = []; type UseVisibleLocalProjectsOptions = { includeMachineFlock?: boolean; syncMachineFlock?: boolean; + workspaceId?: WorkspaceId | null; + enabled?: boolean; }; export function useVisibleLocalProjects( @@ -32,16 +35,22 @@ export function useVisibleLocalProjects( const visibleMachineIndex = useVisibleMachineMetas({ includeMachineFlock: options.includeMachineFlock, syncMachineFlock: options.syncMachineFlock, + workspaceId: options.workspaceId, + enabled: options.enabled, + }); + return useVisibleLocalProjectsFromMachineIndex(visibleMachineIndex, { + workspaceId: options.workspaceId, + enabled: options.enabled, }); - return useVisibleLocalProjectsFromMachineIndex(visibleMachineIndex); } export function useVisibleLocalProjectsFromMachineIndex( visibleMachineIndex: Pick, - options: { enabled?: boolean } = {} + options: { enabled?: boolean; workspaceId?: WorkspaceId | null } = {} ): VisibleLocalProjectIndex { const enabled = options.enabled ?? true; - const workspaceId = useAtomValue(currentWorkspaceIdAtom); + const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom); + const workspaceId = options.workspaceId === undefined ? currentWorkspaceId : options.workspaceId; const { isAuthenticated, isLoading: isConvexAuthLoading } = useAuthenticatedConvex(); const canQuery = enabled && canRunAuthedWorkspaceQuery(workspaceId, isAuthenticated); const currentUserId = useAtomValue(userAtom)?.id ?? null; @@ -71,9 +80,9 @@ export function useVisibleLocalProjectsFromMachineIndex( rawMachines: visibleMachines, machineAccessByMachineId: accessByMachineId, convexAccessRows: rawAccessRows, - currentUserId, + currentUserId: enabled ? currentUserId : null, isLoading, }), - [accessByMachineId, currentUserId, isLoading, rawAccessRows, visibleMachines] + [accessByMachineId, currentUserId, enabled, isLoading, rawAccessRows, visibleMachines] ); } diff --git a/packages/components/src/hooks/use-visible-machine-metas.ts b/packages/components/src/hooks/use-visible-machine-metas.ts index 694e6e5ac..b3f2b63db 100644 --- a/packages/components/src/hooks/use-visible-machine-metas.ts +++ b/packages/components/src/hooks/use-visible-machine-metas.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useAtomValue } from 'jotai'; import { cloudOperations } from '@/lib/cloud-api-operations'; -import type { MachineFlockRowFamily, MachineId } from '@lody/shared'; +import type { MachineFlockRowFamily, MachineId, WorkspaceId } from '@lody/shared'; import { currentWorkspaceIdAtom } from '@/atoms/workspace-context'; import { getMachineMetaMapAtom } from '@/atoms/machines'; import { userAtom } from '@/atoms'; @@ -28,6 +28,8 @@ type UseVisibleMachineMetasOptions = { includeMachineFlock?: boolean; syncMachineFlock?: boolean; machineFlockFamilies?: readonly MachineFlockRowFamily[]; + workspaceId?: WorkspaceId | null; + enabled?: boolean; }; const DEFAULT_MACHINE_FLOCK_FAMILIES = [ @@ -48,33 +50,37 @@ export function useVisibleMachineMetas( ): VisibleMachineMetas { const includeMachineFlock = options.includeMachineFlock ?? true; const syncMachineFlock = options.syncMachineFlock ?? true; - const workspaceId = useAtomValue(currentWorkspaceIdAtom); + const enabled = options.enabled ?? true; + const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom); + const workspaceId = options.workspaceId === undefined ? currentWorkspaceId : options.workspaceId; const { isAuthenticated, isLoading: isConvexAuthLoading } = useAuthenticatedConvex(); - const canQuery = canRunAuthedWorkspaceQuery(workspaceId, isAuthenticated); + const canQuery = enabled && canRunAuthedWorkspaceQuery(workspaceId, isAuthenticated); const rawMachines = useAtomValue(getMachineMetaMapAtom); const onlineMachineIds = useAtomValue(onlineMachineIdsAtom); const currentUserId = useAtomValue(userAtom)?.id ?? null; const queriedAccessRows = useCloudQuery( cloudOperations.machines.listVisibleMachines, - workspaceId ? { workspaceId } : 'skip' + enabled && workspaceId ? { workspaceId } : 'skip' ); - const rawAccessRows = queriedAccessRows ?? EMPTY_ACCESS_ROWS; - const isLoading = isAuthedWorkspaceQueryLoading({ - workspaceId, - isConvexAuthLoading, - canQuery, - queryResult: queriedAccessRows, - }); + const rawAccessRows = enabled ? (queriedAccessRows ?? EMPTY_ACCESS_ROWS) : EMPTY_ACCESS_ROWS; + const isLoading = + !enabled || + isAuthedWorkspaceQueryLoading({ + workspaceId, + isConvexAuthLoading, + canQuery, + queryResult: queriedAccessRows, + }); const baseVisibleIndex = useMemo( () => buildVisibleMachineIndex({ rawMachines, convexAccessRows: rawAccessRows, - currentUserId, + currentUserId: enabled ? currentUserId : null, isLoading, }), - [rawMachines, rawAccessRows, currentUserId, isLoading] + [enabled, rawMachines, rawAccessRows, currentUserId, isLoading] ); const convexAuthorizedMachineIds = useMemo( () => new Set(rawAccessRows.map((row) => row.machineId as MachineId)), diff --git a/packages/components/src/hooks/use-visible-session-metas.ts b/packages/components/src/hooks/use-visible-session-metas.ts index ad13b416e..30885c6f4 100644 --- a/packages/components/src/hooks/use-visible-session-metas.ts +++ b/packages/components/src/hooks/use-visible-session-metas.ts @@ -1,6 +1,6 @@ import { useMemo, useRef } from 'react'; import { useAtomValue } from 'jotai'; -import type { MachineId, SessionMeta } from '@lody/shared'; +import type { MachineId, SessionMeta, WorkspaceId } from '@lody/shared'; import { userAtom } from '@/atoms'; import { allActiveSessionsAtom, archivedSessionListAtom, sessionListAtom } from '@/atoms/doc-meta'; import { filterSessionsByVisibility, type SessionListEntry } from '@/lib/session-visibility'; @@ -31,11 +31,20 @@ type VisibleArchivedSessionMetasResult = { isLoading: boolean; }; -export function useVisibleMachineIdSet(): { +type WorkspaceVisibilityOptions = { + workspaceId?: WorkspaceId | null; + enabled?: boolean; +}; + +export function useVisibleMachineIdSet(options: WorkspaceVisibilityOptions = {}): { visibleMachineIds: Set; isLoading: boolean; } { - const { accessByMachineId, isLoading } = useVisibleMachineMetas({ includeMachineFlock: false }); + const { accessByMachineId, isLoading } = useVisibleMachineMetas({ + includeMachineFlock: false, + workspaceId: options.workspaceId, + enabled: options.enabled, + }); // `accessByMachineId` is a fresh Map on every Loro machine-meta tick even // when the key set is unchanged. Stabilize the Set reference by content so @@ -53,12 +62,14 @@ export function useVisibleMachineIdSet(): { return { visibleMachineIds, isLoading }; } -export function useVisibleLocalProjectKeySet(): { +export function useVisibleLocalProjectKeySet(options: WorkspaceVisibilityOptions = {}): { visibleLocalProjectKeys: Set; isLoading: boolean; } { const { accessByProjectKey, isLoading } = useVisibleLocalProjects({ includeMachineFlock: false, + workspaceId: options.workspaceId, + enabled: options.enabled, }); const prevRef = useRef>(new Set()); @@ -73,36 +84,51 @@ export function useVisibleLocalProjectKeySet(): { return { visibleLocalProjectKeys, isLoading }; } -export function useVisibleSessionMetas(): VisibleSessionMetasResult { +export function useVisibleSessionMetas( + options: WorkspaceVisibilityOptions = {} +): VisibleSessionMetasResult { const sessions = useAtomValue(sessionListAtom); const allActiveSessions = useAtomValue(allActiveSessionsAtom); - const { visibleMachineIds, isLoading: machineLoading } = useVisibleMachineIdSet(); + const { visibleMachineIds, isLoading: machineLoading } = useVisibleMachineIdSet(options); const { visibleLocalProjectKeys, isLoading: localProjectLoading } = - useVisibleLocalProjectKeySet(); - const currentUserId = useAtomValue(userAtom)?.id ?? null; + useVisibleLocalProjectKeySet(options); + const enabled = options.enabled ?? true; + const currentUserIdValue = useAtomValue(userAtom)?.id ?? null; + const currentUserId = enabled ? currentUserIdValue : null; const isLoading = machineLoading || localProjectLoading; const visibleSessions = useMemo( () => - filterSessionsByVisibility( - sessions, - visibleMachineIds, - visibleLocalProjectKeys, - machineLoading, - currentUserId - ), - [currentUserId, machineLoading, sessions, visibleLocalProjectKeys, visibleMachineIds] + enabled + ? filterSessionsByVisibility( + sessions, + visibleMachineIds, + visibleLocalProjectKeys, + machineLoading, + currentUserId + ) + : [], + [currentUserId, enabled, machineLoading, sessions, visibleLocalProjectKeys, visibleMachineIds] ); const visibleAllActiveSessions = useMemo( () => - filterSessionsByVisibility( - allActiveSessions, - visibleMachineIds, - visibleLocalProjectKeys, - machineLoading, - currentUserId - ), - [allActiveSessions, currentUserId, machineLoading, visibleLocalProjectKeys, visibleMachineIds] + enabled + ? filterSessionsByVisibility( + allActiveSessions, + visibleMachineIds, + visibleLocalProjectKeys, + machineLoading, + currentUserId + ) + : [], + [ + allActiveSessions, + currentUserId, + enabled, + machineLoading, + visibleLocalProjectKeys, + visibleMachineIds, + ] ); return { diff --git a/packages/components/src/hooks/use-workspace-context-atoms.ts b/packages/components/src/hooks/use-workspace-context-atoms.ts index a54cb7c38..df1a9ea47 100644 --- a/packages/components/src/hooks/use-workspace-context-atoms.ts +++ b/packages/components/src/hooks/use-workspace-context-atoms.ts @@ -1,14 +1,11 @@ import { useEffect, useLayoutEffect } from 'react'; import { useSetAtom } from 'jotai'; -import { currentWorkspaceIdAtom, currentWorkspaceSlugAtom } from '@/atoms'; +import { clearWorkspaceContextForSlugAtom, setWorkspaceContextAtom } from '@/atoms'; import { writePreferredWorkspaceSlug } from '@/lib/workspace'; import type { WorkspaceId } from '@lody/shared'; /** Minimal shape of `convexApi.auth.getWorkspaceAccessBySlug`'s result we depend on. */ -type WorkspaceAccessForContext = - | { status?: string; organizationId?: string } - | null - | undefined; +type WorkspaceAccessForContext = { status?: string; organizationId?: string } | null | undefined; /** * Establish the workspace-context atoms (`currentWorkspaceSlugAtom` + @@ -23,27 +20,32 @@ export function useWorkspaceContextAtoms( workspaceSlug: string | null, access: WorkspaceAccessForContext ): void { - const setWorkspaceSlug = useSetAtom(currentWorkspaceSlugAtom); - const setWorkspaceId = useSetAtom(currentWorkspaceIdAtom); + const setWorkspaceContext = useSetAtom(setWorkspaceContextAtom); + const clearWorkspaceContextForSlug = useSetAtom(clearWorkspaceContextForSlugAtom); - // Optimistic, before paint — so the runtime can start booting from the cached - // workspace id while the access query resolves. + // Optimistic, before paint: publish the URL target and invalidate any id from + // the previous route as one observable state change. RuntimeProvider can still + // resolve this slug through the offline workspace cache. useLayoutEffect(() => { - setWorkspaceSlug(workspaceSlug); - }, [setWorkspaceSlug, workspaceSlug]); + setWorkspaceContext({ slug: workspaceSlug, workspaceId: null }); + }, [setWorkspaceContext, workspaceSlug]); useEffect(() => { if (workspaceSlug && access?.status === 'member' && access.organizationId) { writePreferredWorkspaceSlug(workspaceSlug); - setWorkspaceId(access.organizationId as WorkspaceId); + setWorkspaceContext({ + slug: workspaceSlug, + workspaceId: access.organizationId as WorkspaceId, + }); } - }, [access, setWorkspaceId, workspaceSlug]); + }, [access, setWorkspaceContext, workspaceSlug]); - // Clear the slug when the consumer unmounts so a stale workspace runtime - // doesn't linger. + // A route cleanup may run after the next route has already published its + // target. Only clear the scope owned by this hook instance. useEffect(() => { + if (!workspaceSlug) return undefined; return () => { - setWorkspaceSlug(null); + clearWorkspaceContextForSlug(workspaceSlug); }; - }, [setWorkspaceSlug]); + }, [clearWorkspaceContextForSlug, workspaceSlug]); } diff --git a/packages/components/src/hooks/useOrganization.ts b/packages/components/src/hooks/useOrganization.ts index 1e159ca94..2569c5cc0 100644 --- a/packages/components/src/hooks/useOrganization.ts +++ b/packages/components/src/hooks/useOrganization.ts @@ -12,7 +12,7 @@ import { } from '@/lib/local-storage-cache'; import { clearLastAppRoutePathIfWorkspaceMatch } from '@/lib/last-app-route'; import { useSetAtom } from 'jotai'; -import { currentWorkspaceIdAtom } from '@/atoms'; +import { setWorkspaceContextAtom } from '@/atoms'; import { WorkspaceId } from '@lody/shared'; import { useStableSession } from '@/hooks/useStableSession'; import { useAuthClient } from '../providers/convex-provider'; @@ -224,7 +224,7 @@ function useCloudOrganizationState(options?: UseOrganizationOptions) { error: activeOrganizationError, } = authClient.useActiveOrganization(); - const setCurrentWorkspace = useSetAtom(currentWorkspaceIdAtom); + const setWorkspaceContext = useSetAtom(setWorkspaceContextAtom); const organizationsRetryTimerRef = useRef | null>(null); const [organizationsRetryCount, setOrganizationsRetryCount] = useState(0); @@ -457,28 +457,27 @@ function useCloudOrganizationState(options?: UseOrganizationOptions) { } } - if (resolvedActiveOrganization) { - setCurrentWorkspace(resolvedActiveOrganization.id as WorkspaceId); - // Cache workspace info for offline-first access on subsequent visits - if (resolvedActiveOrganization.slug) { - cacheWorkspaceInfo( - resolvedActiveOrganization.slug, - resolvedActiveOrganization.id, - resolvedActiveOrganization.name - ); - } + // Only the route-scoped hook instance may publish render identity. Generic + // consumers (for example the sidebar) can briefly observe the previous + // Better Auth organization while a new URL target is already active. + if (!targetSlug) return; + + if (resolvedActiveOrganization?.slug === targetSlug) { + setWorkspaceContext({ + slug: targetSlug, + workspaceId: resolvedActiveOrganization.id as WorkspaceId, + }); return; } if (optimisticWorkspaceId) { - setCurrentWorkspace(optimisticWorkspaceId); - return; + setWorkspaceContext({ slug: targetSlug, workspaceId: optimisticWorkspaceId }); } - setCurrentWorkspace(null); }, [ activeOrganization?.id, optimisticWorkspaceId, resolvedActiveOrganization, - setCurrentWorkspace, + setWorkspaceContext, + targetSlug, user?.id, ]); @@ -607,7 +606,7 @@ function useCloudOrganizationState(options?: UseOrganizationOptions) { // subscriptions skip immediately; otherwise listVisibleMachines can run // with the just-deleted workspace id and throw a 403 during redirect. if (removalTransition.isActiveOrganization) { - setCurrentWorkspace(null); + setWorkspaceContext({ slug: null, workspaceId: null }); } try { const { data, error } = await authClient.organization.delete({ @@ -630,13 +629,16 @@ function useCloudOrganizationState(options?: UseOrganizationOptions) { if (removalTransition.fallbackOrganization) { try { await switchOrganizationOrThrow(removalTransition.fallbackOrganization.id); - setCurrentWorkspace(removalTransition.fallbackOrganization.id as WorkspaceId); + setWorkspaceContext({ + slug: removalTransition.fallbackOrganization.slug, + workspaceId: removalTransition.fallbackOrganization.id as WorkspaceId, + }); } catch (switchError) { console.error('Failed to switch organization after delete:', switchError); - setCurrentWorkspace(null); + setWorkspaceContext({ slug: null, workspaceId: null }); } } else { - setCurrentWorkspace(null); + setWorkspaceContext({ slug: null, workspaceId: null }); } } // TODO: delete local workspace data @@ -650,7 +652,10 @@ function useCloudOrganizationState(options?: UseOrganizationOptions) { return data; } catch (err) { if (removalTransition.isActiveOrganization && !didDelete) { - setCurrentWorkspace(organizationId as WorkspaceId); + setWorkspaceContext({ + slug: removalTransition.removedSlug, + workspaceId: organizationId as WorkspaceId, + }); } console.error('Failed to delete organization:', err); setMutationError('Failed to delete organization'); @@ -665,7 +670,7 @@ function useCloudOrganizationState(options?: UseOrganizationOptions) { refetchActiveOrganization, refetchOrganizations, resolvedActiveOrganization, - setCurrentWorkspace, + setWorkspaceContext, switchOrganizationOrThrow, ] ); @@ -687,7 +692,7 @@ function useCloudOrganizationState(options?: UseOrganizationOptions) { }); let didLeave = false; if (removalTransition.isActiveOrganization) { - setCurrentWorkspace(null); + setWorkspaceContext({ slug: null, workspaceId: null }); } try { const { data, error } = await authClient.organization.leave({ @@ -704,13 +709,16 @@ function useCloudOrganizationState(options?: UseOrganizationOptions) { if (removalTransition.fallbackOrganization) { try { await switchOrganizationOrThrow(removalTransition.fallbackOrganization.id); - setCurrentWorkspace(removalTransition.fallbackOrganization.id as WorkspaceId); + setWorkspaceContext({ + slug: removalTransition.fallbackOrganization.slug, + workspaceId: removalTransition.fallbackOrganization.id as WorkspaceId, + }); } catch (switchError) { console.error('Failed to switch organization after leave:', switchError); - setCurrentWorkspace(null); + setWorkspaceContext({ slug: null, workspaceId: null }); } } else { - setCurrentWorkspace(null); + setWorkspaceContext({ slug: null, workspaceId: null }); } } void refetchOrganizations(); @@ -723,7 +731,10 @@ function useCloudOrganizationState(options?: UseOrganizationOptions) { return data; } catch (err) { if (removalTransition.isActiveOrganization && !didLeave) { - setCurrentWorkspace(organizationId as WorkspaceId); + setWorkspaceContext({ + slug: removalTransition.removedSlug, + workspaceId: organizationId as WorkspaceId, + }); } console.error('Failed to leave organization:', err); setMutationError('Failed to leave organization'); @@ -738,7 +749,7 @@ function useCloudOrganizationState(options?: UseOrganizationOptions) { refetchActiveOrganization, refetchOrganizations, resolvedActiveOrganization, - setCurrentWorkspace, + setWorkspaceContext, switchOrganizationOrThrow, user, ] diff --git a/packages/components/src/lib/workspace-data-scope.ts b/packages/components/src/lib/workspace-data-scope.ts new file mode 100644 index 000000000..16b94ac1e --- /dev/null +++ b/packages/components/src/lib/workspace-data-scope.ts @@ -0,0 +1,45 @@ +import type { WorkspaceId } from '@lody/shared'; +import type { WorkspaceRuntime } from '@/atoms/runtime'; +import type { DocMetaCacheScope } from '@/atoms/doc-meta'; + +export type WorkspaceDataScopeState = + | { status: 'switching'; targetSlug: string } + | { + status: 'ready'; + targetSlug: string; + workspaceId: WorkspaceId; + runtime: WorkspaceRuntime; + }; + +export function resolveWorkspaceDataScope({ + targetSlug, + runtime, + docMetaScope, + organizationsReady, + expectedWorkspaceId, +}: { + targetSlug: string; + runtime: WorkspaceRuntime | null; + docMetaScope: DocMetaCacheScope | null; + organizationsReady: boolean; + expectedWorkspaceId: string | null; +}): WorkspaceDataScopeState { + if (!runtime || runtime.workspaceSlug !== targetSlug) { + return { status: 'switching', targetSlug }; + } + if (!docMetaScope || docMetaScope.runtime !== runtime || !docMetaScope.ready) { + return { status: 'switching', targetSlug }; + } + if ( + organizationsReady && + (expectedWorkspaceId === null || expectedWorkspaceId !== runtime.workspaceId) + ) { + return { status: 'switching', targetSlug }; + } + return { + status: 'ready', + targetSlug, + workspaceId: runtime.workspaceId, + runtime, + }; +} diff --git a/packages/components/src/providers/AGENTS.md b/packages/components/src/providers/AGENTS.md index 66560fb68..3e396a34f 100644 --- a/packages/components/src/providers/AGENTS.md +++ b/packages/components/src/providers/AGENTS.md @@ -12,3 +12,11 @@ live Streams connections. - Release any one-shot document handle or subscription that is not already owned by the workspace runtime. + +## Workspace switching + +- The `$workspaceName` route owns the render-time target slug. Workspace-scoped UI must + require that target, the active runtime, and the runtime-owned doc-meta snapshot to agree + before reading singleton caches. Visibility hooks must receive an explicit scoped workspace + id and `enabled: false` during mismatch so previous-scope queries and Machine Flock work do + not start behind a hidden loading state. diff --git a/packages/components/src/providers/workspace-route-target.tsx b/packages/components/src/providers/workspace-route-target.tsx new file mode 100644 index 000000000..e0dc22f43 --- /dev/null +++ b/packages/components/src/providers/workspace-route-target.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext, type ReactNode } from 'react'; + +const WorkspaceRouteTargetContext = createContext(null); + +export function WorkspaceRouteTargetProvider({ + slug, + children, +}: { + slug: string; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useWorkspaceRouteTargetSlug(): string | null { + return useContext(WorkspaceRouteTargetContext); +} diff --git a/packages/components/src/routes/$workspaceName.tsx b/packages/components/src/routes/$workspaceName.tsx index 7b98ddec0..dd905bb2b 100644 --- a/packages/components/src/routes/$workspaceName.tsx +++ b/packages/components/src/routes/$workspaceName.tsx @@ -24,6 +24,7 @@ import { WORKSPACE_SLUG_RESERVED_LANDING_PREFIXES, } from '@lody/shared'; import { isLocalAppPlatform } from '@/lib/app-platform'; +import { WorkspaceRouteTargetProvider } from '@/providers/workspace-route-target'; import { getLocalWorkspaceSlug, useLocalPlatformWorkspacesState, @@ -90,12 +91,14 @@ export const Route = createFileRoute('/$workspaceName')({ }); function WorkspaceGuardRoute() { - // Local (open-source) platform: no Convex access check exists. The single - // implicit workspace (provisioned by the CLI) is always accessible. - if (isLocalAppPlatform()) { - return ; - } - return ; + const { workspaceName } = Route.useParams(); + // The URL target is available during render, before workspace atoms and + // runtime effects converge. Descendants use it to reject previous-scope data. + return ( + + {isLocalAppPlatform() ? : } + + ); } function LocalWorkspaceGuardRoute() { diff --git a/packages/components/src/routes/__root.tsx b/packages/components/src/routes/__root.tsx index 89fad8135..79d2db9cb 100644 --- a/packages/components/src/routes/__root.tsx +++ b/packages/components/src/routes/__root.tsx @@ -33,9 +33,8 @@ import { toast } from 'sonner'; import { useAtomValue, useSetAtom } from 'jotai'; import { authTokenAtom, - currentWorkspaceIdAtom, - currentWorkspaceSlugAtom, electronDeepLinkSignInInProgressAtom, + setWorkspaceContextAtom, userAtom, } from '@/atoms'; import { StableSessionProvider } from '../providers/stable-session-provider'; @@ -255,8 +254,7 @@ function RootLocationEffects() { const electronSignInInProgress = useAtomValue(electronDeepLinkSignInInProgressAtom); const setUser = useSetAtom(userAtom); const setAuthToken = useSetAtom(authTokenAtom); - const setCurrentWorkspaceId = useSetAtom(currentWorkspaceIdAtom); - const setCurrentWorkspaceSlug = useSetAtom(currentWorkspaceSlugAtom); + const setWorkspaceContext = useSetAtom(setWorkspaceContextAtom); const authInvalidationRef = useRef(false); useEffect(() => { @@ -327,8 +325,7 @@ function RootLocationEffects() { setUser(null); setAuthToken(null); - setCurrentWorkspaceId(null); - setCurrentWorkspaceSlug(null); + setWorkspaceContext({ slug: null, workspaceId: null }); void signOutWithoutRedirect(authClient); toast.error(i18next.t('login.sessionExpired')); @@ -344,8 +341,7 @@ function RootLocationEffects() { location.pathname, navigate, setAuthToken, - setCurrentWorkspaceId, - setCurrentWorkspaceSlug, + setWorkspaceContext, setUser, ]); diff --git a/packages/components/tests/doc-meta-subscription.test.ts b/packages/components/tests/doc-meta-subscription.test.ts index c684a32e3..b58cbc2b1 100644 --- a/packages/components/tests/doc-meta-subscription.test.ts +++ b/packages/components/tests/doc-meta-subscription.test.ts @@ -17,6 +17,7 @@ vi.mock('@/lib/auth-bootstrap', () => ({ import { archivedSessionListAtom, docMetaCacheReadyAtom, + docMetaCacheScopeAtom, docMetaSubscriptionAtom, agentConfigMetaCacheAtom, machineMetaCacheAtom, @@ -172,12 +173,19 @@ describe('docMetaSubscriptionAtom', () => { const store = createStore(); const unmount = store.sub(docMetaSubscriptionAtom, () => {}); + const runtime = createRuntime(repo as unknown as LoroRepo); try { - store.set(runtimeAtom, createRuntime(repo as unknown as LoroRepo)); + store.set(runtimeAtom, runtime); await flush(); expect(store.get(docMetaCacheReadyAtom)).toBe(true); + expect(store.get(docMetaCacheScopeAtom)).toEqual({ + runtime, + workspaceId: runtime.workspaceId, + workspaceSlug: runtime.workspaceSlug, + ready: true, + }); expect(store.get(sessionMetaCacheAtom)[docId]?.isArchived).toBe(true); expect(store.get(sessionListAtom).map((session) => session.id)).not.toContain(sessionId); expect(store.get(archivedSessionListAtom).map((session) => session.id)).toContain(sessionId); diff --git a/packages/components/tests/use-session-sharing.test.tsx b/packages/components/tests/use-session-sharing.test.tsx index 478467f45..cffb20df6 100644 --- a/packages/components/tests/use-session-sharing.test.tsx +++ b/packages/components/tests/use-session-sharing.test.tsx @@ -148,6 +148,8 @@ describe('useSessionSharing project visibility', () => { expect(visibilityMocks.useVisibleLocalProjects).toHaveBeenCalledWith({ includeMachineFlock: false, syncMachineFlock: false, + workspaceId: 'workspace-1', + enabled: true, }); visibilityMocks.useVisibleLocalProjects.mockClear(); @@ -167,10 +169,14 @@ describe('useSessionSharing project visibility', () => { expect(results.at(-1)?.projects.get(privateProject.key)?.project.name).toBe('Private project'); expect(visibilityMocks.useVisibleMachineMetas).toHaveBeenCalledWith({ includeMachineFlock: false, + workspaceId: 'workspace-1', + enabled: true, }); expect(visibilityMocks.useVisibleLocalProjects).toHaveBeenCalledWith({ includeMachineFlock: true, syncMachineFlock: false, + workspaceId: 'workspace-1', + enabled: true, }); }); }); diff --git a/packages/components/tests/workspace-context.test.ts b/packages/components/tests/workspace-context.test.ts new file mode 100644 index 000000000..d3b2dc3f4 --- /dev/null +++ b/packages/components/tests/workspace-context.test.ts @@ -0,0 +1,96 @@ +import { atom, createStore } from 'jotai'; +import { describe, expect, it } from 'vitest'; +import type { WorkspaceId } from '@lody/shared'; + +import { + clearWorkspaceContextForSlugAtom, + currentWorkspaceIdAtom, + currentWorkspaceSlugAtom, + setWorkspaceContextAtom, +} from '../src/atoms/workspace-context'; + +const workspaceIdentityAtom = atom((get) => ({ + slug: get(currentWorkspaceSlugAtom), + workspaceId: get(currentWorkspaceIdAtom), +})); + +const workspaceId = (value: string) => value as WorkspaceId; + +describe('workspace context atoms', () => { + it('publishes a complete workspace identity in one observable update', () => { + const store = createStore(); + store.set(setWorkspaceContextAtom, { + slug: 'workspace-a', + workspaceId: workspaceId('workspace-a-id'), + }); + + const observed: Array<{ slug: string | null; workspaceId: WorkspaceId | null }> = []; + const unsubscribe = store.sub(workspaceIdentityAtom, () => { + observed.push(store.get(workspaceIdentityAtom)); + }); + + store.set(setWorkspaceContextAtom, { + slug: 'workspace-b', + workspaceId: workspaceId('workspace-b-id'), + }); + unsubscribe(); + + expect(observed).toEqual([{ slug: 'workspace-b', workspaceId: workspaceId('workspace-b-id') }]); + }); + + it('clears the previous id in the same update when the route slug changes', () => { + const store = createStore(); + store.set(setWorkspaceContextAtom, { + slug: 'workspace-a', + workspaceId: workspaceId('workspace-a-id'), + }); + + store.set(currentWorkspaceSlugAtom, 'workspace-b'); + + expect(store.get(workspaceIdentityAtom)).toEqual({ + slug: 'workspace-b', + workspaceId: null, + }); + }); + + it('does not let an old route cleanup clear a newer target', () => { + const store = createStore(); + store.set(setWorkspaceContextAtom, { + slug: 'workspace-c', + workspaceId: workspaceId('workspace-c-id'), + }); + + store.set(clearWorkspaceContextForSlugAtom, 'workspace-b'); + + expect(store.get(workspaceIdentityAtom)).toEqual({ + slug: 'workspace-c', + workspaceId: workspaceId('workspace-c-id'), + }); + }); + + it('preserves a cached id when the same slug is republished', () => { + const store = createStore(); + store.set(setWorkspaceContextAtom, { + slug: 'workspace-a', + workspaceId: workspaceId('workspace-a-id'), + }); + + store.set(currentWorkspaceSlugAtom, 'workspace-a'); + + expect(store.get(workspaceIdentityAtom)).toEqual({ + slug: 'workspace-a', + workspaceId: workspaceId('workspace-a-id'), + }); + }); + + it('keeps compatibility with setup code that stages the id before its initial slug', () => { + const store = createStore(); + store.set(currentWorkspaceIdAtom, workspaceId('workspace-a-id')); + store.set(currentWorkspaceSlugAtom, 'workspace-a'); + + expect(store.get(workspaceIdentityAtom)).toEqual({ + slug: 'workspace-a', + workspaceId: workspaceId('workspace-a-id'), + }); + }); +}); diff --git a/packages/components/tests/workspace-data-scope.test.ts b/packages/components/tests/workspace-data-scope.test.ts new file mode 100644 index 000000000..270536f67 --- /dev/null +++ b/packages/components/tests/workspace-data-scope.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import type { WorkspaceId } from '@lody/shared'; + +import type { WorkspaceRuntime } from '../src/atoms/runtime'; +import { resolveWorkspaceDataScope } from '../src/lib/workspace-data-scope'; + +const createRuntime = (slug: string, id: string): WorkspaceRuntime => + ({ workspaceSlug: slug, workspaceId: id as WorkspaceId }) as WorkspaceRuntime; + +const createDocMetaScope = (runtime: WorkspaceRuntime, ready = true) => ({ + runtime, + workspaceId: runtime.workspaceId, + workspaceSlug: runtime.workspaceSlug, + ready, +}); + +describe('resolveWorkspaceDataScope', () => { + it('rejects the previous runtime under a new route target', () => { + const runtimeA = createRuntime('workspace-a', 'workspace-a-id'); + + expect( + resolveWorkspaceDataScope({ + targetSlug: 'workspace-b', + runtime: runtimeA, + docMetaScope: createDocMetaScope(runtimeA), + organizationsReady: false, + expectedWorkspaceId: null, + }) + ).toEqual({ status: 'switching', targetSlug: 'workspace-b' }); + }); + + it('rejects metadata owned by an earlier runtime with the same target slug', () => { + const oldRuntime = createRuntime('workspace-b', 'workspace-b-id'); + const currentRuntime = createRuntime('workspace-b', 'workspace-b-id'); + + expect( + resolveWorkspaceDataScope({ + targetSlug: 'workspace-b', + runtime: currentRuntime, + docMetaScope: createDocMetaScope(oldRuntime), + organizationsReady: true, + expectedWorkspaceId: 'workspace-b-id', + }) + ).toEqual({ status: 'switching', targetSlug: 'workspace-b' }); + }); + + it('rejects a matching snapshot until its bootstrap is ready', () => { + const runtime = createRuntime('workspace-b', 'workspace-b-id'); + + expect( + resolveWorkspaceDataScope({ + targetSlug: 'workspace-b', + runtime, + docMetaScope: createDocMetaScope(runtime, false), + organizationsReady: true, + expectedWorkspaceId: 'workspace-b-id', + }) + ).toEqual({ status: 'switching', targetSlug: 'workspace-b' }); + }); + + it('allows an offline cached runtime without waiting for organizations', () => { + const runtime = createRuntime('workspace-b', 'workspace-b-id'); + + expect( + resolveWorkspaceDataScope({ + targetSlug: 'workspace-b', + runtime, + docMetaScope: createDocMetaScope(runtime), + organizationsReady: false, + expectedWorkspaceId: null, + }) + ).toEqual({ + status: 'ready', + targetSlug: 'workspace-b', + workspaceId: 'workspace-b-id', + runtime, + }); + }); + + it('rejects a server organization id that disagrees with the runtime', () => { + const runtime = createRuntime('workspace-b', 'cached-workspace-b-id'); + + expect( + resolveWorkspaceDataScope({ + targetSlug: 'workspace-b', + runtime, + docMetaScope: createDocMetaScope(runtime), + organizationsReady: true, + expectedWorkspaceId: 'server-workspace-b-id', + }) + ).toEqual({ status: 'switching', targetSlug: 'workspace-b' }); + }); +}); From 061aea282314a992ea2727577de9f0aa1c621539 Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:08:16 +0800 Subject: [PATCH 2/4] fix(components): fence workspace consumers during route changes Model: gpt-5.6-sol --- .../components/src/atoms/workspace-context.ts | 44 ++++- .../src/components/chat/chat-landing.tsx | 4 +- .../src/components/loro-app-sidebar.tsx | 2 +- .../mobile/mobile-github-project-settings.tsx | 5 +- .../workspace-checkout-pending-dialog.tsx | 6 +- .../use-github-project-worktree-admin.ts | 7 +- .../src/hooks/use-lody-live-activity.ts | 5 +- .../src/hooks/use-resolved-workspace-scope.ts | 51 +++++ .../src/hooks/use-session-sharing.ts | 9 +- .../src/hooks/use-visible-local-projects.ts | 6 +- .../src/hooks/use-visible-machine-metas.ts | 19 +- .../src/hooks/use-visible-session-metas.ts | 45 +++-- .../src/hooks/use-workspace-badge.ts | 15 +- .../components/src/hooks/useOrganization.ts | 130 ++++++++++--- packages/components/src/providers/AGENTS.md | 8 +- .../components/src/routes/$workspaceName.tsx | 2 +- .../src/routes/$workspaceName/_auth.tsx | 16 +- .../use-resolved-workspace-scope.test.tsx | 105 ++++++++++ .../components/tests/useOrganization.test.tsx | 183 +++++++++++++++++- .../tests/visible-access-hooks.test.tsx | 70 ++++++- .../tests/workspace-context.test.ts | 49 +++++ 21 files changed, 677 insertions(+), 104 deletions(-) create mode 100644 packages/components/src/hooks/use-resolved-workspace-scope.ts create mode 100644 packages/components/tests/use-resolved-workspace-scope.test.tsx diff --git a/packages/components/src/atoms/workspace-context.ts b/packages/components/src/atoms/workspace-context.ts index cb9f6a740..32f434d5a 100644 --- a/packages/components/src/atoms/workspace-context.ts +++ b/packages/components/src/atoms/workspace-context.ts @@ -6,20 +6,47 @@ export type WorkspaceContext = { workspaceId: WorkspaceId | null; }; -const workspaceContextAtom = atom({ +type VersionedWorkspaceContext = WorkspaceContext & { revision: number }; + +const workspaceContextAtom = atom({ slug: null, workspaceId: null, + revision: 0, }); +/** Read the latest identity and revision from an async continuation. */ +export const workspaceContextSnapshotAtom = atom((get) => get(workspaceContextAtom)); + /** Publish a route target and its resolved id as one observable state change. */ -export const setWorkspaceContextAtom = atom(null, (_get, set, context: WorkspaceContext) => - set(workspaceContextAtom, context) +export const setWorkspaceContextAtom = atom(null, (get, set, context: WorkspaceContext) => { + const revision = get(workspaceContextAtom).revision + 1; + set(workspaceContextAtom, { ...context, revision }); + return revision; +}); + +/** Publish only if no newer route, logout, or mutation has replaced the transition. */ +export const setWorkspaceContextAtRevisionAtom = atom( + null, + (get, set, update: { revision: number | null; context: WorkspaceContext }): boolean => { + const current = get(workspaceContextAtom); + if (update.revision === null || current.revision !== update.revision) return false; + set(workspaceContextAtom, { + ...update.context, + revision: current.revision + 1, + }); + return true; + } ); /** Clear only the route scope that is actually unmounting. */ export const clearWorkspaceContextForSlugAtom = atom(null, (get, set, slug: string) => { - if (get(workspaceContextAtom).slug === slug) { - set(workspaceContextAtom, { slug: null, workspaceId: null }); + const current = get(workspaceContextAtom); + if (current.slug === slug) { + set(workspaceContextAtom, { + slug: null, + workspaceId: null, + revision: current.revision + 1, + }); } }); @@ -30,7 +57,11 @@ export const currentWorkspaceIdAtom = atom( (get) => get(workspaceContextAtom).workspaceId, (get, set, workspaceId: WorkspaceId | null) => { const current = get(workspaceContextAtom); - set(workspaceContextAtom, { ...current, workspaceId }); + set(workspaceContextAtom, { + ...current, + workspaceId, + revision: current.revision + 1, + }); } ); @@ -44,6 +75,7 @@ export const currentWorkspaceSlugAtom = atom( slug === null || (current.slug !== null && current.slug !== slug) ? null : current.workspaceId, + revision: current.revision + 1, }); } ); diff --git a/packages/components/src/components/chat/chat-landing.tsx b/packages/components/src/components/chat/chat-landing.tsx index 13e37b2ac..cfada3bbd 100644 --- a/packages/components/src/components/chat/chat-landing.tsx +++ b/packages/components/src/components/chat/chat-landing.tsx @@ -66,7 +66,6 @@ import { getIpcServices, onIpcEvent, sendIpc } from '@/lib/electron-ipc-client'; import { bugReportDialogOpenAtom, chatLandingSessionStateAtomFamily, - currentWorkspaceIdAtom, getAllAgentConfigAtom, inboxFeatureEnabledAtom, mobileKeyboardActionAtom, @@ -100,6 +99,7 @@ import { useReconcileAcpSessionConfigSelection, } from '@/hooks/use-acp-session-config-selection'; import { useOnlineMachineIds } from '@/hooks/use-machine-online-status'; +import { useResolvedWorkspaceScope } from '@/hooks/use-resolved-workspace-scope'; import { getChatLandingAgentSelectionsForMachine, readChatLandingDefaults, @@ -605,7 +605,7 @@ function WorkspaceChatLanding({ controlConnectionState === 'connecting' || controlConnectionState === 'syncing'; const executorConfigs = useAtomValue(getAllAgentConfigAtom); - const workspaceId = useAtomValue(currentWorkspaceIdAtom); + const { workspaceId } = useResolvedWorkspaceScope(); const setLocalProjectSharedWithTeam = useCloudMutation( cloudOperations.localProjects.setLocalProjectSharedWithTeam ); diff --git a/packages/components/src/components/loro-app-sidebar.tsx b/packages/components/src/components/loro-app-sidebar.tsx index 9557e4925..e9457d13f 100644 --- a/packages/components/src/components/loro-app-sidebar.tsx +++ b/packages/components/src/components/loro-app-sidebar.tsx @@ -47,7 +47,7 @@ import { setWorkspaceContextAtom, } from '@/atoms'; import { docMetaCacheScopeAtom } from '@/atoms/doc-meta'; -import { useWorkspaceRouteTargetSlug } from '@/providers/workspace-route-target'; +import { useWorkspaceRouteTargetSlug } from '../providers/workspace-route-target'; import { resolveWorkspaceDataScope } from '@/lib/workspace-data-scope'; import { tasksFeatureEnabledAtom } from '@/atoms/settings'; diff --git a/packages/components/src/components/mobile/mobile-github-project-settings.tsx b/packages/components/src/components/mobile/mobile-github-project-settings.tsx index ea6f807f6..2e9defdb9 100644 --- a/packages/components/src/components/mobile/mobile-github-project-settings.tsx +++ b/packages/components/src/components/mobile/mobile-github-project-settings.tsx @@ -1,13 +1,12 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useAtomValue } from 'jotai'; import { ChevronRight, Loader2, Wrench } from 'lucide-react'; -import { currentWorkspaceIdAtom } from '@/atoms'; import { useGithubProjectWorktreeAdmin } from '@/hooks/use-github-project-worktree-admin'; import type { ProjectSkillsSource } from '@/hooks/use-project-skills'; import { MobileSettingsRow, MobileSettingsSection } from '@/components/mobile/mobile-settings-row'; import { MobileWorktreeConfigSheet } from '@/components/mobile/mobile-worktree-config-sheet'; import { MobileProjectSkillsRow } from '@/components/mobile/mobile-project-skills-sheet'; +import { useResolvedWorkspaceScope } from '@/hooks/use-resolved-workspace-scope'; export type MobileGithubProjectSettingsProps = { /** "owner/repo" — the workspace repo whose worktree scripts we're editing. */ @@ -23,7 +22,7 @@ export type MobileGithubProjectSettingsProps = { */ export function MobileGithubProjectSettings({ repoFullName }: MobileGithubProjectSettingsProps) { const { t } = useTranslation(); - const workspaceId = useAtomValue(currentWorkspaceIdAtom); + const { workspaceId } = useResolvedWorkspaceScope(); const { rowByRepoFullName, isLoading, onWorktreeSetupChange, onWorktreeCleanupChange } = useGithubProjectWorktreeAdmin(); const row = rowByRepoFullName.get(repoFullName) ?? null; diff --git a/packages/components/src/components/workspace-checkout-pending-dialog.tsx b/packages/components/src/components/workspace-checkout-pending-dialog.tsx index 2668226f0..3ac11bc14 100644 --- a/packages/components/src/components/workspace-checkout-pending-dialog.tsx +++ b/packages/components/src/components/workspace-checkout-pending-dialog.tsx @@ -1,5 +1,5 @@ import { cloudOperations } from '@/lib/cloud-api-operations'; -import { atom, useAtom, useAtomValue } from 'jotai'; +import { atom, useAtom } from 'jotai'; import { useTranslation } from 'react-i18next'; import { useLocation } from '@tanstack/react-router'; import { @@ -12,8 +12,8 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/ui/alert-dialog'; -import { currentWorkspaceIdAtom } from '@/atoms'; import { useOpenSettings } from '@/hooks/use-open-settings'; +import { useResolvedWorkspaceScope } from '@/hooks/use-resolved-workspace-scope'; import { useCloudQuery } from '@lody/platform/react'; import { useIsMobile } from '@/hooks/use-mobile'; import { isNativeAppShell } from '@/lib/native-platform'; @@ -39,7 +39,7 @@ export function WorkspaceCheckoutPendingDialog() { // safety net so a stray mount can never query billing on the local platform. const billingAvailable = useAppCapability('billing'); const hidesBillingUi = isMobile || isNativeAppShell() || !billingAvailable; - const workspaceId = useAtomValue(currentWorkspaceIdAtom); + const { workspaceId } = useResolvedWorkspaceScope(); const location = useLocation(); const { openSettings } = useOpenSettings(); const [dismissed, setDismissed] = useAtom(dismissedCheckoutPendingWorkspacesAtom); diff --git a/packages/components/src/hooks/use-github-project-worktree-admin.ts b/packages/components/src/hooks/use-github-project-worktree-admin.ts index a7cff583e..6dee20923 100644 --- a/packages/components/src/hooks/use-github-project-worktree-admin.ts +++ b/packages/components/src/hooks/use-github-project-worktree-admin.ts @@ -1,9 +1,7 @@ import { useCallback, useMemo, useState } from 'react'; -import { useAtomValue } from 'jotai'; import { useCloudMutation } from '@lody/platform/react'; import { cloudOperations } from '@/lib/cloud-api-operations'; import type { WorktreeCleanupScriptConfig, WorktreeSetupScriptConfig } from '@lody/shared'; -import { currentWorkspaceIdAtom } from '@/atoms/workspace-context'; import { canRunAuthedWorkspaceQuery, isAuthedWorkspaceQueryLoading, @@ -12,6 +10,7 @@ import { useAuthenticatedConvex } from './use-authenticated-convex'; import { useCloudQuery } from '@lody/platform/react'; import { useConvexErrorMessage } from './use-convex-error-message'; import type { SettingsWorkspaceRepoWithStatus } from '@/components/settings/settings-data-cache'; +import { useResolvedWorkspaceScope } from './use-resolved-workspace-scope'; /* Self-contained backing store for the mobile GitHub-project Settings tab. The desktop `/settings/projects` page reads the same repos through @@ -45,7 +44,7 @@ export type GithubProjectWorktreeAdmin = { }; export function useGithubProjectWorktreeSaves() { - const workspaceId = useAtomValue(currentWorkspaceIdAtom); + const { workspaceId } = useResolvedWorkspaceScope(); const setRepoWorktreeSetup = useCloudMutation(cloudOperations.github.setRepoWorktreeSetup); const setRepoWorktreeCleanup = useCloudMutation(cloudOperations.github.setRepoWorktreeCleanup); const getConvexErrorMessage = useConvexErrorMessage(); @@ -116,7 +115,7 @@ export function useGithubProjectWorktreeSaves() { } export function useGithubProjectWorktreeAdmin(): GithubProjectWorktreeAdmin { - const workspaceId = useAtomValue(currentWorkspaceIdAtom); + const { workspaceId } = useResolvedWorkspaceScope(); const { isAuthenticated, isLoading: isConvexAuthLoading } = useAuthenticatedConvex(); const canQuery = canRunAuthedWorkspaceQuery(workspaceId, isAuthenticated); const repos = useCloudQuery( diff --git a/packages/components/src/hooks/use-lody-live-activity.ts b/packages/components/src/hooks/use-lody-live-activity.ts index 9c520bd15..ca5dd6bb1 100644 --- a/packages/components/src/hooks/use-lody-live-activity.ts +++ b/packages/components/src/hooks/use-lody-live-activity.ts @@ -2,11 +2,12 @@ import { useEffect, useMemo, useRef } from 'react'; import { useAtomValue } from 'jotai'; import { useTranslation } from 'react-i18next'; import { allActiveSessionsAtom } from '@/atoms/doc-meta'; -import { currentWorkspaceIdAtom, iosLiveActivitiesEnabledAtom, userAtom } from '@/atoms'; +import { iosLiveActivitiesEnabledAtom, userAtom } from '@/atoms'; import { getAllAgentConfigAtom } from '@/atoms/agents'; import { lodyPresenceNowMsAtom, lodyPresenceStatesAtom } from '@/atoms/presence'; import { isNativeIOSAppShell } from '@/lib/native-platform'; import { useStableNow } from '@/hooks/use-stable-now'; +import { useResolvedWorkspaceScope } from '@/hooks/use-resolved-workspace-scope'; import { usePlatformCapability } from '@lody/platform/react'; import { buildLodyConversationsLiveActivityId, @@ -107,7 +108,7 @@ export function useLodyLiveActivity({ workspaceName }: { workspaceName: string } const presenceNowMs = useAtomValue(lodyPresenceNowMsAtom); const agentConfigs = useAtomValue(getAllAgentConfigAtom); const user = useAtomValue(userAtom); - const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom); + const { workspaceId: currentWorkspaceId } = useResolvedWorkspaceScope(); const { t, i18n } = useTranslation(); const now = useStableNow(LIVE_ACTIVITY_RECHECK_INTERVAL_MS); const userId = user?.id ?? null; diff --git a/packages/components/src/hooks/use-resolved-workspace-scope.ts b/packages/components/src/hooks/use-resolved-workspace-scope.ts new file mode 100644 index 000000000..c99ac7aab --- /dev/null +++ b/packages/components/src/hooks/use-resolved-workspace-scope.ts @@ -0,0 +1,51 @@ +import { useAtomValue } from 'jotai'; +import type { WorkspaceId } from '@lody/shared'; +import { currentWorkspaceIdAtom } from '@/atoms/workspace-context'; +import { docMetaCacheScopeAtom } from '@/atoms/doc-meta'; +import { activeWorkspaceRuntimeAtom } from '@/atoms/runtime'; +import { useWorkspaceRouteTargetSlug } from '../providers/workspace-route-target'; +import { resolveWorkspaceDataScope } from '@/lib/workspace-data-scope'; + +export type WorkspaceScopeOptions = { + workspaceId?: WorkspaceId | null; + enabled?: boolean; +}; + +/** + * Fail closed for workspace-scoped consumers mounted under a workspace route. + * Provider-external consumers, including RuntimeProvider, retain their existing behavior. + */ +export function useResolvedWorkspaceScope(options: WorkspaceScopeOptions = {}): { + workspaceId: WorkspaceId | null; + enabled: boolean; +} { + const routeTargetSlug = useWorkspaceRouteTargetSlug(); + const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom); + const runtime = useAtomValue(activeWorkspaceRuntimeAtom); + const docMetaScope = useAtomValue(docMetaCacheScopeAtom); + const requestedEnabled = options.enabled ?? true; + + if (routeTargetSlug === null) { + return { + workspaceId: options.workspaceId === undefined ? currentWorkspaceId : options.workspaceId, + enabled: requestedEnabled, + }; + } + + const scope = resolveWorkspaceDataScope({ + targetSlug: routeTargetSlug, + runtime, + docMetaScope, + organizationsReady: false, + expectedWorkspaceId: null, + }); + const readyWorkspaceId = scope.status === 'ready' ? scope.workspaceId : null; + const requestedWorkspaceId = + options.workspaceId === undefined ? readyWorkspaceId : options.workspaceId; + + return { + workspaceId: requestedWorkspaceId, + enabled: + requestedEnabled && readyWorkspaceId !== null && requestedWorkspaceId === readyWorkspaceId, + }; +} diff --git a/packages/components/src/hooks/use-session-sharing.ts b/packages/components/src/hooks/use-session-sharing.ts index e48676fe9..9aa77e178 100644 --- a/packages/components/src/hooks/use-session-sharing.ts +++ b/packages/components/src/hooks/use-session-sharing.ts @@ -3,7 +3,7 @@ import { useAtomValue } from 'jotai'; import { useCloudMutation } from '@lody/platform/react'; import { cloudOperations } from '@/lib/cloud-api-operations'; import type { SessionMeta, WorkspaceId } from '@lody/shared'; -import { currentWorkspaceIdAtom, userAtom } from '@/atoms'; +import { userAtom } from '@/atoms'; import { useVisibleLocalProjects } from '@/hooks/use-visible-local-projects'; import { useVisibleMachineMetas } from '@/hooks/use-visible-machine-metas'; import { @@ -14,6 +14,7 @@ import { } from '@/lib/session-sharing'; import { useAuthClient } from '../providers/convex-provider'; import { useAppCapability } from '@/lib/app-platform'; +import { useResolvedWorkspaceScope } from './use-resolved-workspace-scope'; type UseSessionSharingOptions = { includeLocalProjectDetails?: boolean; @@ -42,9 +43,9 @@ function useSessionSharingActiveOrganization(teamSharingAvailable: boolean) { export function useSessionSharing(options: UseSessionSharingOptions = {}) { const teamSharingAvailable = useAppCapability('teamSharing'); const currentUserId = useAtomValue(userAtom)?.id ?? null; - const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom); - const workspaceId = options.workspaceId === undefined ? currentWorkspaceId : options.workspaceId; - const enabled = options.enabled ?? true; + const scope = useResolvedWorkspaceScope(options); + const workspaceId = scope.workspaceId; + const enabled = scope.enabled; const activeOrganization = useSessionSharingActiveOrganization(teamSharingAvailable); const machineIndex = useVisibleMachineMetas({ includeMachineFlock: false, diff --git a/packages/components/src/hooks/use-visible-local-projects.ts b/packages/components/src/hooks/use-visible-local-projects.ts index a27a80e5b..58a7648bd 100644 --- a/packages/components/src/hooks/use-visible-local-projects.ts +++ b/packages/components/src/hooks/use-visible-local-projects.ts @@ -3,7 +3,6 @@ import { useAtomValue } from 'jotai'; import { cloudOperations } from '@/lib/cloud-api-operations'; import type { WorkspaceId } from '@lody/shared'; import { userAtom } from '@/atoms'; -import { currentWorkspaceIdAtom } from '@/atoms/workspace-context'; import { canRunAuthedWorkspaceQuery, isAuthedWorkspaceQueryLoading, @@ -17,6 +16,7 @@ import { useVisibleMachineMetas } from './use-visible-machine-metas'; import type { VisibleMachineIndex } from '@/lib/visible-machine-index'; import { useAuthenticatedConvex } from './use-authenticated-convex'; import { useCloudQuery } from '@lody/platform/react'; +import { useResolvedWorkspaceScope } from './use-resolved-workspace-scope'; export type { LocalProjectVisibilityAccess }; @@ -48,9 +48,7 @@ export function useVisibleLocalProjectsFromMachineIndex( visibleMachineIndex: Pick, options: { enabled?: boolean; workspaceId?: WorkspaceId | null } = {} ): VisibleLocalProjectIndex { - const enabled = options.enabled ?? true; - const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom); - const workspaceId = options.workspaceId === undefined ? currentWorkspaceId : options.workspaceId; + const { workspaceId, enabled } = useResolvedWorkspaceScope(options); const { isAuthenticated, isLoading: isConvexAuthLoading } = useAuthenticatedConvex(); const canQuery = enabled && canRunAuthedWorkspaceQuery(workspaceId, isAuthenticated); const currentUserId = useAtomValue(userAtom)?.id ?? null; diff --git a/packages/components/src/hooks/use-visible-machine-metas.ts b/packages/components/src/hooks/use-visible-machine-metas.ts index b3f2b63db..4e66a618a 100644 --- a/packages/components/src/hooks/use-visible-machine-metas.ts +++ b/packages/components/src/hooks/use-visible-machine-metas.ts @@ -2,7 +2,6 @@ import { useMemo } from 'react'; import { useAtomValue } from 'jotai'; import { cloudOperations } from '@/lib/cloud-api-operations'; import type { MachineFlockRowFamily, MachineId, WorkspaceId } from '@lody/shared'; -import { currentWorkspaceIdAtom } from '@/atoms/workspace-context'; import { getMachineMetaMapAtom } from '@/atoms/machines'; import { userAtom } from '@/atoms'; import { onlineMachineIdsAtom } from '@/atoms/presence'; @@ -19,6 +18,7 @@ import { } from '@/lib/visible-machine-index'; import { useAuthenticatedConvex } from './use-authenticated-convex'; import { useCloudQuery } from '@lody/platform/react'; +import { useResolvedWorkspaceScope } from './use-resolved-workspace-scope'; export type { MachineVisibilityAccess }; @@ -50,9 +50,7 @@ export function useVisibleMachineMetas( ): VisibleMachineMetas { const includeMachineFlock = options.includeMachineFlock ?? true; const syncMachineFlock = options.syncMachineFlock ?? true; - const enabled = options.enabled ?? true; - const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom); - const workspaceId = options.workspaceId === undefined ? currentWorkspaceId : options.workspaceId; + const { workspaceId, enabled } = useResolvedWorkspaceScope(options); const { isAuthenticated, isLoading: isConvexAuthLoading } = useAuthenticatedConvex(); const canQuery = enabled && canRunAuthedWorkspaceQuery(workspaceId, isAuthenticated); const rawMachines = useAtomValue(getMachineMetaMapAtom); @@ -97,11 +95,14 @@ export function useVisibleMachineMetas( const { rowsByMachineId: machineFlockRowsByMachineId, remoteSyncedMachineIds: machineFlockRemoteSyncedMachineIds, - } = useMachineFlockRowsByMachineIdsState(includeMachineFlock ? visibleMachineIds : [], { - families: options.machineFlockFamilies ?? DEFAULT_MACHINE_FLOCK_FAMILIES, - syncRemote: syncMachineFlock, - remoteMachineIds: onlineVisibleMachineIds, - }); + } = useMachineFlockRowsByMachineIdsState( + includeMachineFlock && enabled ? visibleMachineIds : [], + { + families: options.machineFlockFamilies ?? DEFAULT_MACHINE_FLOCK_FAMILIES, + syncRemote: enabled && syncMachineFlock, + remoteMachineIds: enabled ? onlineVisibleMachineIds : [], + } + ); const visibleMachinesWithFlockMeta = useMemo( () => includeMachineFlock diff --git a/packages/components/src/hooks/use-visible-session-metas.ts b/packages/components/src/hooks/use-visible-session-metas.ts index 30885c6f4..ef574bb45 100644 --- a/packages/components/src/hooks/use-visible-session-metas.ts +++ b/packages/components/src/hooks/use-visible-session-metas.ts @@ -6,6 +6,7 @@ import { allActiveSessionsAtom, archivedSessionListAtom, sessionListAtom } from import { filterSessionsByVisibility, type SessionListEntry } from '@/lib/session-visibility'; import { useVisibleLocalProjects } from './use-visible-local-projects'; import { useVisibleMachineMetas } from './use-visible-machine-metas'; +import { useResolvedWorkspaceScope } from './use-resolved-workspace-scope'; function areSetsEqual(left: ReadonlySet, right: ReadonlySet): boolean { if (left === right) return true; @@ -87,12 +88,13 @@ export function useVisibleLocalProjectKeySet(options: WorkspaceVisibilityOptions export function useVisibleSessionMetas( options: WorkspaceVisibilityOptions = {} ): VisibleSessionMetasResult { + const scope = useResolvedWorkspaceScope(options); const sessions = useAtomValue(sessionListAtom); const allActiveSessions = useAtomValue(allActiveSessionsAtom); - const { visibleMachineIds, isLoading: machineLoading } = useVisibleMachineIdSet(options); + const { visibleMachineIds, isLoading: machineLoading } = useVisibleMachineIdSet(scope); const { visibleLocalProjectKeys, isLoading: localProjectLoading } = - useVisibleLocalProjectKeySet(options); - const enabled = options.enabled ?? true; + useVisibleLocalProjectKeySet(scope); + const enabled = scope.enabled; const currentUserIdValue = useAtomValue(userAtom)?.id ?? null; const currentUserId = enabled ? currentUserIdValue : null; const isLoading = machineLoading || localProjectLoading; @@ -140,24 +142,37 @@ export function useVisibleSessionMetas( }; } -export function useVisibleArchivedSessionMetas(): VisibleArchivedSessionMetasResult { +export function useVisibleArchivedSessionMetas( + options: WorkspaceVisibilityOptions = {} +): VisibleArchivedSessionMetasResult { + const scope = useResolvedWorkspaceScope(options); const archivedSessions = useAtomValue(archivedSessionListAtom); - const { visibleMachineIds, isLoading: machineLoading } = useVisibleMachineIdSet(); + const { visibleMachineIds, isLoading: machineLoading } = useVisibleMachineIdSet(scope); const { visibleLocalProjectKeys, isLoading: localProjectLoading } = - useVisibleLocalProjectKeySet(); - const currentUserId = useAtomValue(userAtom)?.id ?? null; + useVisibleLocalProjectKeySet(scope); + const currentUserIdValue = useAtomValue(userAtom)?.id ?? null; + const currentUserId = scope.enabled ? currentUserIdValue : null; const isLoading = machineLoading || localProjectLoading; const visibleArchivedSessions = useMemo( () => - filterSessionsByVisibility( - archivedSessions, - visibleMachineIds, - visibleLocalProjectKeys, - machineLoading, - currentUserId - ), - [archivedSessions, currentUserId, machineLoading, visibleLocalProjectKeys, visibleMachineIds] + scope.enabled + ? filterSessionsByVisibility( + archivedSessions, + visibleMachineIds, + visibleLocalProjectKeys, + machineLoading, + currentUserId + ) + : [], + [ + archivedSessions, + currentUserId, + machineLoading, + scope.enabled, + visibleLocalProjectKeys, + visibleMachineIds, + ] ); return { diff --git a/packages/components/src/hooks/use-workspace-badge.ts b/packages/components/src/hooks/use-workspace-badge.ts index b54260fee..1ad6f451c 100644 --- a/packages/components/src/hooks/use-workspace-badge.ts +++ b/packages/components/src/hooks/use-workspace-badge.ts @@ -1,11 +1,12 @@ import { useEffect, useMemo } from 'react'; import { useAtomValue } from 'jotai'; import { sessionListAtom } from '@/atoms/doc-meta'; -import { userAtom, currentWorkspaceIdAtom } from '@/atoms'; +import { userAtom } from '@/atoms'; import { lodyPresenceNowMsAtom, lodyPresenceStatesAtom } from '@/atoms/presence'; import { isElectronRenderer } from '@/lib/electron'; import { getIpcServices } from '@/lib/electron-ipc-client'; import { findFreshSessionPresenceState } from '@lody/shared'; +import { useResolvedWorkspaceScope } from '@/hooks/use-resolved-workspace-scope'; type WindowBadge = { unread: number; waiting: number }; @@ -28,7 +29,7 @@ export function useWorkspaceBadge(): void { const presenceStates = useAtomValue(lodyPresenceStatesAtom); const presenceNowMs = useAtomValue(lodyPresenceNowMsAtom); const user = useAtomValue(userAtom); - const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom); + const { workspaceId: currentWorkspaceId } = useResolvedWorkspaceScope(); const userId = user?.id ?? null; const badge = useMemo(() => { @@ -37,16 +38,18 @@ export function useWorkspaceBadge(): void { let waiting = 0; for (const session of sessions) { if (session.userId !== userId) continue; - const liveStatus = findFreshSessionPresenceState(presenceStates, session.id, presenceNowMs) - ?.status; + const liveStatus = findFreshSessionPresenceState( + presenceStates, + session.id, + presenceNowMs + )?.status; if (liveStatus?.type === 'requestPermission') { waiting += 1; continue; } const lastMessageAt = typeof session.lastMessageAt === 'number' ? session.lastMessageAt : null; - const lastReadAt = - typeof session.lastReadAt === 'number' ? session.lastReadAt : null; + const lastReadAt = typeof session.lastReadAt === 'number' ? session.lastReadAt : null; if (lastMessageAt !== null && (lastReadAt === null || lastMessageAt > lastReadAt)) { unread += 1; } diff --git a/packages/components/src/hooks/useOrganization.ts b/packages/components/src/hooks/useOrganization.ts index 2569c5cc0..3a3f053b9 100644 --- a/packages/components/src/hooks/useOrganization.ts +++ b/packages/components/src/hooks/useOrganization.ts @@ -11,8 +11,12 @@ import { getCachedWorkspaceId, } from '@/lib/local-storage-cache'; import { clearLastAppRoutePathIfWorkspaceMatch } from '@/lib/last-app-route'; -import { useSetAtom } from 'jotai'; -import { setWorkspaceContextAtom } from '@/atoms'; +import { useSetAtom, useStore } from 'jotai'; +import { + setWorkspaceContextAtRevisionAtom, + setWorkspaceContextAtom, + workspaceContextSnapshotAtom, +} from '@/atoms'; import { WorkspaceId } from '@lody/shared'; import { useStableSession } from '@/hooks/useStableSession'; import { useAuthClient } from '../providers/convex-provider'; @@ -225,6 +229,8 @@ function useCloudOrganizationState(options?: UseOrganizationOptions) { } = authClient.useActiveOrganization(); const setWorkspaceContext = useSetAtom(setWorkspaceContextAtom); + const setWorkspaceContextAtRevision = useSetAtom(setWorkspaceContextAtRevisionAtom); + const workspaceContextStore = useStore(); const organizationsRetryTimerRef = useRef | null>(null); const [organizationsRetryCount, setOrganizationsRetryCount] = useState(0); @@ -602,11 +608,12 @@ function useCloudOrganizationState(options?: UseOrganizationOptions) { activeOrganization: resolvedActiveOrganization, }); let didDelete = false; + let transitionRevision: number | null = null; // Clear before the server removes membership so workspace-scoped Convex // subscriptions skip immediately; otherwise listVisibleMachines can run // with the just-deleted workspace id and throw a 403 during redirect. if (removalTransition.isActiveOrganization) { - setWorkspaceContext({ slug: null, workspaceId: null }); + transitionRevision = setWorkspaceContext({ slug: null, workspaceId: null }); } try { const { data, error } = await authClient.organization.delete({ @@ -627,18 +634,44 @@ function useCloudOrganizationState(options?: UseOrganizationOptions) { } if (removalTransition.isActiveOrganization) { if (removalTransition.fallbackOrganization) { - try { - await switchOrganizationOrThrow(removalTransition.fallbackOrganization.id); - setWorkspaceContext({ - slug: removalTransition.fallbackOrganization.slug, - workspaceId: removalTransition.fallbackOrganization.id as WorkspaceId, - }); - } catch (switchError) { - console.error('Failed to switch organization after delete:', switchError); - setWorkspaceContext({ slug: null, workspaceId: null }); + const fallbackOrganization = removalTransition.fallbackOrganization; + const transitionIsCurrent = () => + transitionRevision !== null && + workspaceContextStore.get(workspaceContextSnapshotAtom).revision === + transitionRevision; + if (transitionIsCurrent()) { + try { + await switchOrganizationOrThrow(fallbackOrganization.id); + const latestContext = workspaceContextStore.get(workspaceContextSnapshotAtom); + if (latestContext.revision !== transitionRevision) { + if ( + latestContext.workspaceId && + latestContext.workspaceId !== fallbackOrganization.id + ) { + await switchOrganizationOrThrow(latestContext.workspaceId); + } + } else { + setWorkspaceContextAtRevision({ + revision: transitionRevision, + context: { + slug: fallbackOrganization.slug, + workspaceId: fallbackOrganization.id as WorkspaceId, + }, + }); + } + } catch (switchError) { + console.error('Failed to switch organization after delete:', switchError); + setWorkspaceContextAtRevision({ + revision: transitionRevision, + context: { slug: null, workspaceId: null }, + }); + } } } else { - setWorkspaceContext({ slug: null, workspaceId: null }); + setWorkspaceContextAtRevision({ + revision: transitionRevision, + context: { slug: null, workspaceId: null }, + }); } } // TODO: delete local workspace data @@ -652,9 +685,12 @@ function useCloudOrganizationState(options?: UseOrganizationOptions) { return data; } catch (err) { if (removalTransition.isActiveOrganization && !didDelete) { - setWorkspaceContext({ - slug: removalTransition.removedSlug, - workspaceId: organizationId as WorkspaceId, + setWorkspaceContextAtRevision({ + revision: transitionRevision, + context: { + slug: removalTransition.removedSlug, + workspaceId: organizationId as WorkspaceId, + }, }); } console.error('Failed to delete organization:', err); @@ -671,7 +707,9 @@ function useCloudOrganizationState(options?: UseOrganizationOptions) { refetchOrganizations, resolvedActiveOrganization, setWorkspaceContext, + setWorkspaceContextAtRevision, switchOrganizationOrThrow, + workspaceContextStore, ] ); @@ -691,8 +729,9 @@ function useCloudOrganizationState(options?: UseOrganizationOptions) { activeOrganization: resolvedActiveOrganization, }); let didLeave = false; + let transitionRevision: number | null = null; if (removalTransition.isActiveOrganization) { - setWorkspaceContext({ slug: null, workspaceId: null }); + transitionRevision = setWorkspaceContext({ slug: null, workspaceId: null }); } try { const { data, error } = await authClient.organization.leave({ @@ -707,18 +746,44 @@ function useCloudOrganizationState(options?: UseOrganizationOptions) { } if (removalTransition.isActiveOrganization) { if (removalTransition.fallbackOrganization) { - try { - await switchOrganizationOrThrow(removalTransition.fallbackOrganization.id); - setWorkspaceContext({ - slug: removalTransition.fallbackOrganization.slug, - workspaceId: removalTransition.fallbackOrganization.id as WorkspaceId, - }); - } catch (switchError) { - console.error('Failed to switch organization after leave:', switchError); - setWorkspaceContext({ slug: null, workspaceId: null }); + const fallbackOrganization = removalTransition.fallbackOrganization; + const transitionIsCurrent = () => + transitionRevision !== null && + workspaceContextStore.get(workspaceContextSnapshotAtom).revision === + transitionRevision; + if (transitionIsCurrent()) { + try { + await switchOrganizationOrThrow(fallbackOrganization.id); + const latestContext = workspaceContextStore.get(workspaceContextSnapshotAtom); + if (latestContext.revision !== transitionRevision) { + if ( + latestContext.workspaceId && + latestContext.workspaceId !== fallbackOrganization.id + ) { + await switchOrganizationOrThrow(latestContext.workspaceId); + } + } else { + setWorkspaceContextAtRevision({ + revision: transitionRevision, + context: { + slug: fallbackOrganization.slug, + workspaceId: fallbackOrganization.id as WorkspaceId, + }, + }); + } + } catch (switchError) { + console.error('Failed to switch organization after leave:', switchError); + setWorkspaceContextAtRevision({ + revision: transitionRevision, + context: { slug: null, workspaceId: null }, + }); + } } } else { - setWorkspaceContext({ slug: null, workspaceId: null }); + setWorkspaceContextAtRevision({ + revision: transitionRevision, + context: { slug: null, workspaceId: null }, + }); } } void refetchOrganizations(); @@ -731,9 +796,12 @@ function useCloudOrganizationState(options?: UseOrganizationOptions) { return data; } catch (err) { if (removalTransition.isActiveOrganization && !didLeave) { - setWorkspaceContext({ - slug: removalTransition.removedSlug, - workspaceId: organizationId as WorkspaceId, + setWorkspaceContextAtRevision({ + revision: transitionRevision, + context: { + slug: removalTransition.removedSlug, + workspaceId: organizationId as WorkspaceId, + }, }); } console.error('Failed to leave organization:', err); @@ -750,8 +818,10 @@ function useCloudOrganizationState(options?: UseOrganizationOptions) { refetchOrganizations, resolvedActiveOrganization, setWorkspaceContext, + setWorkspaceContextAtRevision, switchOrganizationOrThrow, user, + workspaceContextStore, ] ); diff --git a/packages/components/src/providers/AGENTS.md b/packages/components/src/providers/AGENTS.md index 3e396a34f..bdecd8992 100644 --- a/packages/components/src/providers/AGENTS.md +++ b/packages/components/src/providers/AGENTS.md @@ -17,6 +17,8 @@ - The `$workspaceName` route owns the render-time target slug. Workspace-scoped UI must require that target, the active runtime, and the runtime-owned doc-meta snapshot to agree - before reading singleton caches. Visibility hooks must receive an explicit scoped workspace - id and `enabled: false` during mismatch so previous-scope queries and Machine Flock work do - not start behind a hidden loading state. + before reading singleton caches. Shared visibility and sharing hooks enforce this gate by + default when mounted under `WorkspaceRouteTargetProvider`; scope mismatch returns an empty + projection and disables queries, Machine Flock, sharing, and eager-sync inputs. Provider- + external consumers such as `RuntimeProvider` retain their existing default behavior. Explicit + `workspaceId` / `enabled` options remain fenced by the route scope and cannot reopen stale work. diff --git a/packages/components/src/routes/$workspaceName.tsx b/packages/components/src/routes/$workspaceName.tsx index dd905bb2b..be7b5a909 100644 --- a/packages/components/src/routes/$workspaceName.tsx +++ b/packages/components/src/routes/$workspaceName.tsx @@ -24,7 +24,7 @@ import { WORKSPACE_SLUG_RESERVED_LANDING_PREFIXES, } from '@lody/shared'; import { isLocalAppPlatform } from '@/lib/app-platform'; -import { WorkspaceRouteTargetProvider } from '@/providers/workspace-route-target'; +import { WorkspaceRouteTargetProvider } from '../providers/workspace-route-target'; import { getLocalWorkspaceSlug, useLocalPlatformWorkspacesState, diff --git a/packages/components/src/routes/$workspaceName/_auth.tsx b/packages/components/src/routes/$workspaceName/_auth.tsx index 4c4d4eafb..ff6c21bb1 100644 --- a/packages/components/src/routes/$workspaceName/_auth.tsx +++ b/packages/components/src/routes/$workspaceName/_auth.tsx @@ -3,7 +3,7 @@ import { lazy, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useOrganization } from '@/hooks/useOrganization'; import { useAtomValue, useSetAtom } from 'jotai'; -import { currentWorkspaceIdAtom, runtimeInitializingAtom, userAtom } from '@/atoms'; +import { runtimeInitializingAtom, userAtom } from '@/atoms'; import { WorkspaceCheckoutPendingDialog } from '@/components/workspace-checkout-pending-dialog'; import { ElectronSessionCompletionNotifier } from '@/components/electron-session-completion-notifier'; import { ElectronMenuHandler } from '@/components/electron-menu-handler'; @@ -37,6 +37,7 @@ import { useWorkspaceBadge } from '@/hooks/use-workspace-badge'; import { type LodyLiveActivityBridge, useLodyLiveActivity } from '@/hooks/use-lody-live-activity'; import { isNativeIOSAppShell } from '@/lib/native-platform'; import { isLocalAppPlatform } from '@/lib/app-platform'; +import { useResolvedWorkspaceScope } from '../../hooks/use-resolved-workspace-scope'; const AUTH_ROUTE_ONESIGNAL_LOGIN_IDLE_TIMEOUT_MS = 10_000; @@ -105,7 +106,7 @@ function CloudMainLayoutComponent({ workspaceName }: { workspaceName: string }) error, } = useStableSession(); const setRuntimeInitializing = useSetAtom(runtimeInitializingAtom); - const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom); + const { workspaceId: currentWorkspaceId } = useResolvedWorkspaceScope(); const { machines: machineMetaMap } = useVisibleMachineMetas({ includeMachineFlock: false }); const onlineMachineIdSet = useOnlineMachineIds(); const [sessionSettled, setSessionSettled] = useState(!isPending); @@ -336,7 +337,7 @@ function AuthedLayoutContent({ error: organizationsError, } = useOrganization({ targetSlug: workspaceName }); const user = useAtomValue(userAtom); - const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom); + const { workspaceId: currentWorkspaceId } = useResolvedWorkspaceScope(); const [orgSettled, setOrgSettled] = useState(!organizationsLoading); const [userSettled, setUserSettled] = useState(Boolean(user) && Boolean(currentWorkspaceId)); @@ -363,6 +364,15 @@ function AuthedLayoutContent({ return ; } + if (!currentWorkspaceId) { + return ( + + ); + } + return ( diff --git a/packages/components/tests/use-resolved-workspace-scope.test.tsx b/packages/components/tests/use-resolved-workspace-scope.test.tsx new file mode 100644 index 000000000..5f151ea51 --- /dev/null +++ b/packages/components/tests/use-resolved-workspace-scope.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment jsdom + +import { act, createElement, useEffect, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Provider, createStore } from 'jotai'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { WorkspaceId } from '@lody/shared'; +import { setWorkspaceContextAtom } from '../src/atoms/workspace-context'; +import { WorkspaceRouteTargetProvider } from '../src/providers/workspace-route-target'; +import { + useResolvedWorkspaceScope, + type WorkspaceScopeOptions, +} from '../src/hooks/use-resolved-workspace-scope'; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +type ScopeSnapshot = ReturnType; + +function ScopeProbe({ + options, + onSnapshot, +}: { + options?: WorkspaceScopeOptions; + onSnapshot: (snapshot: ScopeSnapshot) => void; +}) { + const snapshot = useResolvedWorkspaceScope(options); + useEffect(() => onSnapshot(snapshot), [onSnapshot, snapshot]); + return null; +} + +describe('useResolvedWorkspaceScope', () => { + let root: Root | undefined; + let container: HTMLDivElement | undefined; + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()); + } + root = undefined; + container?.remove(); + container = undefined; + }); + + async function render(node: ReactNode) { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => root?.render(node)); + } + + it('fails closed under a new route while its runtime and doc-meta scope are not ready', async () => { + const store = createStore(); + store.set(setWorkspaceContextAtom, { + slug: 'workspace-a', + workspaceId: 'workspace-a-id' as WorkspaceId, + }); + let snapshot: ScopeSnapshot | undefined; + + await render( + createElement( + Provider, + { store }, + createElement( + WorkspaceRouteTargetProvider, + { slug: 'workspace-b' }, + createElement(ScopeProbe, { + onSnapshot: (value) => { + snapshot = value; + }, + }) + ) + ) + ); + + expect(snapshot).toEqual({ workspaceId: null, enabled: false }); + }); + + it('preserves the default workspace identity outside a workspace route provider', async () => { + const store = createStore(); + store.set(setWorkspaceContextAtom, { + slug: 'workspace-a', + workspaceId: 'workspace-a-id' as WorkspaceId, + }); + let snapshot: ScopeSnapshot | undefined; + + await render( + createElement( + Provider, + { store }, + createElement(ScopeProbe, { + onSnapshot: (value) => { + snapshot = value; + }, + }) + ) + ); + + expect(snapshot).toEqual({ + workspaceId: 'workspace-a-id', + enabled: true, + }); + }); +}); diff --git a/packages/components/tests/useOrganization.test.tsx b/packages/components/tests/useOrganization.test.tsx index 7efabec2c..50fafef03 100644 --- a/packages/components/tests/useOrganization.test.tsx +++ b/packages/components/tests/useOrganization.test.tsx @@ -2,7 +2,7 @@ import { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { Provider } from 'jotai'; +import { Provider, createStore, type Store } from 'jotai'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@/lib/auth-bootstrap', () => ({ @@ -16,6 +16,8 @@ const organizationMocks = vi.hoisted(() => ({ refetchActiveOrganization: vi.fn(), refetchOrganizations: vi.fn(), setActive: vi.fn(), + deleteOrganization: vi.fn(), + leaveOrganization: vi.fn(), })); vi.mock('../src/providers/convex-provider', () => ({ @@ -28,6 +30,8 @@ vi.mock('../src/lib/app-platform', () => ({ const { StableSessionContext } = await import('../src/hooks/useStableSession'); const { useOrganization } = await import('../src/hooks/useOrganization'); +const { currentWorkspaceIdAtom, currentWorkspaceSlugAtom } = + await import('../src/atoms/workspace-context'); type OrganizationState = ReturnType; let latestOrganizationState: OrganizationState | null = null; @@ -53,17 +57,33 @@ function createOrganization(id: string, slug: string, name: string): TestOrganiz }; } +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + function OrganizationProbe({ targetSlug }: { targetSlug: string }) { latestOrganizationState = useOrganization({ targetSlug }); return null; } -function TestApp({ targetSlug, renderVersion }: { targetSlug: string; renderVersion: number }) { +function TestApp({ + targetSlug, + renderVersion, + store, +}: { + targetSlug: string; + renderVersion: number; + store: Store; +}) { void renderVersion; return createElement( Provider, - null, + { store }, createElement( StableSessionContext.Provider, { @@ -90,15 +110,19 @@ describe('useOrganization setActive dedupe', () => { let root: Root | undefined; let container: HTMLDivElement | undefined; let activeOrganization: TestOrganization; + let store: Store; let listVersion = 0; beforeEach(() => { latestOrganizationState = null; listVersion = 0; activeOrganization = createOrganization('workspace-old', 'old-workspace', 'Old Workspace'); + store = createStore(); organizationMocks.refetchActiveOrganization.mockReset(); organizationMocks.refetchOrganizations.mockReset(); organizationMocks.setActive.mockReset(); + organizationMocks.deleteOrganization.mockReset(); + organizationMocks.leaveOrganization.mockReset(); organizationMocks.setActive.mockResolvedValue({ data: createOrganization('workspace-target', 'target-workspace', 'Target Workspace'), error: null, @@ -112,6 +136,7 @@ describe('useOrganization setActive dedupe', () => { `Target Workspace ${listVersion}` ), createOrganization('workspace-old', 'old-workspace', `Old Workspace ${listVersion}`), + createOrganization('workspace-new', 'workspace-new', `New Workspace ${listVersion}`), ], isPending: false, error: null, @@ -125,6 +150,8 @@ describe('useOrganization setActive dedupe', () => { }), organization: { setActive: organizationMocks.setActive, + delete: organizationMocks.deleteOrganization, + leave: organizationMocks.leaveOrganization, }, }; }); @@ -149,7 +176,7 @@ describe('useOrganization setActive dedupe', () => { } await act(async () => { - root?.render(createElement(TestApp, { targetSlug, renderVersion })); + root?.render(createElement(TestApp, { targetSlug, renderVersion, store })); }); await act(async () => { await Promise.resolve(); @@ -180,6 +207,148 @@ describe('useOrganization setActive dedupe', () => { expect(organizationMocks.setActive).toHaveBeenCalledTimes(1); }); + it('publishes the fallback after delete success when no newer writer intervenes', async () => { + organizationMocks.deleteOrganization.mockResolvedValueOnce({ + data: { id: 'workspace-old' }, + error: null, + }); + await render('old-workspace', 0); + + await act(async () => { + await latestOrganizationState!.deleteOrganization('workspace-old'); + }); + + expect(store.get(currentWorkspaceSlugAtom)).toBe('target-workspace'); + expect(store.get(currentWorkspaceIdAtom)).toBe('workspace-target'); + }); + + it('rolls back after leave failure when no newer writer intervenes', async () => { + organizationMocks.leaveOrganization.mockResolvedValueOnce({ + data: null, + error: { message: 'leave failed' }, + }); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + await render('old-workspace', 0); + + await act(async () => { + await latestOrganizationState!.leaveOrganization('workspace-old').catch(() => undefined); + }); + + expect(store.get(currentWorkspaceSlugAtom)).toBe('old-workspace'); + expect(store.get(currentWorkspaceIdAtom)).toBe('workspace-old'); + }); + + it('does not switch Better Auth or replace identity when delete resolves after navigation', async () => { + const deferredDelete = createDeferred<{ data: { id: string } | null; error: null }>(); + organizationMocks.deleteOrganization.mockReturnValueOnce(deferredDelete.promise); + await render('old-workspace', 0); + + let mutation!: Promise; + await act(async () => { + mutation = latestOrganizationState!.deleteOrganization('workspace-old'); + await Promise.resolve(); + }); + activeOrganization = createOrganization('workspace-new', 'workspace-new', 'New Workspace'); + await render('workspace-new', 1); + deferredDelete.resolve({ data: { id: 'workspace-old' }, error: null }); + await act(async () => { + await mutation; + }); + + expect(store.get(currentWorkspaceSlugAtom)).toBe('workspace-new'); + expect(store.get(currentWorkspaceIdAtom)).toBe('workspace-new'); + expect(organizationMocks.setActive).not.toHaveBeenCalledWith({ + organizationId: 'workspace-target', + }); + }); + + it('restores the newer Better Auth target when navigation overtakes an in-flight fallback', async () => { + const deferredFallback = createDeferred<{ + data: TestOrganization; + error: null; + }>(); + organizationMocks.deleteOrganization.mockResolvedValueOnce({ + data: { id: 'workspace-old' }, + error: null, + }); + await render('old-workspace', 0); + + // Ensure the module-level setActive dedupe does not retain the fallback id + // from an earlier test. + await act(async () => { + await latestOrganizationState!.activateOrganization('workspace-reset'); + }); + organizationMocks.setActive.mockClear(); + + let authWorkspaceId = 'workspace-old'; + organizationMocks.setActive.mockImplementation( + async ({ organizationId }: { organizationId: string }) => { + if (organizationId === 'workspace-target') { + const response = await deferredFallback.promise; + authWorkspaceId = organizationId; + return response; + } + authWorkspaceId = organizationId; + return { + data: createOrganization(organizationId, organizationId, organizationId), + error: null, + }; + } + ); + + let mutation!: Promise; + await act(async () => { + mutation = latestOrganizationState!.deleteOrganization('workspace-old'); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(organizationMocks.setActive).toHaveBeenCalledWith({ + organizationId: 'workspace-target', + }); + + activeOrganization = createOrganization('workspace-new', 'workspace-new', 'New Workspace'); + await render('workspace-new', 1); + deferredFallback.resolve({ + data: createOrganization('workspace-target', 'target-workspace', 'Target Workspace'), + error: null, + }); + await act(async () => { + await mutation; + }); + + expect(organizationMocks.setActive).toHaveBeenLastCalledWith({ + organizationId: 'workspace-new', + }); + expect(authWorkspaceId).toBe('workspace-new'); + expect(store.get(currentWorkspaceSlugAtom)).toBe('workspace-new'); + expect(store.get(currentWorkspaceIdAtom)).toBe('workspace-new'); + }); + + it('keeps a newer navigation identity when leave failure publishes a late rollback', async () => { + const deferredLeave = createDeferred<{ + data: null; + error: { message: string }; + }>(); + organizationMocks.leaveOrganization.mockReturnValueOnce(deferredLeave.promise); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + await render('old-workspace', 0); + + let mutation!: Promise; + await act(async () => { + mutation = latestOrganizationState!.leaveOrganization('workspace-old'); + await Promise.resolve(); + }); + activeOrganization = createOrganization('workspace-new', 'workspace-new', 'New Workspace'); + await render('workspace-new', 1); + deferredLeave.resolve({ data: null, error: { message: 'leave failed' } }); + await act(async () => { + await mutation.catch(() => undefined); + }); + + expect(store.get(currentWorkspaceSlugAtom)).toBe('workspace-new'); + expect(store.get(currentWorkspaceIdAtom)).toBe('workspace-new'); + }); + it('rejects an awaited activation when Better Auth refuses the switch', async () => { organizationMocks.setActive.mockResolvedValueOnce({ data: null, @@ -187,8 +356,8 @@ describe('useOrganization setActive dedupe', () => { }); await render('old-workspace', 0); - await expect(latestOrganizationState?.activateOrganization('workspace-target')).rejects.toThrow( - 'membership changed' - ); + await expect( + latestOrganizationState?.activateOrganization('workspace-rejected') + ).rejects.toThrow('membership changed'); }); }); diff --git a/packages/components/tests/visible-access-hooks.test.tsx b/packages/components/tests/visible-access-hooks.test.tsx index fd01266d2..d2fc850bc 100644 --- a/packages/components/tests/visible-access-hooks.test.tsx +++ b/packages/components/tests/visible-access-hooks.test.tsx @@ -56,7 +56,11 @@ import { useVisibleLocalProjectsFromMachineIndex, } from '../src/hooks/use-visible-local-projects'; import { useVisibleMachineMetas } from '../src/hooks/use-visible-machine-metas'; -import { useVisibleSessionMetas } from '../src/hooks/use-visible-session-metas'; +import { + useVisibleArchivedSessionMetas, + useVisibleSessionMetas, +} from '../src/hooks/use-visible-session-metas'; +import { WorkspaceRouteTargetProvider } from '../src/providers/workspace-route-target'; import { resolveSessionDetailVisibilityState, type SessionDetailPresenceState, @@ -163,6 +167,24 @@ function VisibleSessionProbe({ return null; } +function RouteScopedVisibilityProbe({ + onSnapshot, +}: { + onSnapshot: (value: { activeIds: string[]; archivedIds: string[] }) => void; +}) { + const { sessions } = useVisibleSessionMetas(); + const { archivedSessions } = useVisibleArchivedSessionMetas(); + + useEffect(() => { + onSnapshot({ + activeIds: sessions.map((session) => session.id), + archivedIds: archivedSessions.map((session) => session.id), + }); + }, [archivedSessions, onSnapshot, sessions]); + + return null; +} + function createCachedSession(userId: string): SessionMeta { return { id: 'cached-session' as SessionId, @@ -261,6 +283,52 @@ describe('visible access hooks', () => { vi.clearAllMocks(); }); + it('fails closed for active and archived sessions while a new route scope is not ready', async () => { + queryMocks.machineRows = [ + { + machineId: 'shared-machine', + ownerUserId: 'viewer-user', + sharedWithTeam: false, + updatedAt: 1, + }, + ]; + const activeSession = createCachedSession('viewer-user'); + const archivedSession = { + ...createCachedSession('viewer-user'), + id: 'archived-session' as SessionId, + isArchived: true, + }; + cacheSession(activeSession); + store.set(sessionMetaCacheAtom, { + [getSessionRoomId(activeSession.id)]: activeSession, + [getSessionRoomId(archivedSession.id)]: archivedSession, + }); + let snapshot: { activeIds: string[]; archivedIds: string[] } | undefined; + + await mount( + 'workspace-a', + createElement( + WorkspaceRouteTargetProvider, + { slug: 'workspace-b' }, + createElement(RouteScopedVisibilityProbe, { + onSnapshot: (value) => { + snapshot = value; + }, + }) + ) + ); + + expect(snapshot).toEqual({ activeIds: [], archivedIds: [] }); + const queryArgs = queryMocks.useQuery.mock.calls.map(([, args]) => args); + expect(queryArgs.length).toBeGreaterThan(0); + expect(queryArgs.every((args) => args === 'skip')).toBe(true); + expect(machineFlockMocks.useMachineFlockRowsByMachineIdsState).toHaveBeenCalledWith([], { + families: expect.any(Array), + syncRemote: false, + remoteMachineIds: [], + }); + }); + it('does not reuse machine access rows while a later mount is loading', async () => { queryMocks.machineRows = [ { diff --git a/packages/components/tests/workspace-context.test.ts b/packages/components/tests/workspace-context.test.ts index d3b2dc3f4..7bac4d890 100644 --- a/packages/components/tests/workspace-context.test.ts +++ b/packages/components/tests/workspace-context.test.ts @@ -6,6 +6,7 @@ import { clearWorkspaceContextForSlugAtom, currentWorkspaceIdAtom, currentWorkspaceSlugAtom, + setWorkspaceContextAtRevisionAtom, setWorkspaceContextAtom, } from '../src/atoms/workspace-context'; @@ -83,6 +84,54 @@ describe('workspace context atoms', () => { }); }); + it('rejects a stale mutation continuation after navigation publishes a newer identity', () => { + const store = createStore(); + const revision = store.set(setWorkspaceContextAtom, { + slug: null, + workspaceId: null, + }); + store.set(setWorkspaceContextAtom, { + slug: 'workspace-b', + workspaceId: workspaceId('workspace-b-id'), + }); + + const published = store.set(setWorkspaceContextAtRevisionAtom, { + revision, + context: { + slug: 'workspace-a', + workspaceId: workspaceId('workspace-a-id'), + }, + }); + + expect(published).toBe(false); + expect(store.get(workspaceIdentityAtom)).toEqual({ + slug: 'workspace-b', + workspaceId: workspaceId('workspace-b-id'), + }); + }); + + it('allows a mutation continuation when no newer writer has advanced the revision', () => { + const store = createStore(); + const revision = store.set(setWorkspaceContextAtom, { + slug: null, + workspaceId: null, + }); + + const published = store.set(setWorkspaceContextAtRevisionAtom, { + revision, + context: { + slug: 'workspace-fallback', + workspaceId: workspaceId('workspace-fallback-id'), + }, + }); + + expect(published).toBe(true); + expect(store.get(workspaceIdentityAtom)).toEqual({ + slug: 'workspace-fallback', + workspaceId: workspaceId('workspace-fallback-id'), + }); + }); + it('keeps compatibility with setup code that stages the id before its initial slug', () => { const store = createStore(); store.set(currentWorkspaceIdAtom, workspaceId('workspace-a-id')); From 5113e9f425a1c14dc3aad8322e468e0df0c0bedd Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:23:31 +0800 Subject: [PATCH 3/4] fix(components): validate authoritative workspace scope Reject a same-slug cached runtime once the published workspace identity resolves to a different ID, while preserving offline-first startup before that identity is available. Model: GPT-5 --- .../src/hooks/use-resolved-workspace-scope.ts | 4 +- .../use-resolved-workspace-scope.test.tsx | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/components/src/hooks/use-resolved-workspace-scope.ts b/packages/components/src/hooks/use-resolved-workspace-scope.ts index c99ac7aab..66e44e45e 100644 --- a/packages/components/src/hooks/use-resolved-workspace-scope.ts +++ b/packages/components/src/hooks/use-resolved-workspace-scope.ts @@ -36,8 +36,8 @@ export function useResolvedWorkspaceScope(options: WorkspaceScopeOptions = {}): targetSlug: routeTargetSlug, runtime, docMetaScope, - organizationsReady: false, - expectedWorkspaceId: null, + organizationsReady: currentWorkspaceId !== null, + expectedWorkspaceId: currentWorkspaceId, }); const readyWorkspaceId = scope.status === 'ready' ? scope.workspaceId : null; const requestedWorkspaceId = diff --git a/packages/components/tests/use-resolved-workspace-scope.test.tsx b/packages/components/tests/use-resolved-workspace-scope.test.tsx index 5f151ea51..111e1c67d 100644 --- a/packages/components/tests/use-resolved-workspace-scope.test.tsx +++ b/packages/components/tests/use-resolved-workspace-scope.test.tsx @@ -5,7 +5,10 @@ import { createRoot, type Root } from 'react-dom/client'; import { Provider, createStore } from 'jotai'; import { afterEach, describe, expect, it } from 'vitest'; import type { WorkspaceId } from '@lody/shared'; +import type { WorkspaceRuntime } from '../src/atoms/runtime'; import { setWorkspaceContextAtom } from '../src/atoms/workspace-context'; +import { docMetaCacheScopeAtom } from '../src/atoms/doc-meta'; +import { runtimeAtom } from '../src/atoms/runtime'; import { WorkspaceRouteTargetProvider } from '../src/providers/workspace-route-target'; import { useResolvedWorkspaceScope, @@ -77,6 +80,44 @@ describe('useResolvedWorkspaceScope', () => { expect(snapshot).toEqual({ workspaceId: null, enabled: false }); }); + it('fails closed when the authoritative id disagrees with a same-slug cached runtime', async () => { + const store = createStore(); + const runtime = { + workspaceSlug: 'workspace-a', + workspaceId: 'cached-workspace-a-id' as WorkspaceId, + } as WorkspaceRuntime; + store.set(runtimeAtom, runtime); + store.set(docMetaCacheScopeAtom, { + runtime, + workspaceId: runtime.workspaceId, + workspaceSlug: runtime.workspaceSlug, + ready: true, + }); + store.set(setWorkspaceContextAtom, { + slug: 'workspace-a', + workspaceId: 'server-workspace-a-id' as WorkspaceId, + }); + let snapshot: ScopeSnapshot | undefined; + + await render( + createElement( + Provider, + { store }, + createElement( + WorkspaceRouteTargetProvider, + { slug: 'workspace-a' }, + createElement(ScopeProbe, { + onSnapshot: (value) => { + snapshot = value; + }, + }) + ) + ) + ); + + expect(snapshot).toEqual({ workspaceId: null, enabled: false }); + }); + it('preserves the default workspace identity outside a workspace route provider', async () => { const store = createStore(); store.set(setWorkspaceContextAtom, { From 571f51df6f1722ea65d5c54e6cf3070853230d4c Mon Sep 17 00:00:00 2001 From: wibus-wee <62133302+wibus-wee@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:29:43 +0800 Subject: [PATCH 4/4] test(components): cover workspace recovery races Model: gpt-5.6-sol --- .../components/tests/useOrganization.test.tsx | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/packages/components/tests/useOrganization.test.tsx b/packages/components/tests/useOrganization.test.tsx index 50fafef03..bb234a114 100644 --- a/packages/components/tests/useOrganization.test.tsx +++ b/packages/components/tests/useOrganization.test.tsx @@ -324,6 +324,102 @@ describe('useOrganization setActive dedupe', () => { expect(store.get(currentWorkspaceIdAtom)).toBe('workspace-new'); }); + it.each(['delete', 'leave'] as const)( + 'retries the newer route after a %s fallback wins and its first restoration fails', + async (operation) => { + const deferredFallback = createDeferred<{ + data: TestOrganization; + error: null; + }>(); + organizationMocks[ + operation === 'delete' ? 'deleteOrganization' : 'leaveOrganization' + ].mockResolvedValueOnce({ + data: { id: 'workspace-old' }, + error: null, + }); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + await render('old-workspace', 0); + + // Clear module-level setActive dedupe state left by an earlier test. + await act(async () => { + await latestOrganizationState!.activateOrganization('workspace-reset'); + }); + organizationMocks.setActive.mockClear(); + + let newerTargetAttempts = 0; + organizationMocks.setActive.mockImplementation( + async ({ organizationId }: { organizationId: string }) => { + if (organizationId === 'workspace-target') { + const response = await deferredFallback.promise; + activeOrganization = createOrganization( + 'workspace-target', + 'target-workspace', + 'Target Workspace' + ); + return response; + } + if (organizationId === 'workspace-new') { + newerTargetAttempts += 1; + if (newerTargetAttempts === 1) { + return { data: null, error: { message: 'temporary switch failure' } }; + } + activeOrganization = createOrganization( + 'workspace-new', + 'workspace-new', + 'New Workspace' + ); + } + return { + data: createOrganization(organizationId, organizationId, organizationId), + error: null, + }; + } + ); + + let mutation!: Promise; + await act(async () => { + mutation = + operation === 'delete' + ? latestOrganizationState!.deleteOrganization('workspace-old') + : latestOrganizationState!.leaveOrganization('workspace-old'); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(organizationMocks.setActive).toHaveBeenCalledWith({ + organizationId: 'workspace-target', + }); + + activeOrganization = createOrganization('workspace-new', 'workspace-new', 'New Workspace'); + await render('workspace-new', 1); + deferredFallback.resolve({ + data: createOrganization('workspace-target', 'target-workspace', 'Target Workspace'), + error: null, + }); + await act(async () => { + await mutation; + }); + + expect(newerTargetAttempts).toBe(2); + expect(organizationMocks.refetchActiveOrganization).toHaveBeenCalled(); + expect(organizationMocks.setActive).toHaveBeenLastCalledWith({ + organizationId: 'workspace-new', + }); + expect(store.get(currentWorkspaceSlugAtom)).toBe('workspace-new'); + expect(store.get(currentWorkspaceIdAtom)).toBe('workspace-new'); + + // Once the current route has restored its target, another settled render + // must not issue a third switch. + await render('workspace-new', 2); + + expect(newerTargetAttempts).toBe(2); + expect(organizationMocks.setActive).toHaveBeenLastCalledWith({ + organizationId: 'workspace-new', + }); + expect(store.get(currentWorkspaceSlugAtom)).toBe('workspace-new'); + expect(store.get(currentWorkspaceIdAtom)).toBe('workspace-new'); + } + ); + it('keeps a newer navigation identity when leave failure publishes a late rollback', async () => { const deferredLeave = createDeferred<{ data: null;