refactor!: migrate commonLayout to SSR prefetch with TanStack Query hydration

BREAKING CHANGE: commonLayout is now an async Server Component that
prefetches user-profile and current-workspace on the server via
TanStack Query's prefetchQuery + HydrationBoundary pattern. This
replaces the previous purely client-side data fetching approach.

Key changes:

- **SSR data prefetch (root layout)**: prefetch systemFeatures and
  setupStatus in the root layout server component, wrap children with
  HydrationBoundary to hydrate TanStack Query cache on the client.

- **SSR data prefetch (commonLayout)**: convert commonLayout from a
  client component to an async server component that prefetches
  user-profile (with x-version/x-env response headers) and
  current-workspace. Client-side providers/UI extracted to a new
  layout-client.tsx component.

- **Add loading.tsx (Next.js convention)**: add a Next.js loading.tsx
  file in commonLayout that shows a centered spinner. This replaces the
  deleted Splash component but works via Next.js built-in Suspense
  boundary for route segments, not a client-side overlay.

- **Extract shared SSR fetch utilities (utils/ssr-fetch.ts)**: create
  serverFetch (unauthenticated) and serverFetchWithAuth (with cookie
  forwarding + CSRF token). getAuthHeaders is wrapped with React.cache()
  for per-request deduplication across multiple SSR fetches.

- **Refactor AppInitializer**: split single monolithic async IIFE effect
  into three independent useEffects (oauth tracking, education verify,
  setup status check). Use useReducer for init flag, useRef to prevent
  duplicate tracking in StrictMode. Now reads setupStatus from TanStack
  Query cache (useSetupStatusQuery) instead of fetching independently.

- **Refactor global-public-context**: move Zustand store sync from
  queryFn side-effect to a dedicated useEffect, keeping queryFn pure.
  fetchSystemFeatures now simply returns the API response.

- **Fix usePSInfo SSR crash**: defer globalThis.location access from
  hook top-level to callback execution time via getDomain() helper,
  preventing "Cannot read properties of undefined" during server render.

- **Remove Splash component**: delete the client-side loading overlay
  that relied on useIsLogin polling, replaced by Next.js loading.tsx.

- **Remove staleTime/gcTime overrides in useUserProfile**: allow the
  SSR-prefetched data to be reused via default cache policy instead of
  forcing refetch on every mount.

- **Revert middleware auth guard**: remove the cookie-based session
  check in proxy.ts that caused false redirects to /signin for
  authenticated users (Dify's auth uses token refresh, not simple
  cookie presence).
This commit is contained in:
yyh
2026-02-01 18:27:04 +08:00
parent 3ca767de47
commit 2833965815
10 changed files with 239 additions and 147 deletions

View File

@ -0,0 +1,39 @@
'use client'
import type { ReactNode } from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import AmplitudeProvider from '@/app/components/base/amplitude'
import GotoAnything from '@/app/components/goto-anything'
import Header from '@/app/components/header'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import ReadmePanel from '@/app/components/plugins/readme-panel'
import { AppContextProvider } from '@/context/app-context'
import { EventEmitterContextProvider } from '@/context/event-emitter'
import { ModalContextProvider } from '@/context/modal-context'
import { ProviderContextProvider } from '@/context/provider-context'
import PartnerStack from '../components/billing/partner-stack'
export const CommonLayoutClient = ({ children }: { children: ReactNode }) => {
return (
<>
<AmplitudeProvider />
<AppInitializer>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<HeaderWrapper>
<Header />
</HeaderWrapper>
{children}
<PartnerStack />
<ReadmePanel />
<GotoAnything />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
</AppInitializer>
</>
)
}

View File

