refactor(web): sync deployment route state from next route (#37811)

This commit is contained in:
Stephen Zhou 2026-06-23 17:49:25 +08:00 committed by GitHub
parent 725e4da29d
commit ac06deba20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 119 additions and 52 deletions

View File

@ -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 (
<>
<DeploymentsRouteStateHydrator />
{children}
<DeployDrawer />
</>

View File

@ -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 }) {
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<NextRouteStateBridge />
<MainNavLayout>
<RoleRouteGuard>
{children}

View File

@ -0,0 +1,49 @@
import { atom } from 'jotai'
export type NextRouteParams = Record<string, string | string[]>
type NextRouteState = {
pathname: string
params: NextRouteParams
}
const nextRouteStateAtom = atom<NextRouteState>({
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,
})
}
})

View File

@ -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<NextRouteParams>()
const setNextRouteState = useSetAtom(setNextRouteStateAtom)
useIsomorphicLayoutEffect(() => {
setNextRouteState({
pathname,
params,
})
}, [params, pathname, setNextRouteState])
return null
}

View File

@ -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<typeof createStore>, 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({

View File

@ -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<typeof createStore>, 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',

View File

@ -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<typeof createStore>, 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([

View File

@ -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
}

View File

@ -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<string | undefined>(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
})