refactor: no global loading

This commit is contained in:
Stephen Zhou 2026-03-18 18:09:23 +08:00
parent a87b928079
commit 1620e224bf
No known key found for this signature in database
29 changed files with 283 additions and 181 deletions

View File

@ -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', () => {
</DatasetsLayout>
))
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByTestId('datasets-content')).not.toBeInTheDocument()
expect(screen.getByTestId('datasets-content')).toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})

View File

@ -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 <Loading type="app" />
if (shouldRedirect) {
return null
}

View File

@ -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', () => {
</RoleRouteGuard>
))
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByTestId('guarded-content')).not.toBeInTheDocument()
expect(screen.getByTestId('guarded-content')).toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})

View File

@ -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 <Loading type="app" />
if (shouldRedirect)
return null

View File

@ -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 (
<div className="flex h-full flex-col items-center justify-center gap-y-2">
<AppUnavailable className="h-auto w-auto" code={403} unknownReason="no permission." />
<span className="system-sm-regular cursor-pointer text-text-tertiary" onClick={backToHome}>{t('userProfile.logout', { ns: 'common' })}</span>
</div>
)
}
if (isFetchingAppInfo || isFetchingAppParams || isFetchingAppMeta) {
return (
<div className="flex h-full items-center justify-center">
<Loading />
<span className="cursor-pointer text-text-tertiary system-sm-regular" onClick={backToHome}>{t('userProfile.logout', { ns: 'common' })}</span>
</div>
)
}

View File

@ -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<PropsWithChildren> = ({ 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<PropsWithChildren> = ({ 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<PropsWithChildren> = ({ children }) => {
const redirectOrFinish = () => {
if (redirectUrl)
router.replace(decodeURIComponent(redirectUrl))
else
setIsLoading(false)
}
const proceedToAuth = () => {
setIsLoading(false)
}
(async () => {
@ -60,9 +48,6 @@ const Splash: FC<PropsWithChildren> = ({ children }) => {
if (userLoggedIn && appLoggedIn) {
redirectOrFinish()
}
else if (!userLoggedIn && !appLoggedIn) {
proceedToAuth()
}
else if (!userLoggedIn && appLoggedIn) {
redirectOrFinish()
}
@ -77,7 +62,6 @@ const Splash: FC<PropsWithChildren> = ({ children }) => {
}
catch {
await webAppLogout(shareCode!)
proceedToAuth()
}
}
})()
@ -86,7 +70,6 @@ const Splash: FC<PropsWithChildren> = ({ children }) => {
redirectUrl,
router,
message,
webAppAccessMode,
tokenFromUrl,
embeddedUserId,
])
@ -95,18 +78,11 @@ const Splash: FC<PropsWithChildren> = ({ children }) => {
return (
<div className="flex h-full flex-col items-center justify-center gap-y-4">
<AppUnavailable className="h-auto w-auto" code={code || t('common.appUnavailable', { ns: 'share' })} unknownReason={message} />
<span className="system-sm-regular cursor-pointer text-text-tertiary" onClick={backToHome}>{code === '403' ? t('userProfile.logout', { ns: 'common' }) : t('login.backToHome', { ns: 'share' })}</span>
<span className="cursor-pointer text-text-tertiary system-sm-regular" onClick={backToHome}>{code === '403' ? t('userProfile.logout', { ns: 'common' }) : t('login.backToHome', { ns: 'share' })}</span>
</div>
)
}
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<Loading />
</div>
)
}
return <>{children}</>
}

View File

@ -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 (
<div className="flex min-h-screen w-full justify-center bg-background-default-burn">
<Loading />
</div>
)
}
return (
<>
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>

View File

@ -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 (
<div className="bg-background-default-subtle">
<Loading type="app" />
</div>
)
}
return (
<div className="bg-background-default-subtle">
{authAppInfo?.app_icon && (
@ -169,7 +161,7 @@ export default function OAuthAuthorize() {
)
: (
<>
<Button variant="primary" size="large" className="w-full" onClick={onAuthorize} disabled={!client_id || !redirect_uri || isError || authorizing} loading={authorizing}>{t('continue', { ns: 'oauth' })}</Button>
<Button variant="primary" size="large" className="w-full" onClick={onAuthorize} disabled={isActionDisabled} loading={authorizing}>{t('continue', { ns: 'oauth' })}</Button>
<Button size="large" className="w-full" onClick={() => router.push('/apps')}>{t('operation.cancel', { ns: 'common' })}</Button>
</>
)}

View File

@ -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
}

View File

@ -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) {

View File

@ -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<typeof convertToMp3>[0])
const mp3File = new File([mp3Blob], 'temp.mp3', { type: 'audio/mp3' })
const formData = new FormData()
formData.append('file', mp3File)

View File

@ -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 {

View File

@ -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,

View File

@ -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) => {

View File

@ -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)
}

View File