@ -1,45 +1,46 @@
import type { ReactNode } from 'react'
import * as React from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import GA, { GaType } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import GotoAnything from '@/app/components/goto-anything'
import Header from '@/app/components/header'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import ReadmePanel from '@/app/components/plugins/readme-panel'
import { AppContextProvider } from '@/context/app-context'
import { EventEmitterContextProvider } from '@/context/event-emitter'
import { ModalContextProvider } from '@/context/modal-context'
import { ProviderContextProvider } from '@/context/provider-context'
import PartnerStack from '../components/billing/partner-stack'
import Splash from '../components/splash'
import { getQueryClientServer } from '@/context/query-client-server'
import { serverFetchWithAuth } from '@/utils/ssr-fetch'
import { CommonLayoutClient } from './layout-client'
const IS_DEV = process.env.NODE_ENV === 'development'
async function fetchUserProfileForSSR() {
const { data: profile, headers } = await serverFetchWithAuth('/account/profile')
return {
profile,
meta: {
currentVersion: headers.get('x-version'),
currentEnv: IS_DEV ? 'DEVELOPMENT' : headers.get('x-env'),
},
}
}
export default async function CommonLayout({ children }: { children: ReactNode }) {
const queryClient = getQueryClientServer()
await Promise.all([
queryClient.prefetchQuery({
queryKey: ['common', 'user-profile'],
queryFn: fetchUserProfileForSSR,
}),
queryClient.prefetchQuery({
queryKey: ['common', 'current-workspace'],
queryFn: async () => {
const { data } = await serverFetchWithAuth('/workspaces/current', 'POST', {})
return data
},
}),
])
const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
<HydrationBoundary state={dehydrate(queryClient)}>
<GA gaType={GaType.admin} />
<AmplitudeProvider />
<AppInitializer>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<HeaderWrapper>
<Header />
</HeaderWrapper>
{children}
<PartnerStack />
<ReadmePanel />
<GotoAnything />
<Splash />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
<Zendesk />
</AppInitializer>
</>
<CommonLayoutClient>{children}</CommonLayoutClient>
<Zendesk />
</HydrationBoundary>
)
}
export default Layout

View File

@ -0,0 +1,21 @@
import '@/app/components/base/loading/style.css'
export default function CommonLayoutLoading() {
return (
<div className="flex h-full w-full items-center justify-center">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="spin-animation">
<g clipPath="url(#clip0_324_2488)">
<path d="M15 0H10C9.44772 0 9 0.447715 9 1V6C9 6.55228 9.44772 7 10 7H15C15.5523 7 16 6.55228 16 6V1C16 0.447715 15.5523 0 15 0Z" fill="#1C64F2" />
<path opacity="0.5" d="M15 9H10C9.44772 9 9 9.44772 9 10V15C9 15.5523 9.44772 16 10 16H15C15.5523 16 16 15.5523 16 15V10C16 9.44772 15.5523 9 15 9Z" fill="#1C64F2" />
<path opacity="0.1" d="M6 9H1C0.447715 9 0 9.44772 0 10V15C0 15.5523 0.447715 16 1 16H6C6.55228 16 7 15.5523 7 15V10C7 9.44772 6.55228 9 6 9Z" fill="#1C64F2" />
<path opacity="0.2" d="M6 0H1C0.447715 0 0 0.447715 0 1V6C0 6.55228 0.447715 7 1 7H6C6.55228 7 7 6.55228 7 6V1C7 0.447715 6.55228 0 6 0Z" fill="#1C64F2" />
</g>
<defs>
<clipPath id="clip0_324_2488">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
</div>
)
}

View File

@ -2,15 +2,15 @@
import type { ReactNode } from 'react'
import Cookies from 'js-cookie'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useRouter, useSearchParams } from 'next/navigation'
import { parseAsString, useQueryState } from 'nuqs'
import { useCallback, useEffect, useState } from 'react'
import { useEffect, useReducer, useRef } from 'react'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { useSetupStatusQuery } from '@/context/global-public-context'
import { sendGAEvent } from '@/utils/gtag'
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect'
import { trackEvent } from './base/amplitude'
@ -23,80 +23,68 @@ export const AppInitializer = ({
}: AppInitializerProps) => {
const router = useRouter()
const searchParams = useSearchParams()
// Tokens are now stored in cookies, no need to check localStorage
const pathname = usePathname()
const [init, setInit] = useState(false)
const [init, markInit] = useReducer(() => true, false)
const { data: setupStatus } = useSetupStatusQuery()
const [oauthNewUser, setOauthNewUser] = useQueryState(
'oauth_new_user',
parseAsString.withOptions({ history: 'replace' }),
)
const isSetupFinished = useCallback(async () => {
try {
const setUpStatus = await fetchSetupStatusWithCache()
return setUpStatus.step === 'finished'
}
catch (error) {
console.error(error)
return false
}
}, [])
const oauthTrackedRef = useRef(false)
useEffect(() => {
(async () => {
const action = searchParams.get('action')
if (oauthNewUser === 'true') {
let utmInfo = null
const utmInfoStr = Cookies.get('utm_info')
if (utmInfoStr) {
try {
utmInfo = JSON.parse(utmInfoStr)
}
catch (e) {
console.error('Failed to parse utm_info cookie:', e)
}
}
// Track registration event with UTM params
trackEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
method: 'oauth',
...utmInfo,
})
sendGAEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
method: 'oauth',
...utmInfo,
})
// Clean up: remove utm_info cookie and URL params
Cookies.remove('utm_info')
setOauthNewUser(null)
}
if (action === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
if (oauthNewUser !== 'true' || oauthTrackedRef.current)
return
oauthTrackedRef.current = true
let utmInfo = null
const utmInfoStr = Cookies.get('utm_info')
if (utmInfoStr) {
try {
const isFinished = await isSetupFinished()
if (!isFinished) {
router.replace('/install')
return
}
const redirectUrl = resolvePostLoginRedirect(searchParams)
if (redirectUrl) {
location.replace(redirectUrl)
return
}
setInit(true)
utmInfo = JSON.parse(utmInfoStr)
}
catch {
router.replace('/signin')
catch (e) {
console.error('Failed to parse utm_info cookie:', e)
}
})()
}, [isSetupFinished, router, pathname, searchParams, oauthNewUser, setOauthNewUser])
}
trackEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
method: 'oauth',
...utmInfo,
})
sendGAEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
method: 'oauth',
...utmInfo,
})
Cookies.remove('utm_info')
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect -- setOauthNewUser is from nuqs useQueryState, not useState
setOauthNewUser(null)
}, [oauthNewUser, setOauthNewUser])
useEffect(() => {
const action = searchParams.get('action')
if (action === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
}, [searchParams])
useEffect(() => {
if (!setupStatus)
return
if (setupStatus.step !== 'finished') {
router.replace('/install')
return
}
const redirectUrl = resolvePostLoginRedirect(searchParams)
if (redirectUrl) {
location.replace(redirectUrl)
return
}
markInit()
}, [setupStatus, router, searchParams])
return init ? children : null
}

