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