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() +}