From 1620e224bfd01ddd5b547e7a87e996573c1c025c Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:09:23 +0800 Subject: [PATCH] refactor: no global loading --- .../(commonLayout)/datasets/layout.spec.tsx | 5 +- web/app/(commonLayout)/datasets/layout.tsx | 4 - .../(commonLayout)/role-route-guard.spec.tsx | 5 +- web/app/(commonLayout)/role-route-guard.tsx | 5 -- .../components/authenticated-layout.tsx | 16 +--- web/app/(shareLayout)/components/splash.tsx | 30 +------ web/app/account/oauth/authorize/layout.tsx | 12 +-- web/app/account/oauth/authorize/page.tsx | 12 +-- web/app/components/app-initializer.tsx | 8 +- .../base/voice-input/__tests__/utils.spec.ts | 28 +++--- web/app/components/base/voice-input/index.tsx | 2 +- web/app/components/base/voice-input/utils.ts | 61 +++++++++---- .../billing/partner-stack/use-ps-info.ts | 16 ++-- web/app/components/header/header-wrapper.tsx | 5 +- .../components/header/maintenance-notice.tsx | 7 +- .../steps/install.tsx | 6 +- .../steps/install.tsx | 6 +- .../components/plugins/plugin-item/index.tsx | 16 ++-- .../update-plugin/plugin-version-picker.tsx | 2 +- web/app/components/splash.tsx | 11 +-- .../education-apply/education-apply-page.tsx | 2 +- web/app/education-apply/hooks.ts | 4 +- web/app/page.tsx | 2 - web/app/signin/invite-settings/page.tsx | 8 +- .../__tests__/global-public-context.spec.tsx | 74 +++++++++++++++ web/context/global-public-context.tsx | 5 +- web/context/modal-context-provider.tsx | 4 +- web/context/web-app-context.tsx | 18 +--- web/utils/semver.ts | 90 +++++++++++++++++-- 29 files changed, 283 insertions(+), 181 deletions(-) create mode 100644 web/context/__tests__/global-public-context.spec.tsx diff --git a/web/app/(commonLayout)/datasets/layout.spec.tsx b/web/app/(commonLayout)/datasets/layout.spec.tsx index 9c01cffba8..ac88109159 100644 --- a/web/app/(commonLayout)/datasets/layout.spec.tsx +++ b/web/app/(commonLayout)/datasets/layout.spec.tsx @@ -55,7 +55,7 @@ describe('DatasetsLayout', () => { setAppContext() }) - it('should render loading when workspace is still loading', () => { + it('should keep rendering children when workspace is still loading', () => { setAppContext({ isLoadingCurrentWorkspace: true, currentWorkspace: { id: '' }, @@ -67,8 +67,7 @@ describe('DatasetsLayout', () => { )) - expect(screen.getByRole('status')).toBeInTheDocument() - expect(screen.queryByTestId('datasets-content')).not.toBeInTheDocument() + expect(screen.getByTestId('datasets-content')).toBeInTheDocument() expect(mockReplace).not.toHaveBeenCalled() }) diff --git a/web/app/(commonLayout)/datasets/layout.tsx b/web/app/(commonLayout)/datasets/layout.tsx index a465f8222b..ca70870b15 100644 --- a/web/app/(commonLayout)/datasets/layout.tsx +++ b/web/app/(commonLayout)/datasets/layout.tsx @@ -1,7 +1,6 @@ 'use client' import { useEffect } from 'react' -import Loading from '@/app/components/base/loading' import { useAppContext } from '@/context/app-context' import { ExternalApiPanelProvider } from '@/context/external-api-panel-context' import { ExternalKnowledgeApiProvider } from '@/context/external-knowledge-api-context' @@ -19,9 +18,6 @@ export default function DatasetsLayout({ children }: { children: React.ReactNode router.replace('/apps') }, [shouldRedirect, router]) - if (isLoadingCurrentWorkspace || !currentWorkspace.id) - return - if (shouldRedirect) { return null } diff --git a/web/app/(commonLayout)/role-route-guard.spec.tsx b/web/app/(commonLayout)/role-route-guard.spec.tsx index ca1550f0b8..46138a990f 100644 --- a/web/app/(commonLayout)/role-route-guard.spec.tsx +++ b/web/app/(commonLayout)/role-route-guard.spec.tsx @@ -41,7 +41,7 @@ describe('RoleRouteGuard', () => { setAppContext() }) - it('should render loading while workspace is loading', () => { + it('should keep rendering children while workspace is loading', () => { setAppContext({ isLoadingCurrentWorkspace: true, }) @@ -52,8 +52,7 @@ describe('RoleRouteGuard', () => { )) - expect(screen.getByRole('status')).toBeInTheDocument() - expect(screen.queryByTestId('guarded-content')).not.toBeInTheDocument() + expect(screen.getByTestId('guarded-content')).toBeInTheDocument() expect(mockReplace).not.toHaveBeenCalled() }) diff --git a/web/app/(commonLayout)/role-route-guard.tsx b/web/app/(commonLayout)/role-route-guard.tsx index 483dfef095..591a494058 100644 --- a/web/app/(commonLayout)/role-route-guard.tsx +++ b/web/app/(commonLayout)/role-route-guard.tsx @@ -2,7 +2,6 @@ import type { ReactNode } from 'react' import { useEffect } from 'react' -import Loading from '@/app/components/base/loading' import { useAppContext } from '@/context/app-context' import { usePathname, useRouter } from '@/next/navigation' @@ -22,10 +21,6 @@ export default function RoleRouteGuard({ children }: { children: ReactNode }) { router.replace('/datasets') }, [shouldRedirect, router]) - // Block rendering only for guarded routes to avoid permission flicker. - if (shouldGuardRoute && isLoadingCurrentWorkspace) - return - if (shouldRedirect) return null diff --git a/web/app/(shareLayout)/components/authenticated-layout.tsx b/web/app/(shareLayout)/components/authenticated-layout.tsx index 9f956a8501..109068a99f 100644 --- a/web/app/(shareLayout)/components/authenticated-layout.tsx +++ b/web/app/(shareLayout)/components/authenticated-layout.tsx @@ -4,7 +4,6 @@ import * as React from 'react' import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' import AppUnavailable from '@/app/components/base/app-unavailable' -import Loading from '@/app/components/base/loading' import { useWebAppStore } from '@/context/web-app-context' import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { useGetUserCanAccessApp } from '@/service/access-control' @@ -18,9 +17,9 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => { const updateAppParams = useWebAppStore(s => s.updateAppParams) const updateWebAppMeta = useWebAppStore(s => s.updateWebAppMeta) const updateUserCanAccessApp = useWebAppStore(s => s.updateUserCanAccessApp) - const { isFetching: isFetchingAppParams, data: appParams, error: appParamsError } = useGetWebAppParams() - const { isFetching: isFetchingAppInfo, data: appInfo, error: appInfoError } = useGetWebAppInfo() - const { isFetching: isFetchingAppMeta, data: appMeta, error: appMetaError } = useGetWebAppMeta() + const { data: appParams, error: appParamsError } = useGetWebAppParams() + const { data: appInfo, error: appInfoError } = useGetWebAppInfo() + const { data: appMeta, error: appMetaError } = useGetWebAppMeta() const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({ appId: appInfo?.app_id, isInstalledApp: false }) useEffect(() => { @@ -81,14 +80,7 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => { return (
- {t('userProfile.logout', { ns: 'common' })} -
- ) - } - if (isFetchingAppInfo || isFetchingAppParams || isFetchingAppMeta) { - return ( -
- + {t('userProfile.logout', { ns: 'common' })}
) } diff --git a/web/app/(shareLayout)/components/splash.tsx b/web/app/(shareLayout)/components/splash.tsx index 402005752d..192a5a90e6 100644 --- a/web/app/(shareLayout)/components/splash.tsx +++ b/web/app/(shareLayout)/components/splash.tsx @@ -1,9 +1,8 @@ 'use client' import type { FC, PropsWithChildren } from 'react' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' import AppUnavailable from '@/app/components/base/app-unavailable' -import Loading from '@/app/components/base/loading' import { useWebAppStore } from '@/context/web-app-context' import { useRouter, useSearchParams } from '@/next/navigation' import { fetchAccessToken } from '@/service/share' @@ -12,7 +11,6 @@ import { setWebAppAccessToken, setWebAppPassport, webAppLoginStatus, webAppLogou const Splash: FC = ({ children }) => { const { t } = useTranslation() const shareCode = useWebAppStore(s => s.shareCode) - const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode) const embeddedUserId = useWebAppStore(s => s.embeddedUserId) const searchParams = useSearchParams() const router = useRouter() @@ -32,13 +30,9 @@ const Splash: FC = ({ children }) => { const url = getSigninUrl() router.replace(url) }, [getSigninUrl, router, shareCode]) - - const [isLoading, setIsLoading] = useState(true) useEffect(() => { - if (message) { - setIsLoading(false) + if (message) return - } if (tokenFromUrl) setWebAppAccessToken(tokenFromUrl) @@ -46,12 +40,6 @@ const Splash: FC = ({ children }) => { const redirectOrFinish = () => { if (redirectUrl) router.replace(decodeURIComponent(redirectUrl)) - else - setIsLoading(false) - } - - const proceedToAuth = () => { - setIsLoading(false) } (async () => { @@ -60,9 +48,6 @@ const Splash: FC = ({ children }) => { if (userLoggedIn && appLoggedIn) { redirectOrFinish() } - else if (!userLoggedIn && !appLoggedIn) { - proceedToAuth() - } else if (!userLoggedIn && appLoggedIn) { redirectOrFinish() } @@ -77,7 +62,6 @@ const Splash: FC = ({ children }) => { } catch { await webAppLogout(shareCode!) - proceedToAuth() } } })() @@ -86,7 +70,6 @@ const Splash: FC = ({ children }) => { redirectUrl, router, message, - webAppAccessMode, tokenFromUrl, embeddedUserId, ]) @@ -95,18 +78,11 @@ const Splash: FC = ({ children }) => { return (
- {code === '403' ? t('userProfile.logout', { ns: 'common' }) : t('login.backToHome', { ns: 'share' })} + {code === '403' ? t('userProfile.logout', { ns: 'common' }) : t('login.backToHome', { ns: 'share' })}
) } - if (isLoading) { - return ( -
- -
- ) - } return <>{children} } diff --git a/web/app/account/oauth/authorize/layout.tsx b/web/app/account/oauth/authorize/layout.tsx index 7f6b270b45..e4cb2940c9 100644 --- a/web/app/account/oauth/authorize/layout.tsx +++ b/web/app/account/oauth/authorize/layout.tsx @@ -1,6 +1,4 @@ 'use client' -import Loading from '@/app/components/base/loading' - import Header from '@/app/signin/_header' import { AppContextProvider } from '@/context/app-context-provider' import { useGlobalPublicStore } from '@/context/global-public-context' @@ -11,16 +9,8 @@ import { cn } from '@/utils/classnames' export default function SignInLayout({ children }: any) { const { systemFeatures } = useGlobalPublicStore() useDocumentTitle('') - const { isLoading, data: loginData } = useIsLogin() + const { data: loginData } = useIsLogin() const isLoggedIn = loginData?.logged_in - - if (isLoading) { - return ( -
- -
- ) - } return ( <>
diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index 30cfdd25d3..c8b90a5a21 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -12,7 +12,6 @@ import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { Avatar } from '@/app/components/base/avatar' import Button from '@/app/components/base/button' -import Loading from '@/app/components/base/loading' import { toast } from '@/app/components/base/ui/toast' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect' @@ -70,6 +69,7 @@ export default function OAuthAuthorize() { const { isLoading: isIsLoginLoading, data: loginData } = useIsLogin() const isLoggedIn = loginData?.logged_in const isLoading = isOAuthLoading || isIsLoginLoading + const isActionDisabled = !client_id || !redirect_uri || isError || isLoading || authorizing const onLoginSwitchClick = () => { try { const returnUrl = buildReturnUrl('/account/oauth/authorize', `?client_id=${encodeURIComponent(client_id)}&redirect_uri=${encodeURIComponent(redirect_uri)}`) @@ -110,14 +110,6 @@ export default function OAuthAuthorize() { } }, [client_id, redirect_uri, isError]) - if (isLoading) { - return ( -
- -
- ) - } - return (
{authAppInfo?.app_icon && ( @@ -169,7 +161,7 @@ export default function OAuthAuthorize() { ) : ( <> - + )} diff --git a/web/app/components/app-initializer.tsx b/web/app/components/app-initializer.tsx index e08ece6666..22e04520e7 100644 --- a/web/app/components/app-initializer.tsx +++ b/web/app/components/app-initializer.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from 'react' import Cookies from 'js-cookie' import { parseAsBoolean, useQueryState } from 'nuqs' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect } from 'react' import { EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, @@ -25,7 +25,6 @@ export const AppInitializer = ({ const searchParams = useSearchParams() // Tokens are now stored in cookies, no need to check localStorage const pathname = usePathname() - const [init, setInit] = useState(false) const [oauthNewUser] = useQueryState( 'oauth_new_user', parseAsBoolean.withOptions({ history: 'replace' }), @@ -87,10 +86,7 @@ export const AppInitializer = ({ const redirectUrl = resolvePostLoginRedirect() if (redirectUrl) { location.replace(redirectUrl) - return } - - setInit(true) } catch { router.replace('/signin') @@ -98,5 +94,5 @@ export const AppInitializer = ({ })() }, [isSetupFinished, router, pathname, searchParams, oauthNewUser]) - return init ? children : null + return children } diff --git a/web/app/components/base/voice-input/__tests__/utils.spec.ts b/web/app/components/base/voice-input/__tests__/utils.spec.ts index 390efaa046..8f3aca72bc 100644 --- a/web/app/components/base/voice-input/__tests__/utils.spec.ts +++ b/web/app/components/base/voice-input/__tests__/utils.spec.ts @@ -66,7 +66,7 @@ describe('convertToMp3', () => { vi.clearAllMocks() }) - it('should convert mono WAV data to an MP3 blob', () => { + it('should convert mono WAV data to an MP3 blob', async () => { const recorder = createMockRecorder({ channels: 1, sampleRate: 44100, @@ -76,7 +76,7 @@ describe('convertToMp3', () => { mocks.encodeBuffer.mockReturnValue(new Int8Array([1, 2, 3])) mocks.flush.mockReturnValue(new Int8Array([4, 5])) - const result = convertToMp3(recorder) + const result = await convertToMp3(recorder) expect(result).toBeInstanceOf(Blob) expect(result.type).toBe('audio/mp3') @@ -87,7 +87,7 @@ describe('convertToMp3', () => { expect(mocks.flush).toHaveBeenCalled() }) - it('should convert stereo WAV data to an MP3 blob', () => { + it('should convert stereo WAV data to an MP3 blob', async () => { const recorder = createMockRecorder({ channels: 2, sampleRate: 48000, @@ -98,7 +98,7 @@ describe('convertToMp3', () => { mocks.encodeBuffer.mockReturnValue(new Int8Array([10, 20])) mocks.flush.mockReturnValue(new Int8Array([30])) - const result = convertToMp3(recorder) + const result = await convertToMp3(recorder) expect(result).toBeInstanceOf(Blob) expect(result.type).toBe('audio/mp3') @@ -107,7 +107,7 @@ describe('convertToMp3', () => { expect(firstCall).toHaveLength(2) }) - it('should skip empty encoded buffers', () => { + it('should skip empty encoded buffers', async () => { const recorder = createMockRecorder({ channels: 1, sampleRate: 44100, @@ -117,14 +117,14 @@ describe('convertToMp3', () => { mocks.encodeBuffer.mockReturnValue(new Int8Array(0)) mocks.flush.mockReturnValue(new Int8Array(0)) - const result = convertToMp3(recorder) + const result = await convertToMp3(recorder) expect(result).toBeInstanceOf(Blob) expect(result.type).toBe('audio/mp3') expect(result.size).toBe(0) }) - it('should include flush data when flush returns non-empty buffer', () => { + it('should include flush data when flush returns non-empty buffer', async () => { const recorder = createMockRecorder({ channels: 1, sampleRate: 22050, @@ -134,13 +134,13 @@ describe('convertToMp3', () => { mocks.encodeBuffer.mockReturnValue(new Int8Array(0)) mocks.flush.mockReturnValue(new Int8Array([99, 98, 97])) - const result = convertToMp3(recorder) + const result = await convertToMp3(recorder) expect(result).toBeInstanceOf(Blob) expect(result.size).toBe(3) }) - it('should omit flush data when flush returns empty buffer', () => { + it('should omit flush data when flush returns empty buffer', async () => { const recorder = createMockRecorder({ channels: 1, sampleRate: 44100, @@ -150,13 +150,13 @@ describe('convertToMp3', () => { mocks.encodeBuffer.mockReturnValue(new Int8Array([1, 2])) mocks.flush.mockReturnValue(new Int8Array(0)) - const result = convertToMp3(recorder) + const result = await convertToMp3(recorder) expect(result).toBeInstanceOf(Blob) expect(result.size).toBe(2) }) - it('should process multiple chunks when sample count exceeds maxSamples (1152)', () => { + it('should process multiple chunks when sample count exceeds maxSamples (1152)', async () => { const samples = Array.from({ length: 2400 }, (_, i) => i % 32767) const recorder = createMockRecorder({ channels: 1, @@ -167,13 +167,13 @@ describe('convertToMp3', () => { mocks.encodeBuffer.mockReturnValue(new Int8Array([1])) mocks.flush.mockReturnValue(new Int8Array(0)) - const result = convertToMp3(recorder) + const result = await convertToMp3(recorder) expect(mocks.encodeBuffer.mock.calls.length).toBeGreaterThan(1) expect(result).toBeInstanceOf(Blob) }) - it('should encode stereo with right channel subarray', () => { + it('should encode stereo with right channel subarray', async () => { const recorder = createMockRecorder({ channels: 2, sampleRate: 44100, @@ -184,7 +184,7 @@ describe('convertToMp3', () => { mocks.encodeBuffer.mockReturnValue(new Int8Array([5, 6, 7])) mocks.flush.mockReturnValue(new Int8Array([8])) - const result = convertToMp3(recorder) + const result = await convertToMp3(recorder) expect(result).toBeInstanceOf(Blob) for (const call of mocks.encodeBuffer.mock.calls) { diff --git a/web/app/components/base/voice-input/index.tsx b/web/app/components/base/voice-input/index.tsx index 9ae390a3ca..b78e88352a 100644 --- a/web/app/components/base/voice-input/index.tsx +++ b/web/app/components/base/voice-input/index.tsx @@ -82,7 +82,7 @@ const VoiceInput = ({ const canvas = canvasRef.current! const ctx = ctxRef.current! ctx.clearRect(0, 0, canvas.width, canvas.height) - const mp3Blob = convertToMp3(recorder.current) + const mp3Blob = await convertToMp3(recorder.current as unknown as Parameters[0]) const mp3File = new File([mp3Blob], 'temp.mp3', { type: 'audio/mp3' }) const formData = new FormData() formData.append('file', mp3File) diff --git a/web/app/components/base/voice-input/utils.ts b/web/app/components/base/voice-input/utils.ts index 8fbd1a8b17..8b4455c119 100644 --- a/web/app/components/base/voice-input/utils.ts +++ b/web/app/components/base/voice-input/utils.ts @@ -1,24 +1,55 @@ -import lamejs from 'lamejs' -import BitStream from 'lamejs/src/js/BitStream' -import Lame from 'lamejs/src/js/Lame' -import MPEGMode from 'lamejs/src/js/MPEGMode' - -/* v8 ignore next - @preserve */ -if (globalThis) { - (globalThis as any).MPEGMode = MPEGMode - ; (globalThis as any).Lame = Lame - ; (globalThis as any).BitStream = BitStream +type RecorderLike = { + getWAV: () => ArrayBufferLike + getChannelData: () => { + left: ArrayBufferView + right?: ArrayBufferView | null + } } -export const convertToMp3 = (recorder: any) => { - const wav = lamejs.WavHeader.readHeader(recorder.getWAV()) +const toInt16Array = (view: ArrayBufferView) => { + return new Int16Array(view.buffer, view.byteOffset, view.byteLength / 2) +} + +const loadLame = async () => { + const [ + lamejsModule, + bitStreamModule, + lameModule, + mpegModeModule, + ] = await Promise.all([ + import('lamejs'), + import('lamejs/src/js/BitStream'), + import('lamejs/src/js/Lame'), + import('lamejs/src/js/MPEGMode'), + ]) + + const lamejs = lamejsModule.default + const BitStream = bitStreamModule.default + const Lame = lameModule.default + const MPEGMode = mpegModeModule.default + + /* v8 ignore next - @preserve */ + if (globalThis) { + ; (globalThis as any).MPEGMode = MPEGMode + ; (globalThis as any).Lame = Lame + ; (globalThis as any).BitStream = BitStream + } + + return lamejs +} + +export const convertToMp3 = async (recorder: RecorderLike) => { + const lamejs = await loadLame() + const wavBuffer = recorder.getWAV() + const wavView = wavBuffer instanceof DataView ? wavBuffer : new DataView(wavBuffer) + const wav = lamejs.WavHeader.readHeader(wavView) const { channels, sampleRate } = wav const mp3enc = new lamejs.Mp3Encoder(channels, sampleRate, 128) const result = recorder.getChannelData() const buffer: BlobPart[] = [] - const leftData = result.left && new Int16Array(result.left.buffer, 0, result.left.byteLength / 2) - const rightData = result.right && new Int16Array(result.right.buffer, 0, result.right.byteLength / 2) + const leftData = toInt16Array(result.left) + const rightData = result.right ? toInt16Array(result.right) : null const remaining = leftData.length + (rightData ? rightData.length : 0) const maxSamples = 1152 @@ -34,7 +65,7 @@ export const convertToMp3 = (recorder: any) => { let mp3buf = null if (channels === 2) { - right = rightData.subarray(i, i + maxSamples) + right = rightData?.subarray(i, i + maxSamples) || null mp3buf = mp3enc.encodeBuffer(left, right) } else { 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 7c45d7ef87..d2eff05c8e 100644 --- a/web/app/components/billing/partner-stack/use-ps-info.ts +++ b/web/app/components/billing/partner-stack/use-ps-info.ts @@ -24,7 +24,7 @@ const usePSInfo = () => { }] = useBoolean(false) const { mutateAsync } = useBindPartnerStackInfo() // Save to top domain. cloud.dify.ai => .dify.ai - const domain = globalThis.location.hostname.replace('cloud', '') + const domain = globalThis.location?.hostname?.replace('cloud', '') const saveOrUpdate = useCallback(() => { if (!psPartnerKey || !psClickId) @@ -37,9 +37,9 @@ const usePSInfo = () => { }), { expires: PARTNER_STACK_CONFIG.saveCookieDays, path: '/', - domain, + ...(domain ? { domain } : {}), }) - }, [psPartnerKey, psClickId, isPSChanged]) + }, [psPartnerKey, psClickId, isPSChanged, domain]) const bind = useCallback(async () => { if (psPartnerKey && psClickId && !hasBind) { @@ -55,11 +55,15 @@ const usePSInfo = () => { if ((error as { status: number })?.status === 400) shouldRemoveCookie = true } - if (shouldRemoveCookie) - Cookies.remove(PARTNER_STACK_CONFIG.cookieName, { path: '/', domain }) + if (shouldRemoveCookie) { + Cookies.remove(PARTNER_STACK_CONFIG.cookieName, { + path: '/', + ...(domain ? { domain } : {}), + }) + } setBind() } - }, [psPartnerKey, psClickId, mutateAsync, hasBind, setBind]) + }, [psPartnerKey, psClickId, mutateAsync, hasBind, setBind, domain]) return { psPartnerKey, psClickId, diff --git a/web/app/components/header/header-wrapper.tsx b/web/app/components/header/header-wrapper.tsx index e140939976..b58a5f50f1 100644 --- a/web/app/components/header/header-wrapper.tsx +++ b/web/app/components/header/header-wrapper.tsx @@ -10,6 +10,8 @@ type HeaderWrapperProps = { children: React.ReactNode } +const getWorkflowCanvasMaximize = () => globalThis.localStorage?.getItem('workflow-canvas-maximize') === 'true' + const HeaderWrapper = ({ children, }: HeaderWrapperProps) => { @@ -18,8 +20,7 @@ const HeaderWrapper = ({ // Check if the current path is a workflow canvas & fullscreen const inWorkflowCanvas = pathname.endsWith('/workflow') const isPipelineCanvas = pathname.endsWith('/pipeline') - const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true' - const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize) + const [hideHeader, setHideHeader] = useState(getWorkflowCanvasMaximize) const { eventEmitter } = useEventEmitterContextContext() eventEmitter?.useSubscription((v: any) => { diff --git a/web/app/components/header/maintenance-notice.tsx b/web/app/components/header/maintenance-notice.tsx index 4df7108177..32303247e6 100644 --- a/web/app/components/header/maintenance-notice.tsx +++ b/web/app/components/header/maintenance-notice.tsx @@ -3,16 +3,19 @@ import { X } from '@/app/components/base/icons/src/vender/line/general' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { NOTICE_I18N } from '@/i18n-config/language' +const getShowNotice = () => globalThis.localStorage?.getItem('hide-maintenance-notice') !== '1' + const MaintenanceNotice = () => { const locale = useLanguage() - const [showNotice, setShowNotice] = useState(() => localStorage.getItem('hide-maintenance-notice') !== '1') + const [showNotice, setShowNotice] = useState(getShowNotice) + const handleJumpNotice = () => { window.open(NOTICE_I18N.href, '_blank') } const handleCloseNotice = () => { - localStorage.setItem('hide-maintenance-notice', '1') + globalThis.localStorage?.setItem('hide-maintenance-notice', '1') setShowNotice(false) } diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx index 1e36daefc1..b115800548 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx @@ -5,12 +5,12 @@ import { RiLoader2Line } from '@remixicon/react' import * as React from 'react' import { useEffect, useMemo } from 'react' import { Trans, useTranslation } from 'react-i18next' -import { gte } from 'semver' import Button from '@/app/components/base/button' import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed' import { useAppContext } from '@/context/app-context' import { uninstallPlugin } from '@/service/plugins' import { useInstallPackageFromLocal, usePluginTaskList } from '@/service/use-plugins' +import { gte } from '@/utils/semver' import Card from '../../../card' import { TaskStatus } from '../../../types' import checkTaskStatus from '../../base/check-task-status' @@ -117,7 +117,7 @@ const Installed: FC = ({ return ( <>
-
+

{t(`${i18nPrefix}.readyToInstall`, { ns: 'plugin' })}

= ({ />

{!isDifyVersionCompatible && ( -

+

{t('difyVersionNotCompatible', { ns: 'plugin', minimalDifyVersion: payload.meta.minimum_dify_version })}

)} diff --git a/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx index 275d4ca47b..c2779aece8 100644 --- a/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx +++ b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx @@ -5,11 +5,11 @@ import { RiLoader2Line } from '@remixicon/react' import * as React from 'react' import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { gte } from 'semver' import Button from '@/app/components/base/button' import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed' import { useAppContext } from '@/context/app-context' import { useInstallPackageFromMarketPlace, usePluginDeclarationFromMarketPlace, usePluginTaskList, useUpdatePackageFromMarketPlace } from '@/service/use-plugins' +import { gte } from '@/utils/semver' import Card from '../../../card' // import { RiInformation2Line } from '@remixicon/react' import { TaskStatus } from '../../../types' @@ -133,10 +133,10 @@ const Installed: FC = ({ return ( <>
-
+

{t(`${i18nPrefix}.readyToInstall`, { ns: 'plugin' })}

{!isDifyVersionCompatible && ( -

+

{t('difyVersionNotCompatible', { ns: 'plugin', minimalDifyVersion: pluginDeclaration?.manifest.meta.minimum_dify_version })}

)} diff --git a/web/app/components/plugins/plugin-item/index.tsx b/web/app/components/plugins/plugin-item/index.tsx index 3f658c63a8..9a39bc936b 100644 --- a/web/app/components/plugins/plugin-item/index.tsx +++ b/web/app/components/plugins/plugin-item/index.tsx @@ -11,7 +11,6 @@ import { import * as React from 'react' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { gte } from 'semver' import Tooltip from '@/app/components/base/tooltip' import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' import { API_PREFIX } from '@/config' @@ -20,6 +19,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context' import { useRenderI18nObject } from '@/hooks/use-i18n' import useTheme from '@/hooks/use-theme' import { cn } from '@/utils/classnames' +import { gte } from '@/utils/semver' import { getMarketplaceUrl } from '@/utils/var' import Badge from '../../base/badge' import { Github } from '../../base/icons/src/public/common' @@ -164,8 +164,8 @@ const PluginItem: FC = ({ /> {category === PluginCategoryEnum.extension && ( <> -
·
-
+
·
+
= ({ && ( <> -
{t('from', { ns: 'plugin' })}
+
{t('from', { ns: 'plugin' })}
GitHub
@@ -196,7 +196,7 @@ const PluginItem: FC = ({ && ( <>
-
+
{t('from', { ns: 'plugin' })} {' '} marketplace @@ -210,7 +210,7 @@ const PluginItem: FC = ({ <>
-
Local Plugin
+
Local Plugin
)} @@ -219,14 +219,14 @@ const PluginItem: FC = ({ <>
-
Debugging Plugin
+
Debugging Plugin
)}
{/* Deprecated */} {source === PluginSource.marketplace && enable_marketplace && isDeprecated && ( -
+
· {t('deprecated', { ns: 'plugin' })} diff --git a/web/app/components/plugins/update-plugin/plugin-version-picker.tsx b/web/app/components/plugins/update-plugin/plugin-version-picker.tsx index d662c2b6e0..a1e18ba123 100644 --- a/web/app/components/plugins/update-plugin/plugin-version-picker.tsx +++ b/web/app/components/plugins/update-plugin/plugin-version-picker.tsx @@ -4,7 +4,6 @@ import type { Placement } from '@/app/components/base/ui/placement' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { lt } from 'semver' import Badge from '@/app/components/base/badge' import { Popover, @@ -14,6 +13,7 @@ import { import useTimestamp from '@/hooks/use-timestamp' import { useVersionListOfPlugin } from '@/service/use-plugins' import { cn } from '@/utils/classnames' +import { lt } from '@/utils/semver' type Props = { disabled?: boolean diff --git a/web/app/components/splash.tsx b/web/app/components/splash.tsx index e4103e8c93..82b59fe1f6 100644 --- a/web/app/components/splash.tsx +++ b/web/app/components/splash.tsx @@ -2,20 +2,15 @@ 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 ( -
- -
- ) - } + if (isLoading || !isLoggedIn) + return null + return null } export default React.memo(Splash) diff --git a/web/app/education-apply/education-apply-page.tsx b/web/app/education-apply/education-apply-page.tsx index 19ef26814d..684f831584 100644 --- a/web/app/education-apply/education-apply-page.tsx +++ b/web/app/education-apply/education-apply-page.tsx @@ -47,7 +47,7 @@ const EducationApplyAge = () => { setShowModal(undefined) onPlanInfoChanged() updateEducationStatus() - localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) + globalThis.localStorage?.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) router.replace('/') } diff --git a/web/app/education-apply/hooks.ts b/web/app/education-apply/hooks.ts index 79faa8b3b2..337659b69c 100644 --- a/web/app/education-apply/hooks.ts +++ b/web/app/education-apply/hooks.ts @@ -133,7 +133,7 @@ const useEducationReverifyNotice = ({ export const useEducationInit = () => { const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal) const setShowEducationExpireNoticeModal = useModalContextSelector(s => s.setShowEducationExpireNoticeModal) - const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) + const educationVerifying = globalThis.localStorage?.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) const searchParams = useSearchParams() const educationVerifyAction = searchParams.get('action') @@ -156,7 +156,7 @@ export const useEducationInit = () => { setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING }) if (educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION) - localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes') + globalThis.localStorage?.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes') } if (educationVerifyAction === EDUCATION_RE_VERIFY_ACTION) handleVerify() diff --git a/web/app/page.tsx b/web/app/page.tsx index 65f8827e01..5c71722692 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,4 +1,3 @@ -import Loading from '@/app/components/base/loading' import Link from '@/next/link' const Home = async () => { @@ -6,7 +5,6 @@ const Home = async () => {
-
🚀
diff --git a/web/app/signin/invite-settings/page.tsx b/web/app/signin/invite-settings/page.tsx index ac7a7191f8..16c398ad24 100644 --- a/web/app/signin/invite-settings/page.tsx +++ b/web/app/signin/invite-settings/page.tsx @@ -6,7 +6,6 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' -import Loading from '@/app/components/base/loading' import { SimpleSelect } from '@/app/components/base/select' import Toast from '@/app/components/base/toast' import { LICENSE_LINK } from '@/constants/link' @@ -65,9 +64,7 @@ export default function InviteSettingsPage() { } }, [language, name, recheck, timezone, token, router, t]) - if (!checkRes) - return - if (!checkRes.is_valid) { + if (checkRes?.is_valid === false) { return (
@@ -147,8 +144,9 @@ export default function InviteSettingsPage() { variant="primary" className="w-full" onClick={handleActivate} + disabled={!checkRes?.is_valid} > - {`${t('join', { ns: 'login' })} ${checkRes?.data?.workspace_name}`} + {`${t('join', { ns: 'login' })} ${checkRes?.data?.workspace_name ?? ''}`}
diff --git a/web/context/__tests__/global-public-context.spec.tsx b/web/context/__tests__/global-public-context.spec.tsx new file mode 100644 index 0000000000..e37efee886 --- /dev/null +++ b/web/context/__tests__/global-public-context.spec.tsx @@ -0,0 +1,74 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import GlobalPublicStoreProvider, { useGlobalPublicStore } from '@/context/global-public-context' +import { defaultSystemFeatures } from '@/types/feature' + +const mockSystemFeatures = vi.fn() +const mockFetchSetupStatusWithCache = vi.fn() + +vi.mock('@/service/client', () => ({ + consoleClient: { + systemFeatures: () => mockSystemFeatures(), + }, +})) + +vi.mock('@/utils/setup-status', () => ({ + fetchSetupStatusWithCache: () => mockFetchSetupStatusWithCache(), +})) + +const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const renderProvider = () => { + const queryClient = createQueryClient() + + return render( + + +
provider child
+
+
, + ) +} + +describe('GlobalPublicStoreProvider', () => { + beforeEach(() => { + vi.clearAllMocks() + useGlobalPublicStore.setState({ systemFeatures: defaultSystemFeatures }) + mockFetchSetupStatusWithCache.mockResolvedValue({ setup_status: 'finished' }) + }) + + describe('Rendering', () => { + it('should render children when system features are still loading', async () => { + mockSystemFeatures.mockReturnValue(new Promise(() => {})) + + renderProvider() + + expect(screen.getByText('provider child')).toBeInTheDocument() + await waitFor(() => { + expect(mockSystemFeatures).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe('State Updates', () => { + it('should update the public store when system features query succeeds', async () => { + mockSystemFeatures.mockResolvedValue({ + ...defaultSystemFeatures, + enable_marketplace: true, + }) + + renderProvider() + + await waitFor(() => { + expect(useGlobalPublicStore.getState().systemFeatures.enable_marketplace).toBe(true) + }) + expect(mockFetchSetupStatusWithCache).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/context/global-public-context.tsx b/web/context/global-public-context.tsx index 3a570fc7ef..e0ff645475 100644 --- a/web/context/global-public-context.tsx +++ b/web/context/global-public-context.tsx @@ -3,7 +3,6 @@ import type { FC, PropsWithChildren } from 'react' import type { SystemFeatures } from '@/types/feature' import { useQuery } from '@tanstack/react-query' import { create } from 'zustand' -import Loading from '@/app/components/base/loading' import { consoleClient } from '@/service/client' import { defaultSystemFeatures } from '@/types/feature' import { fetchSetupStatusWithCache } from '@/utils/setup-status' @@ -53,13 +52,11 @@ const GlobalPublicStoreProvider: FC = ({ }) => { // Fetch systemFeatures and setupStatus in parallel to reduce waterfall. // setupStatus is prefetched here and cached in localStorage for AppInitializer. - const { isPending } = useSystemFeaturesQuery() + useSystemFeaturesQuery() // Prefetch setupStatus for AppInitializer (result not needed here) useSetupStatusQuery() - if (isPending) - return
return <>{children} } export default GlobalPublicStoreProvider diff --git a/web/context/modal-context-provider.tsx b/web/context/modal-context-provider.tsx index 5f14729e74..daa1e06410 100644 --- a/web/context/modal-context-provider.tsx +++ b/web/context/modal-context-provider.tsx @@ -106,10 +106,10 @@ export const ModalContextProvider = ({ const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false) const handleCancelAccountSettingModal = () => { - const educationVerifying = localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) + const educationVerifying = globalThis.localStorage?.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) if (educationVerifying === 'yes') - localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) + globalThis.localStorage?.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) accountSettingCallbacksRef.current?.onCancelCallback?.() accountSettingCallbacksRef.current = null diff --git a/web/context/web-app-context.tsx b/web/context/web-app-context.tsx index 33679fd44f..e3971c7e08 100644 --- a/web/context/web-app-context.tsx +++ b/web/context/web-app-context.tsx @@ -6,11 +6,9 @@ import type { AppData, AppMeta } from '@/models/share' import { useEffect } from 'react' import { create } from 'zustand' import { getProcessedSystemVariablesFromUrlParams } from '@/app/components/base/chat/utils' -import Loading from '@/app/components/base/loading' import { AccessMode } from '@/models/access-control' import { usePathname, useSearchParams } from '@/next/navigation' import { useGetWebAppAccessModeByCode } from '@/service/use-share' -import { useIsSystemFeaturesPending } from './global-public-context' type WebAppStore = { shareCode: string | null @@ -65,7 +63,6 @@ const getShareCodeFromPathname = (pathname: string): string | null => { } const WebAppStoreProvider: FC = ({ children }) => { - const isGlobalPending = useIsSystemFeaturesPending() const updateWebAppAccessMode = useWebAppStore(state => state.updateWebAppAccessMode) const updateShareCode = useWebAppStore(state => state.updateShareCode) const updateEmbeddedUserId = useWebAppStore(state => state.updateEmbeddedUserId) @@ -104,24 +101,13 @@ const WebAppStoreProvider: FC = ({ children }) => { } }, [searchParamsString, updateEmbeddedUserId, updateEmbeddedConversationId]) - const { isLoading, data: accessModeResult } = useGetWebAppAccessModeByCode(shareCode) + const { data: accessModeResult } = useGetWebAppAccessModeByCode(shareCode) useEffect(() => { if (accessModeResult?.accessMode) updateWebAppAccessMode(accessModeResult.accessMode) }, [accessModeResult, updateWebAppAccessMode, shareCode]) - if (isGlobalPending || isLoading) { - return ( -
- -
- ) - } - return ( - <> - {children} - - ) + return <>{children} } export default WebAppStoreProvider diff --git a/web/utils/semver.ts b/web/utils/semver.ts index aea84153ec..811f55d8e7 100644 --- a/web/utils/semver.ts +++ b/web/utils/semver.ts @@ -1,13 +1,93 @@ -import semver from 'semver' +type SemverIdentifier = number | string -export const getLatestVersion = (versionList: string[]) => { - return semver.rsort(versionList)[0] +type ParsedSemver = { + core: number[] + prerelease: SemverIdentifier[] +} + +const parseIdentifier = (value: string): SemverIdentifier => { + if (/^\d+$/.test(value)) + return Number(value) + + return value +} + +const parseSemver = (version: string): ParsedSemver => { + const normalized = version.trim().replace(/^v/i, '').split('+')[0] + const [corePart, prereleasePart] = normalized.split('-', 2) + + return { + core: corePart.split('.').map(part => Number(part) || 0), + prerelease: prereleasePart + ? prereleasePart.split('.').filter(Boolean).map(parseIdentifier) + : [], + } +} + +const compareIdentifier = (left: SemverIdentifier, right: SemverIdentifier) => { + if (typeof left === 'number' && typeof right === 'number') + return Math.sign(left - right) + + if (typeof left === 'number') + return -1 + + if (typeof right === 'number') + return 1 + + return left.localeCompare(right) +} + +const comparePrerelease = (left: SemverIdentifier[], right: SemverIdentifier[]) => { + if (!left.length && !right.length) + return 0 + + if (!left.length) + return 1 + + if (!right.length) + return -1 + + const maxLength = Math.max(left.length, right.length) + for (let i = 0; i < maxLength; i += 1) { + const leftId = left[i] + const rightId = right[i] + + if (leftId === undefined) + return -1 + + if (rightId === undefined) + return 1 + + const result = compareIdentifier(leftId, rightId) + if (result !== 0) + return result + } + + return 0 } export const compareVersion = (v1: string, v2: string) => { - return semver.compare(v1, v2) + const left = parseSemver(v1) + const right = parseSemver(v2) + const maxCoreLength = Math.max(left.core.length, right.core.length) + + for (let i = 0; i < maxCoreLength; i += 1) { + const diff = (left.core[i] || 0) - (right.core[i] || 0) + if (diff !== 0) + return Math.sign(diff) + } + + return comparePrerelease(left.prerelease, right.prerelease) +} + +export const gte = (v1: string, v2: string) => compareVersion(v1, v2) >= 0 + +export const lt = (v1: string, v2: string) => compareVersion(v1, v2) < 0 + +export const getLatestVersion = (versionList: string[]) => { + return [...versionList].sort((left, right) => compareVersion(right, left))[0] } export const isEqualOrLaterThanVersion = (baseVersion: string, targetVersion: string) => { - return semver.gte(baseVersion, targetVersion) + return gte(baseVersion, targetVersion) }