View File

@ -23,8 +23,8 @@ const usePSInfo = () => {
setTrue: setBind,
}] = useBoolean(false)
const { mutateAsync } = useBindPartnerStackInfo()
// Save to top domain. cloud.dify.ai => .dify.ai
const domain = globalThis.location.hostname.replace('cloud', '')
const getDomain = () => globalThis.location?.hostname.replace('cloud', '') ?? ''
const saveOrUpdate = useCallback(() => {
if (!psPartnerKey || !psClickId)
@ -37,7 +37,7 @@ const usePSInfo = () => {
}), {
expires: PARTNER_STACK_CONFIG.saveCookieDays,
path: '/',
domain,
domain: getDomain(),
})
}, [psPartnerKey, psClickId, isPSChanged])
@ -56,7 +56,7 @@ const usePSInfo = () => {
shouldRemoveCookie = true
}
if (shouldRemoveCookie)
Cookies.remove(PARTNER_STACK_CONFIG.cookieName, { path: '/', domain })
Cookies.remove(PARTNER_STACK_CONFIG.cookieName, { path: '/', domain: getDomain() })
setBind()
}
}, [psPartnerKey, psClickId, mutateAsync, hasBind, setBind])

View File

@ -1,21 +0,0 @@
'use client'
import type { FC, PropsWithChildren } from 'react'
import * as React from 'react'
import { useIsLogin } from '@/service/use-common'
import Loading from './base/loading'
const Splash: FC<PropsWithChildren> = () => {
// would auto redirect to signin page if not logged in
const { isLoading, data: loginData } = useIsLogin()
const isLoggedIn = loginData?.logged_in
if (isLoading || !isLoggedIn) {
return (
<div className="fixed inset-0 z-[9999999] flex h-full items-center justify-center bg-background-body">
<Loading />
</div>
)
}
return null
}
export default React.memo(Splash)

View File

