From ce7762979094779b21fc15879d95e99f0e6b592e Mon Sep 17 00:00:00 2001 From: stopachka Date: Wed, 27 May 2026 15:23:28 -0700 Subject: [PATCH 1/3] Make the URL the source of truth for the dashboard workspace The current workspace was tracked in four places that fought each other (React state, the `org` query param, the selected app's owner, and localStorage), which caused races: switching orgs snapped back to personal, switching to personal snapped back to the org, and the org settings gear flashed then redirected to /dash. Derive currentWorkspaceId from the `org` query param instead, so there's no lag between navigation and state. localStorage is now only a cold-start default (restore the last workspace on a bare /dash), read once and consumed on an explicit switch. Navigation flows through a single goToApp helper that always carries `org` forward. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../www/components/dash/BackToAppsButton.tsx | 5 +- client/www/components/dash/MainDashLayout.tsx | 91 ++++++++++--------- client/www/components/dash/ProfilePanel.tsx | 16 +--- client/www/pages/dash/index.tsx | 81 +++++++++-------- 4 files changed, 98 insertions(+), 95 deletions(-) diff --git a/client/www/components/dash/BackToAppsButton.tsx b/client/www/components/dash/BackToAppsButton.tsx index c026edd0c5..87a3f59f2a 100644 --- a/client/www/components/dash/BackToAppsButton.tsx +++ b/client/www/components/dash/BackToAppsButton.tsx @@ -1,8 +1,11 @@ import { ArrowUturnLeftIcon } from '@heroicons/react/24/outline'; import Link from 'next/link'; +import { useRouter } from 'next/router'; import { createPortal } from 'react-dom'; export const BackToAppsButton = () => { + const router = useRouter(); + const org = router.query.org as string | undefined; const element = document.getElementById('left-top-bar')!; if (!element) { return null; @@ -10,7 +13,7 @@ export const BackToAppsButton = () => { return createPortal( Back to Apps diff --git a/client/www/components/dash/MainDashLayout.tsx b/client/www/components/dash/MainDashLayout.tsx index f993700ead..b38a359d15 100644 --- a/client/www/components/dash/MainDashLayout.tsx +++ b/client/www/components/dash/MainDashLayout.tsx @@ -19,17 +19,17 @@ import { useRouter } from 'next/router'; export type FetchedDash = ReturnType; -const getInitialWorkspace = () => { - // pull from the "org" query param - const org = new URLSearchParams(window.location.search).get('org'); +const WORKSPACE_STORAGE_KEY = 'workspace'; - if (org) return org; - if (!window) return 'personal'; +const getSavedWorkspace = () => + typeof window === 'undefined' + ? null + : window.localStorage.getItem(WORKSPACE_STORAGE_KEY); - const possibleSaved = window.localStorage.getItem('workspace'); - - if (possibleSaved) return possibleSaved; - return 'personal'; +const saveWorkspace = (workspaceId: string) => { + if (typeof window !== 'undefined') { + window.localStorage.setItem(WORKSPACE_STORAGE_KEY, workspaceId); + } }; export const { use: useFetchedDash, provider: DashFetchProvider } = @@ -37,9 +37,38 @@ export const { use: useFetchedDash, provider: DashFetchProvider } = 'dashResponse', (args?: { workspaceId?: string | null | undefined }) => { const dashResult = useDashFetch(); - const [currentWorkspaceId, setWorkspace] = useState( - args?.workspaceId || getInitialWorkspace(), - ); + const router = useReadyRouter(); + + // Read once: the workspace we were in last time, used to reopen a bare + // /dash where you left off. An explicit switch clears it so a restored + // default never overrides a deliberate choice. + const [restoredWorkspace, setRestoredWorkspace] = + useState(getSavedWorkspace); + + // The URL is the source of truth. With no `org` in the URL we use the + // restored workspace on a bare entry, otherwise personal. An `app` in the + // URL resolves its own workspace (see pages/dash), so we don't restore + // then. The devtool seeds the workspace via `args`. + const urlOrg = router.query.org as string | undefined; + const isBareEntry = !urlOrg && !router.query.app; + const currentWorkspaceId = + urlOrg ?? + args?.workspaceId ?? + (isBareEntry ? restoredWorkspace : null) ?? + 'personal'; + + const setWorkspace = ( + workspaceId: string | 'personal', + opts?: { replace?: boolean }, + ) => { + setRestoredWorkspace(null); + const query = workspaceId === 'personal' ? {} : { org: workspaceId }; + if (opts?.replace) { + router.replace({ query }); + } else { + router.push({ query }); + } + }; const workspace = useWorkspace(dashResult, currentWorkspaceId); @@ -48,43 +77,19 @@ export const { use: useFetchedDash, provider: DashFetchProvider } = await workspace.mutate(); }; - const router = useReadyRouter(); + // Remember the current workspace so the next bare /dash can restore it. + useEffect(() => { + saveWorkspace(currentWorkspaceId); + }, [currentWorkspaceId]); + // If we can't load the org (e.g. we were removed from it) fall back to + // the personal account. useEffect(() => { if (workspace.error) { - setWorkspace('personal'); + setWorkspace('personal', { replace: true }); } }, [workspace.error]); - useEffect(() => { - if (typeof window === 'undefined') return; - - window.localStorage.setItem('workspace', currentWorkspaceId); - - // Use Next.js router for navigation instead of direct history manipulation - const currentUrl = new URL(window.location.href); - - // set the query param - // if its personal remove the query param - if (currentWorkspaceId === 'personal') { - if (currentUrl.searchParams.has('org')) { - const newUrl = new URL(window.location.href); - newUrl.searchParams.delete('org'); - router.replace(newUrl.pathname + newUrl.search, undefined, { - shallow: true, - }); - } - } else { - if (currentUrl.searchParams.get('org') !== currentWorkspaceId) { - const newUrl = new URL(window.location.href); - newUrl.searchParams.set('org', currentWorkspaceId); - router.replace(newUrl.pathname + newUrl.search, undefined, { - shallow: true, - }); - } - } - }, [currentWorkspaceId, router.pathname]); - const addNewAppOptimistically = ( promise: Promise, app: InstantApp, diff --git a/client/www/components/dash/ProfilePanel.tsx b/client/www/components/dash/ProfilePanel.tsx index 6cd5eaf85c..6941ad40d5 100644 --- a/client/www/components/dash/ProfilePanel.tsx +++ b/client/www/components/dash/ProfilePanel.tsx @@ -8,7 +8,6 @@ import { } from '@heroicons/react/24/outline'; import clsx from 'clsx'; import Link from 'next/link'; -import { useReadyRouter } from '../clientOnlyPage'; import { UserSettingsIcon } from '../icons/UserSettingsIcon'; import { Button, @@ -24,7 +23,6 @@ import { useFlag } from '@/lib/hooks/useFlag'; export const ProfilePanel = () => { const dashResponse = useFetchedDash(); - const router = useReadyRouter(); const email = dashResponse.data.user.email; @@ -74,9 +72,8 @@ export const ProfilePanel = () => { )} >