From 468cc19e686a24c8de3212e6f7ee861958d0c39d Mon Sep 17 00:00:00 2001
From: yyh <92089059+lyzno1@users.noreply.github.com>
Date: Wed, 20 May 2026 11:23:21 +0800
Subject: [PATCH] fix(web): prevent local cloud analytics script errors
(#36420)
---
eslint-suppressions.json | 8 -
web/app/(commonLayout)/layout.tsx | 4 +-
web/app/account/(commonLayout)/layout.tsx | 4 +-
.../use-workflow-online-users.spec.ts | 141 ++++++++++++++++++
.../apps/hooks/use-workflow-online-users.ts | 7 +-
.../base/ga/__tests__/index.spec.tsx | 119 ++++++---------
web/app/components/base/ga/index.tsx | 90 +++++------
7 files changed, 226 insertions(+), 147 deletions(-)
create mode 100644 web/app/components/apps/hooks/__tests__/use-workflow-online-users.spec.ts
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index 0a6df42d77..b2a7812e63 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -1023,14 +1023,6 @@
"count": 2
}
},
- "web/app/components/base/ga/index.tsx": {
- "erasable-syntax-only/enums": {
- "count": 1
- },
- "react-refresh/only-export-components": {
- "count": 1
- }
- },
"web/app/components/base/icons/src/public/avatar/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx
index 2467f35b7b..6f80c447b5 100644
--- a/web/app/(commonLayout)/layout.tsx
+++ b/web/app/(commonLayout)/layout.tsx
@@ -3,7 +3,7 @@ import * as React from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import InSiteMessageNotification from '@/app/components/app/in-site-message/notification'
import AmplitudeProvider from '@/app/components/base/amplitude'
-import GA, { GaType } from '@/app/components/base/ga'
+import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import { GotoAnything } from '@/app/components/goto-anything'
import Header from '@/app/components/header'
@@ -19,7 +19,7 @@ import RoleRouteGuard from './role-route-guard'
const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
-
+
diff --git a/web/app/account/(commonLayout)/layout.tsx b/web/app/account/(commonLayout)/layout.tsx
index 8fdbd8a238..4d344c3f78 100644
--- a/web/app/account/(commonLayout)/layout.tsx
+++ b/web/app/account/(commonLayout)/layout.tsx
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
import * as React from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import AmplitudeProvider from '@/app/components/base/amplitude'
-import GA, { GaType } from '@/app/components/base/ga'
+import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import { AppContextProvider } from '@/context/app-context-provider'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
@@ -13,7 +13,7 @@ import Header from './header'
const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
-
+
diff --git a/web/app/components/apps/hooks/__tests__/use-workflow-online-users.spec.ts b/web/app/components/apps/hooks/__tests__/use-workflow-online-users.spec.ts
new file mode 100644
index 0000000000..bdaefc67ba
--- /dev/null
+++ b/web/app/components/apps/hooks/__tests__/use-workflow-online-users.spec.ts
@@ -0,0 +1,141 @@
+import type { WorkflowOnlineUser, WorkflowOnlineUsersResponse } from '@/models/app'
+import { renderHook } from '@testing-library/react'
+import { useWorkflowOnlineUsers } from '../use-workflow-online-users'
+
+type QueryOptions = {
+ input: {
+ body: {
+ app_ids: string[]
+ }
+ }
+ enabled: boolean
+ select: (response?: WorkflowOnlineUsersResponse) => Record
+ refetchInterval: false | number
+}
+
+const { mockUseQuery, mockQueryOptions } = vi.hoisted(() => ({
+ mockUseQuery: vi.fn(),
+ mockQueryOptions: vi.fn((options: QueryOptions) => options),
+}))
+
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: mockUseQuery,
+}))
+
+vi.mock('@/service/client', () => ({
+ consoleQuery: {
+ apps: {
+ workflowOnlineUsers: {
+ queryOptions: mockQueryOptions,
+ },
+ },
+ },
+}))
+
+const getLastQueryOptions = () => {
+ const lastCall = mockQueryOptions.mock.lastCall
+ if (!lastCall)
+ throw new Error('workflowOnlineUsers.queryOptions was not called')
+ return lastCall[0] as QueryOptions
+}
+
+describe('useWorkflowOnlineUsers', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseQuery.mockReturnValue({ data: undefined })
+ })
+
+ describe('Query Options', () => {
+ it('should disable query with a valid empty input when app ids are empty', () => {
+ renderHook(() => useWorkflowOnlineUsers({
+ appIds: [],
+ enabled: true,
+ }))
+
+ expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
+ input: { body: { app_ids: [] } },
+ enabled: false,
+ refetchInterval: false,
+ }))
+ expect(mockUseQuery).toHaveBeenCalledWith(expect.objectContaining({
+ input: { body: { app_ids: [] } },
+ enabled: false,
+ }))
+ })
+
+ it('should enable query and polling when collaboration is enabled with app ids', () => {
+ renderHook(() => useWorkflowOnlineUsers({
+ appIds: ['app-1', 'app-2'],
+ enabled: true,
+ }))
+
+ expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
+ input: { body: { app_ids: ['app-1', 'app-2'] } },
+ enabled: true,
+ refetchInterval: 10000,
+ }))
+ })
+
+ it('should disable query while preserving valid input when collaboration is disabled', () => {
+ renderHook(() => useWorkflowOnlineUsers({
+ appIds: ['app-1'],
+ enabled: false,
+ }))
+
+ expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
+ input: { body: { app_ids: ['app-1'] } },
+ enabled: false,
+ refetchInterval: false,
+ }))
+ })
+ })
+
+ describe('Response Mapping', () => {
+ it('should normalize array response data by app id', () => {
+ renderHook(() => useWorkflowOnlineUsers({
+ appIds: ['app-1'],
+ enabled: true,
+ }))
+
+ const options = getLastQueryOptions()
+ const user = { user_id: 'user-1', username: 'Alice' }
+
+ expect(options.select({
+ data: [
+ { app_id: 'app-1', users: [user] },
+ ],
+ })).toEqual({
+ 'app-1': [user],
+ })
+ })
+
+ it('should normalize record response data without changing user arrays', () => {
+ renderHook(() => useWorkflowOnlineUsers({
+ appIds: ['app-1'],
+ enabled: true,
+ }))
+
+ const options = getLastQueryOptions()
+ const users = [{ user_id: 'user-1', username: 'Alice' }]
+
+ expect(options.select({
+ data: {
+ 'app-1': users,
+ },
+ })).toEqual({
+ 'app-1': users,
+ })
+ })
+ })
+
+ describe('Return Value', () => {
+ it('should return an empty map when query data is unavailable', () => {
+ const { result } = renderHook(() => useWorkflowOnlineUsers({
+ appIds: ['app-1'],
+ enabled: true,
+ }))
+
+ expect(result.current.onlineUsersMap).toEqual({})
+ })
+ })
+})
diff --git a/web/app/components/apps/hooks/use-workflow-online-users.ts b/web/app/components/apps/hooks/use-workflow-online-users.ts
index a1778306f3..a0ec6a2d4e 100644
--- a/web/app/components/apps/hooks/use-workflow-online-users.ts
+++ b/web/app/components/apps/hooks/use-workflow-online-users.ts
@@ -1,5 +1,5 @@
import type { WorkflowOnlineUser, WorkflowOnlineUsersResponse } from '@/models/app'
-import { skipToken, useQuery } from '@tanstack/react-query'
+import { useQuery } from '@tanstack/react-query'
import { consoleQuery } from '@/service/client'
type WorkflowOnlineUsersMap = Record
@@ -36,9 +36,8 @@ export const useWorkflowOnlineUsers = ({
}: UseWorkflowOnlineUsersParams) => {
const shouldFetch = enabled && appIds.length > 0
const { data: onlineUsersMap = {} } = useQuery(consoleQuery.apps.workflowOnlineUsers.queryOptions({
- input: shouldFetch
- ? { body: { app_ids: appIds } }
- : skipToken,
+ input: { body: { app_ids: appIds } },
+ enabled: shouldFetch,
select: normalizeWorkflowOnlineUsers,
refetchInterval: shouldFetch ? 10000 : false,
}))
diff --git a/web/app/components/base/ga/__tests__/index.spec.tsx b/web/app/components/base/ga/__tests__/index.spec.tsx
index 619c4514dc..4ce9677a7a 100644
--- a/web/app/components/base/ga/__tests__/index.spec.tsx
+++ b/web/app/components/base/ga/__tests__/index.spec.tsx
@@ -2,29 +2,24 @@ import type { ReactElement, ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
type ConfigState = {
- isCeEdition: boolean
+ isCloudEdition: boolean
isProd: boolean
}
-type GaProps = {
- gaType: string
-}
-
-type GaRenderFn = (props: GaProps) => Promise
-type GaTypeValue = 'admin' | 'webapp'
+type GoogleAnalyticsScriptsRenderFn = () => Promise
const { mockHeaders, mockHeadersGet, configState } = vi.hoisted(() => ({
mockHeaders: vi.fn(),
mockHeadersGet: vi.fn(),
configState: ({
- isCeEdition: false,
+ isCloudEdition: true,
isProd: true,
}) as ConfigState,
}))
vi.mock('@/config', () => ({
- get IS_CE_EDITION() {
- return configState.isCeEdition
+ get IS_CLOUD_EDITION() {
+ return configState.isCloudEdition
},
get IS_PROD() {
return configState.isProd
@@ -41,18 +36,18 @@ vi.mock('@/next/script', () => ({
strategy,
src,
nonce,
- dangerouslySetInnerHTML,
+ children,
}: {
id?: string
strategy?: string
src?: string
nonce?: string
- dangerouslySetInnerHTML?: { __html?: string }
+ children?: ReactNode
}) => (
+
+
>
)
}
-export default React.memo(GA)