@ -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<Props> = ({
return (
<>
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
<div className="system-md-regular text-text-secondary">
<div className="text-text-secondary system-md-regular">
<p>{t(`${i18nPrefix}.readyToInstall`, { ns: 'plugin' })}</p>
<p>
<Trans
@ -127,7 +127,7 @@ const Installed: FC<Props> = ({
/>
</p>
{!isDifyVersionCompatible && (
<p className="system-md-regular flex items-center gap-1 text-text-warning">
<p className="flex items-center gap-1 text-text-warning system-md-regular">
{t('difyVersionNotCompatible', { ns: 'plugin', minimalDifyVersion: payload.meta.minimum_dify_version })}
</p>
)}

View File

@ -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<Props> = ({
return (
<>
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
<div className="system-md-regular text-text-secondary">
<div className="text-text-secondary system-md-regular">
<p>{t(`${i18nPrefix}.readyToInstall`, { ns: 'plugin' })}</p>
{!isDifyVersionCompatible && (
<p className="system-md-regular text-text-warning">
<p className="text-text-warning system-md-regular">
{t('difyVersionNotCompatible', { ns: 'plugin', minimalDifyVersion: pluginDeclaration?.manifest.meta.minimum_dify_version })}
</p>
)}

View File

@ -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<Props> = ({
/>
{category === PluginCategoryEnum.extension && (
<>
<div className="system-xs-regular mx-2 text-text-quaternary">·</div>
<div className="system-xs-regular flex items-center gap-x-1 overflow-hidden text-text-tertiary">
<div className="mx-2 text-text-quaternary system-xs-regular">·</div>
<div className="flex items-center gap-x-1 overflow-hidden text-text-tertiary system-xs-regular">
<RiLoginCircleLine className="size-3 shrink-0" />
<span
className="truncate"
@ -183,7 +183,7 @@ const PluginItem: FC<Props> = ({
&& (
<>
<a href={`https://github.com/${meta!.repo}`} target="_blank" className="flex items-center gap-1">
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('from', { ns: 'plugin' })}</div>
<div className="text-text-tertiary system-2xs-medium-uppercase">{t('from', { ns: 'plugin' })}</div>
<div className="flex items-center space-x-0.5 text-text-secondary">
<Github className="h-3 w-3" />
<div className="system-2xs-semibold-uppercase">GitHub</div>
@ -196,7 +196,7 @@ const PluginItem: FC<Props> = ({
&& (
<>
<a href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })} target="_blank" className="flex items-center gap-0.5">
<div className="system-2xs-medium-uppercase text-text-tertiary">
<div className="text-text-tertiary system-2xs-medium-uppercase">
{t('from', { ns: 'plugin' })}
{' '}
<span className="text-text-secondary">marketplace</span>
@ -210,7 +210,7 @@ const PluginItem: FC<Props> = ({
<>
<div className="flex items-center gap-1">
<RiHardDrive3Line className="h-3 w-3 text-text-tertiary" />
<div className="system-2xs-medium-uppercase text-text-tertiary">Local Plugin</div>
<div className="text-text-tertiary system-2xs-medium-uppercase">Local Plugin</div>
</div>
</>
)}
@ -219,14 +219,14 @@ const PluginItem: FC<Props> = ({
<>
<div className="flex items-center gap-1">
<RiBugLine className="h-3 w-3 text-text-warning" />
<div className="system-2xs-medium-uppercase text-text-warning">Debugging Plugin</div>
<div className="text-text-warning system-2xs-medium-uppercase">Debugging Plugin</div>
</div>
</>
)}
</div>
{/* Deprecated */}
{source === PluginSource.marketplace && enable_marketplace && isDeprecated && (
<div className="system-2xs-medium-uppercase flex shrink-0 items-center gap-x-2">
<div className="flex shrink-0 items-center gap-x-2 system-2xs-medium-uppercase">
<span className="text-text-tertiary">·</span>
<span className="text-text-warning">
{t('deprecated', { ns: 'plugin' })}

View File

@ -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

View File

@ -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<PropsWithChildren> = () => {
// would auto redirect to signin page if not logged in
const { isLoading, data: loginData } = useIsLogin()
const isLoggedIn = loginData?.logged_in
if (isLoading || !isLoggedIn) {
return (
<div className="fixed inset-0 z-[9999999] flex h-full items-center justify-center bg-background-body">
<Loading />
</div>
)
}
if (isLoading || !isLoggedIn)
return null
return null
}
export default React.memo(Splash)

View File

@ -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('/')
}

View File

@ -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()

View File

@ -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 () => {
<div className="flex min-h-screen flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<Loading type="area" />
<div className="mt-10 text-center">
<Link href="/apps">🚀</Link>
</div>

View File

@ -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 <Loading />
if (!checkRes.is_valid) {
if (checkRes?.is_valid === false) {
return (
<div className="flex flex-col md:w-[400px]">
<div className="mx-auto w-full">
@ -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 ?? ''}`}
</Button>
</div>
</form>

View File

@ -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(
<QueryClientProvider client={queryClient}>
<GlobalPublicStoreProvider>
<div>provider child</div>
</GlobalPublicStoreProvider>
</QueryClientProvider>,
)
}
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)
})
})
})

View File

@ -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<PropsWithChildren> = ({
}) => {
// 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 <div className="flex h-screen w-screen items-center justify-center"><Loading /></div>
return <>{children}</>
}
export default GlobalPublicStoreProvider

View File

@ -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

View File

@ -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<PropsWithChildren> = ({ 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<PropsWithChildren> = ({ 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 (
<div className="flex h-full w-full items-center justify-center">
<Loading />
</div>
)
}
return (
<>
{children}
</>
)
return <>{children}</>
}
export default WebAppStoreProvider

View File

@ -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)
}