diff --git a/web/app/(commonLayout)/layout-client.tsx b/web/app/(commonLayout)/layout-client.tsx
new file mode 100644
index 0000000000..b1025c5fd8
--- /dev/null
+++ b/web/app/(commonLayout)/layout-client.tsx
@@ -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 (
+ <>
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx
index a0ccde957d..348db5f81f 100644
--- a/web/app/(commonLayout)/layout.tsx
+++ b/web/app/(commonLayout)/layout.tsx
@@ -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 (
- <>
+
-
-
-
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
-
-
-
-
- >
+ {children}
+
+
)
}
-export default Layout
diff --git a/web/app/(commonLayout)/loading.tsx b/web/app/(commonLayout)/loading.tsx
new file mode 100644
index 0000000000..6a975ef7d3
--- /dev/null
+++ b/web/app/(commonLayout)/loading.tsx
@@ -0,0 +1,21 @@
+import '@/app/components/base/loading/style.css'
+
+export default function CommonLayoutLoading() {
+ return (
+
+
+
+ )
+}
diff --git a/web/app/components/app-initializer.tsx b/web/app/components/app-initializer.tsx
index 3410ecbe9a..fb6ac1c6da 100644
--- a/web/app/components/app-initializer.tsx
+++ b/web/app/components/app-initializer.tsx
@@ -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
}
diff --git a/web/app/components/billing/partner-stack/use-ps-info.ts b/web/app/components/billing/partner-stack/use-ps-info.ts
index 51d693f358..82de909f6a 100644
--- a/web/app/components/billing/partner-stack/use-ps-info.ts
+++ b/web/app/components/billing/partner-stack/use-ps-info.ts
@@ -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])
diff --git a/web/app/components/splash.tsx b/web/app/components/splash.tsx
deleted file mode 100644
index e4103e8c93..0000000000
--- a/web/app/components/splash.tsx
+++ /dev/null
@@ -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 = () => {
- // would auto redirect to signin page if not logged in
- const { isLoading, data: loginData } = useIsLogin()
- const isLoggedIn = loginData?.logged_in
-
- if (isLoading || !isLoggedIn) {
- return (
-
-
-
- )
- }
- return null
-}
-export default React.memo(Splash)
diff --git a/web/app/layout.tsx b/web/app/layout.tsx
index 199c12e814..a8a1c469d0 100644
--- a/web/app/layout.tsx
+++ b/web/app/layout.tsx
@@ -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.DATA_API_PREFIX]: process.env.NEXT_PUBLIC_API_PREFIX,
@@ -108,13 +123,15 @@ const LocaleLayout = async ({
-
-
-
- {children}
-
-
-
+
+
+
+
+ {children}
+
+
+
+
diff --git a/web/context/global-public-context.tsx b/web/context/global-public-context.tsx
index 3a570fc7ef..e3d6902fd1 100644
--- a/web/context/global-public-context.tsx
+++ b/web/context/global-public-context.tsx
@@ -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 = ({
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
return <>{children}>
diff --git a/web/service/use-common.ts b/web/service/use-common.ts
index ca0845d95a..9bed6ae52b 100644
--- a/web/service/use-common.ts
+++ b/web/service/use-common.ts
@@ -92,8 +92,6 @@ export const useUserProfile = () => {
},
}
},
- staleTime: 0,
- gcTime: 0,
})
}
diff --git a/web/utils/ssr-fetch.ts b/web/utils/ssr-fetch.ts
new file mode 100644
index 0000000000..c02192f1b3
--- /dev/null
+++ b/web/utils/ssr-fetch.ts
@@ -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 = {
+ data: T
+ headers: Headers
+}
+
+export async function serverFetchWithAuth(
+ path: string,
+ method = 'GET',
+ body?: unknown,
+): Promise> {
+ 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(path: string): Promise {
+ const res = await fetch(`${SSR_API_PREFIX}${path}`, { cache: 'no-store' })
+ if (!res.ok)
+ throw new Error(`${res.status}`)
+ return res.json()
+}