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

This reverts commit 2833965815.
This commit is contained in:
yyh 2026-02-01 19:06:45 +08:00
parent e2913d9ee1
commit f1c15e0a17
No known key found for this signature in database
10 changed files with 147 additions and 239 deletions

View File

@ -1,39 +0,0 @@
'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,46 +1,45 @@
import type { ReactNode } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import * as React from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import AmplitudeProvider from '@/app/components/base/amplitude'
import GA, { GaType } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
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
},
}),
])
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'
const Layout = ({ children }: { children: ReactNode }) => {
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<>
<GA gaType={GaType.admin} />
<CommonLayoutClient>{children}</CommonLayoutClient>
<Zendesk />
</HydrationBoundary>
<AmplitudeProvider />
<AppInitializer>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<HeaderWrapper>
<Header />
</HeaderWrapper>
{children}
<PartnerStack />
<ReadmePanel />
<GotoAnything />
<Splash />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
<Zendesk />
</AppInitializer>
</>
)
}
export default Layout

View File

@ -1,21 +0,0 @@
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 { useRouter, useSearchParams } from 'next/navigation'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { parseAsString, useQueryState } from 'nuqs'
import { useEffect, useReducer, useRef } from 'react'
import { useCallback, useEffect, useState } 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,68 +23,80 @@ export const AppInitializer = ({
}: AppInitializerProps) => {
const router = useRouter()
const searchParams = useSearchParams()
const [init, markInit] = useReducer(() => true, false)
const { data: setupStatus } = useSetupStatusQuery()
// Tokens are now stored in cookies, no need to check localStorage
const pathname = usePathname()
const [init, setInit] = useState(false)
const [oauthNewUser, setOauthNewUser] = useQueryState(
'oauth_new_user',
parseAsString.withOptions({ history: 'replace' }),
)
const oauthTrackedRef = useRef(false)
const isSetupFinished = useCallback(async () => {
try {
const setUpStatus = await fetchSetupStatusWithCache()
return setUpStatus.step === 'finished'
}
catch (error) {
console.error(error)
return false
}
}, [])
useEffect(() => {
if (oauthNewUser !== 'true' || oauthTrackedRef.current)
return
oauthTrackedRef.current = true
(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')
let utmInfo = null
const utmInfoStr = Cookies.get('utm_info')
if (utmInfoStr) {
try {
utmInfo = JSON.parse(utmInfoStr)
const isFinished = await isSetupFinished()
if (!isFinished) {
router.replace('/install')
return
}
const redirectUrl = resolvePostLoginRedirect(searchParams)
if (redirectUrl) {
location.replace(redirectUrl)
return
}
setInit(true)
}
catch (e) {
console.error('Failed to parse utm_info cookie:', e)
catch {
router.replace('/signin')
}
}
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])
})()
}, [isSetupFinished, router, pathname, searchParams, oauthNewUser, setOauthNewUser])
return init ? children : null
}

View File

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

View File

@ -0,0 +1,21 @@
'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,16 +1,13 @@
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'
@ -42,18 +39,6 @@ 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,
@ -123,15 +108,13 @@ const LocaleLayout = async ({
<BrowserInitializer>
<SentryInitializer>
<TanstackQueryInitializer>
<HydrationBoundary state={dehydrate(queryClient)}>
<I18nServerProvider>
<ToastProvider>
<GlobalPublicStoreProvider>
{children}
</GlobalPublicStoreProvider>
</ToastProvider>
</I18nServerProvider>
</HydrationBoundary>
<I18nServerProvider>
<ToastProvider>
<GlobalPublicStoreProvider>
{children}
</GlobalPublicStoreProvider>
</ToastProvider>
</I18nServerProvider>
</TanstackQueryInitializer>
</SentryInitializer>
</BrowserInitializer>

View File

@ -2,7 +2,6 @@
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'
@ -23,7 +22,10 @@ const systemFeaturesQueryKey = ['systemFeatures'] as const
const setupStatusQueryKey = ['setupStatus'] as const
async function fetchSystemFeatures() {
return consoleClient.systemFeatures()
const data = await consoleClient.systemFeatures()
const { setSystemFeatures } = useGlobalPublicStore.getState()
setSystemFeatures({ ...defaultSystemFeatures, ...data })
return data
}
export function useSystemFeaturesQuery() {
@ -49,15 +51,12 @@ export function useSetupStatusQuery() {
const GlobalPublicStoreProvider: FC<PropsWithChildren> = ({
children,
}) => {
const { data, isPending } = useSystemFeaturesQuery()
useSetupStatusQuery()
// Fetch systemFeatures and setupStatus in parallel to reduce waterfall.
// setupStatus is prefetched here and cached in localStorage for AppInitializer.
const { isPending } = useSystemFeaturesQuery()
useEffect(() => {
if (data) {
const { setSystemFeatures } = useGlobalPublicStore.getState()
setSystemFeatures({ ...defaultSystemFeatures, ...data })
}
}, [data])
// Prefetch setupStatus for AppInitializer (result not needed here)
useSetupStatusQuery()
if (isPending)
return <div className="flex h-screen w-screen items-center justify-center"><Loading /></div>

View File

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

View File

@ -1,48 +0,0 @@
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()
}