From ac06deba20056f38287e9c44ceca5093037a3464 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Tue, 23 Jun 2026 17:49:25 +0800 Subject: [PATCH] refactor(web): sync deployment route state from next route (#37811) --- web/app/(commonLayout)/deployments/layout.tsx | 2 - web/app/(commonLayout)/layout.tsx | 2 + web/app/components/next-route-state/atoms.ts | 49 +++++++++++++++++++ web/app/components/next-route-state/index.tsx | 24 +++++++++ .../detail/__tests__/state.spec.ts | 11 ++++- .../versions-tab/__tests__/state.spec.ts | 13 +++-- .../deployments/nav/__tests__/state.spec.ts | 18 ++++--- .../deployments/route-state-hydrator.tsx | 35 ------------- web/features/deployments/route-state.ts | 17 ++++++- 9 files changed, 119 insertions(+), 52 deletions(-) create mode 100644 web/app/components/next-route-state/atoms.ts create mode 100644 web/app/components/next-route-state/index.tsx delete mode 100644 web/features/deployments/route-state-hydrator.tsx diff --git a/web/app/(commonLayout)/deployments/layout.tsx b/web/app/(commonLayout)/deployments/layout.tsx index b8088169fd5..eb522444778 100644 --- a/web/app/(commonLayout)/deployments/layout.tsx +++ b/web/app/(commonLayout)/deployments/layout.tsx @@ -1,13 +1,11 @@ import type { ReactNode } from 'react' import { DeployDrawer } from '@/features/deployments/deploy-drawer' -import { DeploymentsRouteStateHydrator } from '@/features/deployments/route-state-hydrator' export default function DeploymentsLayout({ children }: { children: ReactNode }) { return ( <> - {children} diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx index 4d6bdf7984c..6092618bfb3 100644 --- a/web/app/(commonLayout)/layout.tsx +++ b/web/app/(commonLayout)/layout.tsx @@ -7,6 +7,7 @@ import Zendesk from '@/app/components/base/zendesk' import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder' import { GotoAnything } from '@/app/components/goto-anything' import MainNavLayout from '@/app/components/main-nav/layout' +import { NextRouteStateBridge } from '@/app/components/next-route-state' import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics' import ReadmePanel from '@/app/components/plugins/readme-panel' import WorkflowGeneratorMount from '@/app/components/workflow/workflow-generator/mount' @@ -30,6 +31,7 @@ export default async function Layout({ children }: { children: ReactNode }) { + {children} diff --git a/web/app/components/next-route-state/atoms.ts b/web/app/components/next-route-state/atoms.ts new file mode 100644 index 00000000000..1997f284976 --- /dev/null +++ b/web/app/components/next-route-state/atoms.ts @@ -0,0 +1,49 @@ +import { atom } from 'jotai' + +export type NextRouteParams = Record + +type NextRouteState = { + pathname: string + params: NextRouteParams +} + +const nextRouteStateAtom = atom({ + pathname: '', + params: {}, +}) + +function normalizedParamEntries(params: NextRouteParams) { + return Object.keys(params) + .sort() + .map((key) => { + const value = params[key] + return [key, Array.isArray(value) ? [...value] : value] as const + }) +} + +function normalizeNextRouteParams(params: NextRouteParams): NextRouteParams { + return Object.fromEntries(normalizedParamEntries(params)) as NextRouteParams +} + +function routeParamsKey(params: NextRouteParams) { + return JSON.stringify(normalizedParamEntries(params)) +} + +export const nextPathnameAtom = atom(get => get(nextRouteStateAtom).pathname) + +export const nextParamsAtom = atom(get => get(nextRouteStateAtom).params) + +export const setNextRouteStateAtom = atom(null, (get, set, routeState: NextRouteState) => { + const nextParams = normalizeNextRouteParams(routeState.params) + const currentRouteState = get(nextRouteStateAtom) + + if ( + currentRouteState.pathname !== routeState.pathname + || routeParamsKey(currentRouteState.params) !== routeParamsKey(nextParams) + ) { + set(nextRouteStateAtom, { + pathname: routeState.pathname, + params: nextParams, + }) + } +}) diff --git a/web/app/components/next-route-state/index.tsx b/web/app/components/next-route-state/index.tsx new file mode 100644 index 00000000000..7fb2d83c767 --- /dev/null +++ b/web/app/components/next-route-state/index.tsx @@ -0,0 +1,24 @@ +'use client' + +import type { NextRouteParams } from './atoms' +import { useIsomorphicLayoutEffect } from 'foxact/use-isomorphic-layout-effect' +import { useSetAtom } from 'jotai' +import { useParams, usePathname } from '@/next/navigation' +import { + setNextRouteStateAtom, +} from './atoms' + +export function NextRouteStateBridge() { + const pathname = usePathname() + const params = useParams() + const setNextRouteState = useSetAtom(setNextRouteStateAtom) + + useIsomorphicLayoutEffect(() => { + setNextRouteState({ + pathname, + params, + }) + }, [params, pathname, setNextRouteState]) + + return null +} diff --git a/web/features/deployments/detail/__tests__/state.spec.ts b/web/features/deployments/detail/__tests__/state.spec.ts index 5d7b9d47948..1a53b843c4c 100644 --- a/web/features/deployments/detail/__tests__/state.spec.ts +++ b/web/features/deployments/detail/__tests__/state.spec.ts @@ -2,7 +2,7 @@ import type { Getter } from 'jotai' import { skipToken } from '@tanstack/react-query' import { atom, createStore } from 'jotai' import { describe, expect, it, vi } from 'vitest' -import { deploymentRouteAppInstanceIdAtom } from '../../route-state' +import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms' type QueryOptions = { enabled?: boolean @@ -65,6 +65,13 @@ async function loadState() { return await import('../state') } +function setDeploymentRoute(store: ReturnType, appInstanceId = 'app-instance-1') { + store.set(setNextRouteStateAtom, { + pathname: `/deployments/${appInstanceId}/overview`, + params: { appInstanceId }, + }) +} + describe('deployment detail state', () => { it('should disable detail queries with skipToken until a route app instance exists', async () => { const state = await loadState() @@ -92,7 +99,7 @@ describe('deployment detail state', () => { const state = await loadState() const store = createStore() - store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1') + setDeploymentRoute(store) store.set(state.deploymentSourceAppIdAtom, 'source-app-1') expect(store.get(state.deploymentDetailAppInstanceQueryAtom)).toMatchObject({ diff --git a/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts b/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts index 7feae92226a..2e58fb3bb80 100644 --- a/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts +++ b/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts @@ -2,7 +2,7 @@ import type { Getter } from 'jotai' import { skipToken } from '@tanstack/react-query' import { atom, createStore } from 'jotai' import { describe, expect, it, vi } from 'vitest' -import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' +import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms' type QueryOptions = { enabled?: boolean @@ -57,6 +57,13 @@ async function loadState() { return await import('../state') } +function setDeploymentRoute(store: ReturnType, appInstanceId = 'app-instance-1') { + store.set(setNextRouteStateAtom, { + pathname: `/deployments/${appInstanceId}/overview`, + params: { appInstanceId }, + }) +} + describe('versions tab state', () => { it('should gate release history and menu queries until route and menu state are ready', async () => { const state = await loadState() @@ -79,7 +86,7 @@ describe('versions tab state', () => { it('should build release history input from the current page', async () => { const state = await loadState() const store = createStore() - store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1') + setDeploymentRoute(store) store.set(state.setReleaseHistoryCurrentPageAtom, -1) expect(store.get(state.releaseHistoryCurrentPageAtom)).toBe(0) @@ -99,7 +106,7 @@ describe('versions tab state', () => { it('should scope deploy menu queries to the open release id', async () => { const state = await loadState() const store = createStore() - store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1') + setDeploymentRoute(store) store.set(state.setDeployReleaseMenuOpenAtom, { releaseId: 'release-1', diff --git a/web/features/deployments/nav/__tests__/state.spec.ts b/web/features/deployments/nav/__tests__/state.spec.ts index 7374aa7515c..e4eb25bae01 100644 --- a/web/features/deployments/nav/__tests__/state.spec.ts +++ b/web/features/deployments/nav/__tests__/state.spec.ts @@ -6,10 +6,7 @@ import type { import type { Getter } from 'jotai' import { atom, createStore } from 'jotai' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - deploymentRouteAppInstanceIdAtom, - deploymentsRouteActiveAtom, -} from '../../route-state' +import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms' type QueryOptions = { enabled?: boolean @@ -120,6 +117,13 @@ function setListAppInstances(appInstances: AppInstance[]) { }) } +function setDeploymentRoute(store: ReturnType, appInstanceId = 'app-instance-1') { + store.set(setNextRouteStateAtom, { + pathname: `/deployments/${appInstanceId}/overview`, + params: { appInstanceId }, + }) +} + describe('deployments nav state', () => { beforeEach(() => { vi.clearAllMocks() @@ -137,8 +141,7 @@ describe('deployments nav state', () => { it('should append the current route item when it is missing from the list query', async () => { const state = await loadState() const store = createStore() - store.set(deploymentsRouteActiveAtom, true) - store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1') + setDeploymentRoute(store) setListAppInstances([ appInstance({ id: 'app-instance-2', @@ -172,8 +175,7 @@ describe('deployments nav state', () => { it('should use the route id as a fallback current item name', async () => { const state = await loadState() const store = createStore() - store.set(deploymentsRouteActiveAtom, true) - store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1') + setDeploymentRoute(store) setListAppInstances([]) expect(store.get(state.deploymentsNavItemsAtom)).toMatchObject([ diff --git a/web/features/deployments/route-state-hydrator.tsx b/web/features/deployments/route-state-hydrator.tsx deleted file mode 100644 index e2c6fefd44f..00000000000 --- a/web/features/deployments/route-state-hydrator.tsx +++ /dev/null @@ -1,35 +0,0 @@ -'use client' - -import { useSetAtom } from 'jotai' -import { useHydrateAtoms } from 'jotai/react/utils' -import { useEffect } from 'react' -import { useParams } from '@/next/navigation' -import { - deploymentRouteAppInstanceIdAtom, - deploymentsRouteActiveAtom, -} from './route-state' - -function routeAppInstanceId(params?: { appInstanceId?: string | string[] }) { - return typeof params?.appInstanceId === 'string' ? params.appInstanceId : undefined -} - -export function DeploymentsRouteStateHydrator() { - const params = useParams<{ appInstanceId?: string | string[] }>() - const appInstanceId = routeAppInstanceId(params) - const setDeploymentsRouteActive = useSetAtom(deploymentsRouteActiveAtom) - const setRouteAppInstanceId = useSetAtom(deploymentRouteAppInstanceIdAtom) - - useHydrateAtoms([ - [deploymentsRouteActiveAtom, true], - [deploymentRouteAppInstanceIdAtom, appInstanceId], - ] as const, { dangerouslyForceHydrate: true }) - - useEffect(() => { - return () => { - setDeploymentsRouteActive(false) - setRouteAppInstanceId(undefined) - } - }, [setDeploymentsRouteActive, setRouteAppInstanceId]) - - return null -} diff --git a/web/features/deployments/route-state.ts b/web/features/deployments/route-state.ts index 8e1d7d5fead..fc2c7397188 100644 --- a/web/features/deployments/route-state.ts +++ b/web/features/deployments/route-state.ts @@ -1,6 +1,19 @@ 'use client' import { atom } from 'jotai' +import { + nextParamsAtom, + nextPathnameAtom, +} from '@/app/components/next-route-state/atoms' -export const deploymentsRouteActiveAtom = atom(false) -export const deploymentRouteAppInstanceIdAtom = atom(undefined) +function isDeploymentsRoute(pathname: string) { + return pathname === '/deployments' || pathname.startsWith('/deployments/') +} + +export const deploymentsRouteActiveAtom = atom(get => isDeploymentsRoute(get(nextPathnameAtom))) + +export const deploymentRouteAppInstanceIdAtom = atom((get) => { + const appInstanceId = get(nextParamsAtom).appInstanceId + + return typeof appInstanceId === 'string' ? appInstanceId : undefined +})