@ -1,13 +1,16 @@
import type { Viewport } from 'next'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { Provider as JotaiProvider } from 'jotai'
import { ThemeProvider } from 'next-themes'
import { Instrument_Serif } from 'next/font/google'
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import GlobalPublicStoreProvider from '@/context/global-public-context'
import { TanstackQueryInitializer } from '@/context/query-client'
import { getQueryClientServer } from '@/context/query-client-server'
import { getLocaleOnServer } from '@/i18n-config/server'
import { DatasetAttr } from '@/types/feature'
import { cn } from '@/utils/classnames'
import { serverFetch } from '@/utils/ssr-fetch'
import { ToastProvider } from './components/base/toast'
import BrowserInitializer from './components/browser-initializer'
import { ReactScanLoader } from './components/devtools/react-scan/loader'
@ -39,6 +42,18 @@ const LocaleLayout = async ({
children: React.ReactNode
}) => {
const locale = await getLocaleOnServer()
const queryClient = getQueryClientServer()
await Promise.all([
queryClient.prefetchQuery({
queryKey: ['systemFeatures'],
queryFn: () => serverFetch('/system-features'),
}),
queryClient.prefetchQuery({
queryKey: ['setupStatus'],
queryFn: () => serverFetch('/setup'),
}),
])
const datasetMap: Record<DatasetAttr, string | undefined> = {
[DatasetAttr.DATA_API_PREFIX]: process.env.NEXT_PUBLIC_API_PREFIX,
@ -108,13 +123,15 @@ const LocaleLayout = async ({
<BrowserInitializer>
<SentryInitializer>
<TanstackQueryInitializer>
<I18nServerProvider>
<ToastProvider>
<GlobalPublicStoreProvider>
{children}
</GlobalPublicStoreProvider>
</ToastProvider>
</I18nServerProvider>
<HydrationBoundary state={dehydrate(queryClient)}>
<I18nServerProvider>
<ToastProvider>
<GlobalPublicStoreProvider>
{children}
</GlobalPublicStoreProvider>
</ToastProvider>
</I18nServerProvider>
</HydrationBoundary>
</TanstackQueryInitializer>
</SentryInitializer>
</BrowserInitializer>

View File

@ -2,6 +2,7 @@
import type { FC, PropsWithChildren } from 'react'
import type { SystemFeatures } from '@/types/feature'
import { useQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import { create } from 'zustand'
import Loading from '@/app/components/base/loading'
import { consoleClient } from '@/service/client'
@ -22,10 +23,7 @@ const systemFeaturesQueryKey = ['systemFeatures'] as const
const setupStatusQueryKey = ['setupStatus'] as const
async function fetchSystemFeatures() {
const data = await consoleClient.systemFeatures()
const { setSystemFeatures } = useGlobalPublicStore.getState()
setSystemFeatures({ ...defaultSystemFeatures, ...data })
return data
return consoleClient.systemFeatures()
}
export function useSystemFeaturesQuery() {
@ -51,13 +49,16 @@ export function useSetupStatusQuery() {
const GlobalPublicStoreProvider: FC<PropsWithChildren> = ({
children,
}) => {
// Fetch systemFeatures and setupStatus in parallel to reduce waterfall.
// setupStatus is prefetched here and cached in localStorage for AppInitializer.
const { isPending } = useSystemFeaturesQuery()
// Prefetch setupStatus for AppInitializer (result not needed here)
const { data, isPending } = useSystemFeaturesQuery()
useSetupStatusQuery()
useEffect(() => {
if (data) {
const { setSystemFeatures } = useGlobalPublicStore.getState()
setSystemFeatures({ ...defaultSystemFeatures, ...data })
}
}, [data])
if (isPending)
return <div className="flex h-screen w-screen items-center justify-center"><Loading /></div>
return <>{children}</>

View File

@ -92,8 +92,6 @@ export const useUserProfile = () => {
},
}
},
staleTime: 0,
gcTime: 0,
})
}

48
web/utils/ssr-fetch.ts Normal file
View File

@ -0,0 +1,48 @@
import { cookies } from 'next/headers'
import { cache } from 'react'
const SSR_API_PREFIX = process.env.NEXT_PUBLIC_API_PREFIX || 'http://localhost:5001/console/api'
export const getAuthHeaders = cache(async () => {
const cookieStore = await cookies()
const cookieHeader = cookieStore.getAll().map(c => `${c.name}=${c.value}`).join('; ')
const csrfToken = cookieStore.get('csrf_token')?.value
|| cookieStore.get('__Host-csrf_token')?.value
return {
'Content-Type': 'application/json',
'Cookie': cookieHeader,
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}),
}
})
type ServerFetchResult<T> = {
data: T
headers: Headers
}
export async function serverFetchWithAuth<T>(
path: string,
method = 'GET',
body?: unknown,
): Promise<ServerFetchResult<T>> {
const headers = await getAuthHeaders()
const res = await fetch(`${SSR_API_PREFIX}${path}`, {
method,
headers,
...(body ? { body: JSON.stringify(body) } : {}),
cache: 'no-store',
})
if (!res.ok)
throw new Error(`${res.status}`)
const data: T = await res.json()
return { data, headers: res.headers }
}
export async function serverFetch<T>(path: string): Promise<T> {
const res = await fetch(`${SSR_API_PREFIX}${path}`, { cache: 'no-store' })
if (!res.ok)
throw new Error(`${res.status}`)
return res.json()
}