diff --git a/web/app/(commonLayout)/layout-client.tsx b/web/app/(commonLayout)/layout-client.tsx deleted file mode 100644 index b1025c5fd8..0000000000 --- a/web/app/(commonLayout)/layout-client.tsx +++ /dev/null @@ -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 ( - <> - - - - - - - -
- - {children} - - - - - - - - - - ) -} diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx index 348db5f81f..a0ccde957d 100644 --- a/web/app/(commonLayout)/layout.tsx +++ b/web/app/(commonLayout)/layout.tsx @@ -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 ( - + <> - {children} - - + + + + + + + +
+ + {children} + + + + + + + + + + + ) } +export default Layout diff --git a/web/app/(commonLayout)/loading.tsx b/web/app/(commonLayout)/loading.tsx deleted file mode 100644 index 6a975ef7d3..0000000000 --- a/web/app/(commonLayout)/loading.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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 fb6ac1c6da..3410ecbe9a 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 { 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 } 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 82de909f6a..51d693f358 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() - - 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]) diff --git a/web/app/components/splash.tsx b/web/app/components/splash.tsx new file mode 100644 index 0000000000..e4103e8c93 --- /dev/null +++ b/web/app/components/splash.tsx @@ -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 = () => { + // 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 a8a1c469d0..199c12e814 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -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.DATA_API_PREFIX]: process.env.NEXT_PUBLIC_API_PREFIX, @@ -123,15 +108,13 @@ const LocaleLayout = async ({ - - - - - {children} - - - - + + + + {children} + + + diff --git a/web/context/global-public-context.tsx b/web/context/global-public-context.tsx index e3d6902fd1..3a570fc7ef 100644 --- a/web/context/global-public-context.tsx +++ b/web/context/global-public-context.tsx @@ -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 = ({ 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
diff --git a/web/service/use-common.ts b/web/service/use-common.ts index 9bed6ae52b..ca0845d95a 100644 --- a/web/service/use-common.ts +++ b/web/service/use-common.ts @@ -92,6 +92,8 @@ export const useUserProfile = () => { }, } }, + staleTime: 0, + gcTime: 0, }) } diff --git a/web/utils/ssr-fetch.ts b/web/utils/ssr-fetch.ts deleted file mode 100644 index c02192f1b3..0000000000 --- a/web/utils/ssr-fetch.ts +++ /dev/null @@ -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 = { - 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() -}