From 28339658159090443c97de12d33a818c60f036a6 Mon Sep 17 00:00:00 2001 From: yyh Date: Sun, 1 Feb 2026 18:27:04 +0800 Subject: [PATCH] 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). --- web/app/(commonLayout)/layout-client.tsx | 39 ++++++ web/app/(commonLayout)/layout.tsx | 75 +++++------ web/app/(commonLayout)/loading.tsx | 21 +++ web/app/components/app-initializer.tsx | 122 ++++++++---------- .../billing/partner-stack/use-ps-info.ts | 8 +- web/app/components/splash.tsx | 21 --- web/app/layout.tsx | 31 ++++- web/context/global-public-context.tsx | 19 +-- web/service/use-common.ts | 2 - web/utils/ssr-fetch.ts | 48 +++++++ 10 files changed, 239 insertions(+), 147 deletions(-) create mode 100644 web/app/(commonLayout)/layout-client.tsx create mode 100644 web/app/(commonLayout)/loading.tsx delete mode 100644 web/app/components/splash.tsx create mode 100644 web/utils/ssr-fetch.ts 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() +}