From 7c99c9f3e8b61c311c0029d2345103b93a161cd5 Mon Sep 17 00:00:00 2001 From: Desel72 Date: Wed, 18 Mar 2026 06:04:45 -0500 Subject: [PATCH 1/7] fix: sync workflow description and name to MCP server on update (#33637) --- api/controllers/console/app/mcp_server.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/controllers/console/app/mcp_server.py b/api/controllers/console/app/mcp_server.py index 2025048e09..4b20418b53 100644 --- a/api/controllers/console/app/mcp_server.py +++ b/api/controllers/console/app/mcp_server.py @@ -103,13 +103,13 @@ class AppMCPServerController(Resource): raise NotFound() description = payload.description - if description is None: - pass - elif not description: + if description is None or not description: server.description = app_model.description or "" else: server.description = description + server.name = app_model.name + server.parameters = json.dumps(payload.parameters, ensure_ascii=False) if payload.status: try: From 29c70736dca75ffad8e4b6db2dc1e8681cd475e6 Mon Sep 17 00:00:00 2001 From: QuantumGhost Date: Wed, 18 Mar 2026 19:41:25 +0800 Subject: [PATCH 2/7] fix(api): Preserving the content transform logic in fetch_prompt_messages (#33666) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/dify_graph/nodes/llm/llm_utils.py | 8 +- .../core/workflow/nodes/llm/test_llm_utils.py | 106 ++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py diff --git a/api/dify_graph/nodes/llm/llm_utils.py b/api/dify_graph/nodes/llm/llm_utils.py index 073dce232f..2be391a424 100644 --- a/api/dify_graph/nodes/llm/llm_utils.py +++ b/api/dify_graph/nodes/llm/llm_utils.py @@ -256,9 +256,13 @@ def fetch_prompt_messages( ): continue prompt_message_content.append(content_item) - if prompt_message_content: + if not prompt_message_content: + continue + if len(prompt_message_content) == 1 and prompt_message_content[0].type == PromptMessageContentType.TEXT: + prompt_message.content = prompt_message_content[0].data + else: prompt_message.content = prompt_message_content - filtered_prompt_messages.append(prompt_message) + filtered_prompt_messages.append(prompt_message) elif not prompt_message.is_empty(): filtered_prompt_messages.append(prompt_message) diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py new file mode 100644 index 0000000000..618a498659 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py @@ -0,0 +1,106 @@ +from unittest import mock + +import pytest + +from core.model_manager import ModelInstance +from dify_graph.model_runtime.entities import ImagePromptMessageContent, PromptMessageRole, TextPromptMessageContent +from dify_graph.model_runtime.entities.message_entities import SystemPromptMessage +from dify_graph.nodes.llm import llm_utils +from dify_graph.nodes.llm.entities import LLMNodeChatModelMessage +from dify_graph.nodes.llm.exc import NoPromptFoundError +from dify_graph.runtime import VariablePool + + +def _fetch_prompt_messages_with_mocked_content(content): + variable_pool = VariablePool.empty() + model_instance = mock.MagicMock(spec=ModelInstance) + prompt_template = [ + LLMNodeChatModelMessage( + text="You are a classifier.", + role=PromptMessageRole.SYSTEM, + edition_type="basic", + ) + ] + + with ( + mock.patch( + "dify_graph.nodes.llm.llm_utils.fetch_model_schema", + return_value=mock.MagicMock(features=[]), + ), + mock.patch( + "dify_graph.nodes.llm.llm_utils.handle_list_messages", + return_value=[SystemPromptMessage(content=content)], + ), + mock.patch( + "dify_graph.nodes.llm.llm_utils.handle_memory_chat_mode", + return_value=[], + ), + ): + return llm_utils.fetch_prompt_messages( + sys_query=None, + sys_files=[], + context=None, + memory=None, + model_instance=model_instance, + prompt_template=prompt_template, + stop=["END"], + memory_config=None, + vision_enabled=False, + vision_detail=ImagePromptMessageContent.DETAIL.HIGH, + variable_pool=variable_pool, + jinja2_variables=[], + template_renderer=None, + ) + + +def test_fetch_prompt_messages_skips_messages_when_all_contents_are_filtered_out(): + with pytest.raises(NoPromptFoundError): + _fetch_prompt_messages_with_mocked_content( + [ + ImagePromptMessageContent( + format="url", + url="https://example.com/image.png", + mime_type="image/png", + ), + ] + ) + + +def test_fetch_prompt_messages_flattens_single_text_content_after_filtering_unsupported_multimodal_items(): + prompt_messages, stop = _fetch_prompt_messages_with_mocked_content( + [ + TextPromptMessageContent(data="You are a classifier."), + ImagePromptMessageContent( + format="url", + url="https://example.com/image.png", + mime_type="image/png", + ), + ] + ) + + assert stop == ["END"] + assert prompt_messages == [SystemPromptMessage(content="You are a classifier.")] + + +def test_fetch_prompt_messages_keeps_list_content_when_multiple_supported_items_remain(): + prompt_messages, stop = _fetch_prompt_messages_with_mocked_content( + [ + TextPromptMessageContent(data="You are"), + TextPromptMessageContent(data=" a classifier."), + ImagePromptMessageContent( + format="url", + url="https://example.com/image.png", + mime_type="image/png", + ), + ] + ) + + assert stop == ["END"] + assert prompt_messages == [ + SystemPromptMessage( + content=[ + TextPromptMessageContent(data="You are"), + TextPromptMessageContent(data=" a classifier."), + ] + ) + ] From 25ab5e46b34051cbe74f28396feb2d880a490970 Mon Sep 17 00:00:00 2001 From: BitToby <218712309+bittoby@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:47:51 +0200 Subject: [PATCH 3/7] refactor(api): type default_retrieval_model with DefaultRetrievalModelDict in core/rag (#33676) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/rag/datasource/retrieval_service.py | 5 +++- api/core/rag/retrieval/dataset_retrieval.py | 24 +++++++++++++++----- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/api/core/rag/datasource/retrieval_service.py b/api/core/rag/datasource/retrieval_service.py index 7f6ecc3d3f..d7ea03efee 100644 --- a/api/core/rag/datasource/retrieval_service.py +++ b/api/core/rag/datasource/retrieval_service.py @@ -68,9 +68,12 @@ class SegmentRecord(TypedDict): class DefaultRetrievalModelDict(TypedDict): - search_method: RetrievalMethod | str + search_method: RetrievalMethod reranking_enable: bool reranking_model: RerankingModelDict + reranking_mode: NotRequired[str] + weights: NotRequired[WeightsDict | None] + score_threshold: NotRequired[float] top_k: int score_threshold_enabled: bool diff --git a/api/core/rag/retrieval/dataset_retrieval.py b/api/core/rag/retrieval/dataset_retrieval.py index c44e9b847b..1096c69041 100644 --- a/api/core/rag/retrieval/dataset_retrieval.py +++ b/api/core/rag/retrieval/dataset_retrieval.py @@ -33,7 +33,7 @@ from core.prompt.entities.advanced_prompt_entities import ChatModelMessage, Comp from core.prompt.simple_prompt_transform import ModelMode from core.rag.data_post_processor.data_post_processor import DataPostProcessor, RerankingModelDict, WeightsDict from core.rag.datasource.keyword.jieba.jieba_keyword_table_handler import JiebaKeywordTableHandler -from core.rag.datasource.retrieval_service import RetrievalService +from core.rag.datasource.retrieval_service import DefaultRetrievalModelDict, RetrievalService from core.rag.entities.citation_metadata import RetrievalSourceMetadata from core.rag.entities.context_entities import DocumentContext from core.rag.entities.metadata_entities import Condition, MetadataCondition @@ -87,7 +87,7 @@ from models.enums import CreatorUserRole, DatasetQuerySource from services.external_knowledge_service import ExternalDatasetService from services.feature_service import FeatureService -default_retrieval_model: dict[str, Any] = { +default_retrieval_model: DefaultRetrievalModelDict = { "search_method": RetrievalMethod.SEMANTIC_SEARCH, "reranking_enable": False, "reranking_model": {"reranking_provider_name": "", "reranking_model_name": ""}, @@ -666,7 +666,11 @@ class DatasetRetrieval: document_ids_filter = document_ids else: return [] - retrieval_model_config = dataset.retrieval_model or default_retrieval_model + retrieval_model_config: DefaultRetrievalModelDict = ( + cast(DefaultRetrievalModelDict, dataset.retrieval_model) + if dataset.retrieval_model + else default_retrieval_model + ) # get top k top_k = retrieval_model_config["top_k"] @@ -1058,7 +1062,11 @@ class DatasetRetrieval: all_documents.append(document) else: # get retrieval model , if the model is not setting , using default - retrieval_model = dataset.retrieval_model or default_retrieval_model + retrieval_model: DefaultRetrievalModelDict = ( + cast(DefaultRetrievalModelDict, dataset.retrieval_model) + if dataset.retrieval_model + else default_retrieval_model + ) if dataset.indexing_technique == "economy": # use keyword table query @@ -1132,7 +1140,7 @@ class DatasetRetrieval: if retrieve_config.retrieve_strategy == DatasetRetrieveConfigEntity.RetrieveStrategy.SINGLE: # get retrieval model config - default_retrieval_model = { + default_retrieval_model: DefaultRetrievalModelDict = { "search_method": RetrievalMethod.SEMANTIC_SEARCH, "reranking_enable": False, "reranking_model": {"reranking_provider_name": "", "reranking_model_name": ""}, @@ -1141,7 +1149,11 @@ class DatasetRetrieval: } for dataset in available_datasets: - retrieval_model_config = dataset.retrieval_model or default_retrieval_model + retrieval_model_config: DefaultRetrievalModelDict = ( + cast(DefaultRetrievalModelDict, dataset.retrieval_model) + if dataset.retrieval_model + else default_retrieval_model + ) # get top k top_k = retrieval_model_config["top_k"] From 49256770c9be299d177120063cd4fa9fe8d724b7 Mon Sep 17 00:00:00 2001 From: QuantumGhost Date: Wed, 18 Mar 2026 21:53:16 +0800 Subject: [PATCH 4/7] chore: bump version to 1.13.2 (#33681) --- api/pyproject.toml | 2 +- api/uv.lock | 2 +- docker/docker-compose-template.yaml | 8 ++++---- docker/docker-compose.yaml | 8 ++++---- web/package.json | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 31b778ab8c..f824fe7c23 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dify-api" -version = "1.13.1" +version = "1.13.2" requires-python = ">=3.11,<3.13" dependencies = [ diff --git a/api/uv.lock b/api/uv.lock index ddb70f6b54..11b4438254 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1533,7 +1533,7 @@ wheels = [ [[package]] name = "dify-api" -version = "1.13.1" +version = "1.13.2" source = { virtual = "." } dependencies = [ { name = "aliyun-log-python-sdk" }, diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 939f23136a..04bd2858ff 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -21,7 +21,7 @@ services: # API service api: - image: langgenius/dify-api:1.13.1 + image: langgenius/dify-api:1.13.2 restart: always environment: # Use the shared environment variables. @@ -63,7 +63,7 @@ services: # worker service # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: - image: langgenius/dify-api:1.13.1 + image: langgenius/dify-api:1.13.2 restart: always environment: # Use the shared environment variables. @@ -102,7 +102,7 @@ services: # worker_beat service # Celery beat for scheduling periodic tasks. worker_beat: - image: langgenius/dify-api:1.13.1 + image: langgenius/dify-api:1.13.2 restart: always environment: # Use the shared environment variables. @@ -132,7 +132,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.13.1 + image: langgenius/dify-web:1.13.2 restart: always environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index b6b6f299cf..bf72a0f623 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -728,7 +728,7 @@ services: # API service api: - image: langgenius/dify-api:1.13.1 + image: langgenius/dify-api:1.13.2 restart: always environment: # Use the shared environment variables. @@ -770,7 +770,7 @@ services: # worker service # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: - image: langgenius/dify-api:1.13.1 + image: langgenius/dify-api:1.13.2 restart: always environment: # Use the shared environment variables. @@ -809,7 +809,7 @@ services: # worker_beat service # Celery beat for scheduling periodic tasks. worker_beat: - image: langgenius/dify-api:1.13.1 + image: langgenius/dify-api:1.13.2 restart: always environment: # Use the shared environment variables. @@ -839,7 +839,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.13.1 + image: langgenius/dify-web:1.13.2 restart: always environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} diff --git a/web/package.json b/web/package.json index 4d9b5b9be7..663aac076c 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "dify-web", "type": "module", - "version": "1.13.1", + "version": "1.13.2", "private": true, "packageManager": "pnpm@10.32.1", "imports": { From 37ffa262ae0ca107eb1cea1e8fca5414f698b088 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Wed, 18 Mar 2026 22:00:25 +0800 Subject: [PATCH 5/7] refactor: move to std-semver (#33682) --- .../plugins/plugin-install-flow.test.ts | 27 ------------------ .../install-plugin/__tests__/hooks.spec.ts | 28 ------------------- .../steps/install.tsx | 8 +++--- .../steps/install.tsx | 8 +++--- .../components/plugins/plugin-item/index.tsx | 18 ++++++------ .../update-plugin/__tests__/index.spec.tsx | 14 ---------- .../update-plugin/plugin-version-picker.tsx | 4 +-- web/eslint-suppressions.json | 13 --------- web/package.json | 3 +- web/pnpm-lock.yaml | 20 ++++++------- web/utils/semver.spec.ts | 22 ++++++++++++++- web/utils/semver.ts | 14 +++++++--- 12 files changed, 60 insertions(+), 119 deletions(-) diff --git a/web/__tests__/plugins/plugin-install-flow.test.ts b/web/__tests__/plugins/plugin-install-flow.test.ts index 7ceca4535b..8edb6705d4 100644 --- a/web/__tests__/plugins/plugin-install-flow.test.ts +++ b/web/__tests__/plugins/plugin-install-flow.test.ts @@ -22,33 +22,6 @@ vi.mock('@/service/plugins', () => ({ checkTaskStatus: vi.fn(), })) -vi.mock('@/utils/semver', () => ({ - compareVersion: (a: string, b: string) => { - const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number) - const [aMajor, aMinor = 0, aPatch = 0] = parse(a) - const [bMajor, bMinor = 0, bPatch = 0] = parse(b) - if (aMajor !== bMajor) - return aMajor > bMajor ? 1 : -1 - if (aMinor !== bMinor) - return aMinor > bMinor ? 1 : -1 - if (aPatch !== bPatch) - return aPatch > bPatch ? 1 : -1 - return 0 - }, - getLatestVersion: (versions: string[]) => { - return versions.sort((a, b) => { - const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number) - const [aMaj, aMin = 0, aPat = 0] = parse(a) - const [bMaj, bMin = 0, bPat = 0] = parse(b) - if (aMaj !== bMaj) - return bMaj - aMaj - if (aMin !== bMin) - return bMin - aMin - return bPat - aPat - })[0] - }, -})) - const { useGitHubReleases, useGitHubUpload } = await import( '@/app/components/plugins/install-plugin/hooks', ) diff --git a/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts b/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts index b0e3ec5832..918a9b36e3 100644 --- a/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts +++ b/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts @@ -16,34 +16,6 @@ vi.mock('@/service/plugins', () => ({ uploadGitHub: (...args: unknown[]) => mockUploadGitHub(...args), })) -vi.mock('@/utils/semver', () => ({ - compareVersion: (a: string, b: string) => { - const parseVersion = (v: string) => v.replace(/^v/, '').split('.').map(Number) - const va = parseVersion(a) - const vb = parseVersion(b) - for (let i = 0; i < Math.max(va.length, vb.length); i++) { - const diff = (va[i] || 0) - (vb[i] || 0) - if (diff > 0) - return 1 - if (diff < 0) - return -1 - } - return 0 - }, - getLatestVersion: (versions: string[]) => { - return versions.sort((a, b) => { - const pa = a.replace(/^v/, '').split('.').map(Number) - const pb = b.replace(/^v/, '').split('.').map(Number) - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const diff = (pa[i] || 0) - (pb[i] || 0) - if (diff !== 0) - return diff - } - return 0 - }).pop()! - }, -})) - const mockFetch = vi.fn() globalThis.fetch = mockFetch 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..d37151a253 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 { isEqualOrLaterThanVersion } from '@/utils/semver' import Card from '../../../card' import { TaskStatus } from '../../../types' import checkTaskStatus from '../../base/check-task-status' @@ -111,13 +111,13 @@ const Installed: FC = ({ const isDifyVersionCompatible = useMemo(() => { if (!langGeniusVersionInfo.current_version) return true - return gte(langGeniusVersionInfo.current_version, payload.meta.minimum_dify_version ?? '0.0.0') + return isEqualOrLaterThanVersion(langGeniusVersionInfo.current_version, payload.meta.minimum_dify_version ?? '0.0.0') }, [langGeniusVersionInfo.current_version, payload.meta.minimum_dify_version]) 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..8a4e0bd82a 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 { isEqualOrLaterThanVersion } from '@/utils/semver' import Card from '../../../card' // import { RiInformation2Line } from '@remixicon/react' import { TaskStatus } from '../../../types' @@ -126,17 +126,17 @@ const Installed: FC = ({ const isDifyVersionCompatible = useMemo(() => { if (!pluginDeclaration || !langGeniusVersionInfo.current_version) return true - return gte(langGeniusVersionInfo.current_version, pluginDeclaration?.manifest.meta.minimum_dify_version ?? '0.0.0') + return isEqualOrLaterThanVersion(langGeniusVersionInfo.current_version, pluginDeclaration?.manifest.meta.minimum_dify_version ?? '0.0.0') }, [langGeniusVersionInfo.current_version, pluginDeclaration]) const { canInstall } = useInstallPluginLimit({ ...payload, from: 'marketplace' }) 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..08da055bde 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 { isEqualOrLaterThanVersion } from '@/utils/semver' import { getMarketplaceUrl } from '@/utils/var' import Badge from '../../base/badge' import { Github } from '../../base/icons/src/public/common' @@ -71,7 +71,7 @@ const PluginItem: FC = ({ const isDifyVersionCompatible = useMemo(() => { if (!langGeniusVersionInfo.current_version) return true - return gte(langGeniusVersionInfo.current_version, declarationMeta.minimum_dify_version ?? '0.0.0') + return isEqualOrLaterThanVersion(langGeniusVersionInfo.current_version, declarationMeta.minimum_dify_version ?? '0.0.0') }, [declarationMeta.minimum_dify_version, langGeniusVersionInfo.current_version]) const isDeprecated = useMemo(() => { @@ -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/__tests__/index.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx index 73fb132850..656bb042a4 100644 --- a/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx +++ b/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx @@ -104,20 +104,6 @@ vi.mock('../../install-plugin/install-from-github', () => ({ ), })) -// Mock semver -vi.mock('semver', () => ({ - lt: (v1: string, v2: string) => { - const parseVersion = (v: string) => v.split('.').map(Number) - const [major1, minor1, patch1] = parseVersion(v1) - const [major2, minor2, patch2] = parseVersion(v2) - if (major1 !== major2) - return major1 < major2 - if (minor1 !== minor2) - return minor1 < minor2 - return patch1 < patch2 - }, -})) - // ================================ // Test Data Factories // ================================ 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..9f14cd6c83 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 { isEarlierThanVersion } from '@/utils/semver' type Props = { disabled?: boolean @@ -100,7 +100,7 @@ const PluginVersionPicker: FC = ({ onClick={() => handleSelect({ version: version.version, unique_identifier: version.unique_identifier, - isDowngrade: lt(version.version, currentVersion), + isDowngrade: isEarlierThanVersion(version.version, currentVersion), })} >
diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index b613b64691..174b7a875c 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -4976,11 +4976,6 @@ "count": 1 } }, - "app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx": { - "tailwindcss/enforce-consistent-class-order": { - "count": 2 - } - }, "app/components/plugins/install-plugin/install-from-local-package/steps/uploading.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 @@ -4997,11 +4992,6 @@ "count": 1 } }, - "app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx": { - "tailwindcss/enforce-consistent-class-order": { - "count": 2 - } - }, "app/components/plugins/marketplace/description/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 9 @@ -5480,9 +5470,6 @@ "no-restricted-imports": { "count": 1 }, - "tailwindcss/enforce-consistent-class-order": { - "count": 7 - }, "ts/no-explicit-any": { "count": 1 } diff --git a/web/package.json b/web/package.json index 663aac076c..e053c981e7 100644 --- a/web/package.json +++ b/web/package.json @@ -151,9 +151,9 @@ "remark-breaks": "4.0.0", "remark-directive": "4.0.0", "scheduler": "0.27.0", - "semver": "7.7.4", "sharp": "0.34.5", "sortablejs": "1.15.7", + "std-semver": "1.0.8", "streamdown": "2.5.0", "string-ts": "2.3.1", "tailwind-merge": "2.6.1", @@ -206,7 +206,6 @@ "@types/react-slider": "1.3.6", "@types/react-syntax-highlighter": "15.5.13", "@types/react-window": "1.8.8", - "@types/semver": "7.7.1", "@types/sortablejs": "1.15.9", "@typescript-eslint/parser": "8.57.1", "@typescript/native-preview": "7.0.0-dev.20260317.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index b8a67542a3..59bbea1f25 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -340,15 +340,15 @@ importers: scheduler: specifier: 0.27.0 version: 0.27.0 - semver: - specifier: 7.7.4 - version: 7.7.4 sharp: specifier: 0.34.5 version: 0.34.5 sortablejs: specifier: 1.15.7 version: 1.15.7 + std-semver: + specifier: 1.0.8 + version: 1.0.8 streamdown: specifier: 2.5.0 version: 2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -500,9 +500,6 @@ importers: '@types/react-window': specifier: 1.8.8 version: 1.8.8 - '@types/semver': - specifier: 7.7.1 - version: 7.7.1 '@types/sortablejs': specifier: 1.15.9 version: 1.15.9 @@ -3420,9 +3417,6 @@ packages: '@types/resolve@1.20.6': resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} - '@types/semver@7.7.1': - resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - '@types/sortablejs@1.15.9': resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==} @@ -7115,6 +7109,10 @@ packages: std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + std-semver@1.0.8: + resolution: {integrity: sha512-9SN0XIjBBXCT6ZXXVnScJN4KP2RyFg6B8sEoFlugVHMANysfaEni4LTWlvUQQ/R0wgZl1Ovt9KBQbzn21kHoZA==} + engines: {node: '>=20.19.0'} + storybook@10.2.19: resolution: {integrity: sha512-UUm5eGSm6BLhkcFP0WbxkmAHJZfVN2ViLpIZOqiIPS++q32VYn+CLFC0lrTYTDqYvaG7i4BK4uowXYujzE4NdQ==} hasBin: true @@ -10755,8 +10753,6 @@ snapshots: '@types/resolve@1.20.6': {} - '@types/semver@7.7.1': {} - '@types/sortablejs@1.15.9': {} '@types/trusted-types@2.0.7': @@ -15205,6 +15201,8 @@ snapshots: std-env@4.0.0: {} + std-semver@1.0.8: {} + storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@storybook/global': 5.0.0 diff --git a/web/utils/semver.spec.ts b/web/utils/semver.spec.ts index c2188a976c..42d6a3fb54 100644 --- a/web/utils/semver.spec.ts +++ b/web/utils/semver.spec.ts @@ -1,4 +1,4 @@ -import { compareVersion, getLatestVersion, isEqualOrLaterThanVersion } from './semver' +import { compareVersion, getLatestVersion, isEarlierThanVersion, isEqualOrLaterThanVersion } from './semver' describe('semver utilities', () => { describe('getLatestVersion', () => { @@ -72,4 +72,24 @@ describe('semver utilities', () => { expect(isEqualOrLaterThanVersion('1.0.0-alpha', '1.0.0')).toBe(false) }) }) + + describe('isEarlierThanVersion', () => { + it('should return true when baseVersion is less than targetVersion', () => { + expect(isEarlierThanVersion('1.0.0', '1.1.0')).toBe(true) + expect(isEarlierThanVersion('1.9.9', '2.0.0')).toBe(true) + expect(isEarlierThanVersion('1.0.0', '1.0.1')).toBe(true) + }) + + it('should return false when baseVersion is equal to or greater than targetVersion', () => { + expect(isEarlierThanVersion('1.0.0', '1.0.0')).toBe(false) + expect(isEarlierThanVersion('1.1.0', '1.0.0')).toBe(false) + expect(isEarlierThanVersion('1.0.1', '1.0.0')).toBe(false) + }) + + it('should handle pre-release versions correctly', () => { + expect(isEarlierThanVersion('1.0.0-beta', '1.0.0')).toBe(true) + expect(isEarlierThanVersion('1.0.0-alpha', '1.0.0-beta')).toBe(true) + expect(isEarlierThanVersion('1.0.0', '1.0.0-beta')).toBe(false) + }) + }) }) diff --git a/web/utils/semver.ts b/web/utils/semver.ts index aea84153ec..a22d219947 100644 --- a/web/utils/semver.ts +++ b/web/utils/semver.ts @@ -1,13 +1,19 @@ -import semver from 'semver' +import { compare, greaterOrEqual, lessThan, parse } from 'std-semver' export const getLatestVersion = (versionList: string[]) => { - return semver.rsort(versionList)[0] + return [...versionList].sort((versionA, versionB) => { + return compare(parse(versionB), parse(versionA)) + })[0] } export const compareVersion = (v1: string, v2: string) => { - return semver.compare(v1, v2) + return compare(parse(v1), parse(v2)) } export const isEqualOrLaterThanVersion = (baseVersion: string, targetVersion: string) => { - return semver.gte(baseVersion, targetVersion) + return greaterOrEqual(parse(baseVersion), parse(targetVersion)) +} + +export const isEarlierThanVersion = (baseVersion: string, targetVersion: string) => { + return lessThan(parse(baseVersion), parse(targetVersion)) } From 5ee0633bac846df139a82a97e9a52e82b1fb1514 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Wed, 18 Mar 2026 22:15:48 +0800 Subject: [PATCH 6/7] ci: revert agent reporter (#33685) --- .github/workflows/web-tests.yml | 2 +- web/vite.config.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index be2595a599..b705ad4166 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -87,7 +87,7 @@ jobs: merge-multiple: true - name: Merge reports - run: vp test --merge-reports --reporter=json --reporter=agent --coverage + run: vp test --merge-reports --coverage --silent=passed-only - name: Report app/components baseline coverage run: node ./scripts/report-components-coverage-baseline.mjs diff --git a/web/vite.config.ts b/web/vite.config.ts index de74154651..0df333a91b 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -87,7 +87,6 @@ export default defineConfig(({ mode }) => { environment: 'jsdom', globals: true, setupFiles: ['./vitest.setup.ts'], - reporters: ['agent'], coverage: { provider: 'v8', reporter: isCI ? ['json', 'json-summary'] : ['text', 'json', 'json-summary'], From 42543922211d49c56a888c71c6d1252303ce731b Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Wed, 18 Mar 2026 23:46:22 +0800 Subject: [PATCH 7/7] fix: leaked set timeout (#33692) --- .../transfer-ownership-modal/index.tsx | 25 ++- .../app-selector/__tests__/index.spec.tsx | 158 +++++------------- .../app-selector/app-picker.tsx | 61 ++++--- .../app-selector/index.tsx | 26 +-- web/eslint-suppressions.json | 6 - 5 files changed, 110 insertions(+), 166 deletions(-) diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx index c4f614737a..099a146866 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx @@ -1,6 +1,6 @@ import { noop } from 'es-toolkit/function' import * as React from 'react' -import { useState } from 'react' +import { useCallback, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' @@ -36,18 +36,33 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { const [stepToken, setStepToken] = useState('') const [newOwner, setNewOwner] = useState('') const [isTransfer, setIsTransfer] = useState(false) + const timerIdRef = React.useRef(undefined) + + const retimeCountdown = useCallback((timerId?: number) => { + if (timerIdRef.current !== undefined) + window.clearInterval(timerIdRef.current) + + timerIdRef.current = timerId + }, []) + + React.useEffect(() => { + if (!show) + retimeCountdown() + + return retimeCountdown + }, [retimeCountdown, show]) const startCount = () => { setTime(60) - const timer = setInterval(() => { + retimeCountdown(window.setInterval(() => { setTime((prev) => { - if (prev <= 0) { - clearInterval(timer) + if (prev <= 1) { + retimeCountdown() return 0 } return prev - 1 }) - }, 1000) + }, 1000)) } const sendEmail = async () => { diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx index 5497786794..4dd604a03e 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx @@ -76,16 +76,16 @@ afterAll(() => { // Mock portal components for controlled positioning in tests // Use React context to properly scope open state per portal instance (for nested portals) -const _PortalOpenContext = React.createContext(false) - vi.mock('@/app/components/base/portal-to-follow-elem', () => { // Context reference shared across mock components let sharedContext: React.Context | null = null // Lazily get or create the context const getContext = (): React.Context => { - if (!sharedContext) - sharedContext = React.createContext(false) + if (!sharedContext) { + const PortalOpenContext = React.createContext(false) + sharedContext = PortalOpenContext + } return sharedContext } @@ -725,6 +725,39 @@ describe('AppPicker', () => { triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) expect(onLoadMore).toHaveBeenCalledTimes(2) }) + + it('should reset loadingRef when the picker closes before the debounce timeout finishes', () => { + const onLoadMore = vi.fn() + const { rerender } = render( + , + ) + + triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) + expect(onLoadMore).toHaveBeenCalledTimes(1) + + rerender() + rerender() + + triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) + expect(onLoadMore).toHaveBeenCalledTimes(2) + }) + + it('should reset loadingRef when the picker unmounts before the debounce timeout finishes', () => { + const onLoadMore = vi.fn() + const { unmount } = render( + , + ) + + triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) + expect(onLoadMore).toHaveBeenCalledTimes(1) + + unmount() + + render() + + triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) + expect(onLoadMore).toHaveBeenCalledTimes(2) + }) }) describe('Memoization', () => { @@ -1539,7 +1572,7 @@ describe('AppSelector', () => { expect(screen.getByTestId('portal-content')).toBeInTheDocument() }) - it('should manage isLoadingMore state during load more', () => { + it('should render correctly during load more setup', () => { mockHasNextPage = true mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))) @@ -1739,7 +1772,7 @@ describe('AppSelector', () => { expect(mockFetchNextPage).toHaveBeenCalled() }) - it('should set isLoadingMore and reset after delay in handleLoadMore', async () => { + it('should avoid duplicate fetches while the picker debounce is active', async () => { mockHasNextPage = true mockIsFetchingNextPage = false mockFetchNextPage.mockResolvedValue(undefined) @@ -1756,34 +1789,15 @@ describe('AppSelector', () => { expect(mockFetchNextPage).toHaveBeenCalledTimes(1) - // Try to trigger again immediately - should be blocked by isLoadingMore + // Try to trigger again immediately - should be blocked by AppPicker loadingRef triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - // Still only one call due to isLoadingMore + // Still only one call due to the picker-level debounce expect(mockFetchNextPage).toHaveBeenCalledTimes(1) - // This verifies the debounce logic is working - multiple calls are blocked expect(screen.getAllByTestId('portal-content').length).toBeGreaterThan(0) }) - it('should not call fetchNextPage when isLoadingMore is true', async () => { - mockHasNextPage = true - mockIsFetchingNextPage = false - mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000))) - - renderWithQueryClient() - - // Open portals - fireEvent.click(screen.getAllByTestId('portal-trigger')[0]) - const triggers = screen.getAllByTestId('portal-trigger') - fireEvent.click(triggers[1]) - - // Trigger intersection - this starts loading - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - - expect(mockFetchNextPage).toHaveBeenCalledTimes(1) - }) - it('should skip handleLoadMore when isFetchingNextPage is true', async () => { mockHasNextPage = true mockIsFetchingNextPage = true // This will block the handleLoadMore @@ -1821,89 +1835,7 @@ describe('AppSelector', () => { // fetchNextPage should NOT be called because hasMore is false expect(mockFetchNextPage).not.toHaveBeenCalled() }) - - it('should return early from handleLoadMore when isLoadingMore is true', async () => { - mockHasNextPage = true - mockIsFetchingNextPage = false - // Make fetchNextPage slow to keep isLoadingMore true - mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 5000))) - - renderWithQueryClient() - - fireEvent.click(screen.getAllByTestId('portal-trigger')[0]) - const triggers = screen.getAllByTestId('portal-trigger') - fireEvent.click(triggers[1]) - - // First call starts loading - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - expect(mockFetchNextPage).toHaveBeenCalledTimes(1) - - // Second call should return early due to isLoadingMore - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - - // Still only 1 call because isLoadingMore blocks it - expect(mockFetchNextPage).toHaveBeenCalledTimes(1) - }) - - it('should reset isLoadingMore via setTimeout after fetchNextPage resolves', async () => { - mockHasNextPage = true - mockIsFetchingNextPage = false - mockFetchNextPage.mockResolvedValue(undefined) - - renderWithQueryClient() - - fireEvent.click(screen.getAllByTestId('portal-trigger')[0]) - const triggers = screen.getAllByTestId('portal-trigger') - fireEvent.click(triggers[1]) - - // Trigger load more - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - - // Wait for fetchNextPage to complete and setTimeout to fire - await act(async () => { - await Promise.resolve() - vi.advanceTimersByTime(350) // Past the 300ms setTimeout - }) - - // Should be able to load more again - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - - // This might trigger another fetch if loadingRef also reset - expect(screen.getAllByTestId('portal-content').length).toBeGreaterThan(0) - }) - - it('should reset isLoadingMore after fetchNextPage completes with setTimeout', async () => { - mockHasNextPage = true - mockIsFetchingNextPage = false - mockFetchNextPage.mockResolvedValue(undefined) - - renderWithQueryClient() - - // Open portals - fireEvent.click(screen.getAllByTestId('portal-trigger')[0]) - const triggers = screen.getAllByTestId('portal-trigger') - fireEvent.click(triggers[1]) - - // Trigger first intersection - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - - expect(mockFetchNextPage).toHaveBeenCalledTimes(1) - - // Advance timer past the 300ms setTimeout in finally block - await act(async () => { - vi.advanceTimersByTime(400) - }) - - // Also advance past the loadingRef timeout in AppPicker (500ms) - await act(async () => { - vi.advanceTimersByTime(200) - }) - - // Verify component is still rendered correctly - expect(screen.getAllByTestId('portal-content').length).toBeGreaterThan(0) - }) }) - describe('Form Change Handling', () => { it('should handle form change with image file', () => { const onSelect = vi.fn() @@ -2284,7 +2216,7 @@ describe('AppSelector Integration', () => { expect(screen.getByTestId('portal-content')).toBeInTheDocument() }) - it('should set isLoadingMore to false after fetchNextPage completes', async () => { + it('should stay stable after fetchNextPage completes', async () => { mockHasNextPage = true mockIsFetchingNextPage = false mockFetchNextPage.mockResolvedValue(undefined) @@ -2293,16 +2225,10 @@ describe('AppSelector Integration', () => { fireEvent.click(screen.getAllByTestId('portal-trigger')[0]) - // Advance timers past the 300ms delay - await act(async () => { - vi.advanceTimersByTime(400) - }) - expect(screen.getByTestId('portal-content')).toBeInTheDocument() }) it('should not call fetchNextPage when conditions prevent it', () => { - // isLoadingMore would be true internally mockHasNextPage = false mockIsFetchingNextPage = true diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx index c32e959652..b849ced8fd 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx @@ -51,9 +51,30 @@ const AppPicker: FC = ({ onSearchChange, }) => { const { t } = useTranslation() - const observerTarget = useRef(null) + const observerTargetRef = useRef(null) const observerRef = useRef(null) const loadingRef = useRef(false) + const loadingResetTimerIdRef = useRef(undefined) + + const retimeLoadingReset = useCallback((timerId?: number) => { + if (loadingResetTimerIdRef.current !== undefined) + globalThis.clearTimeout(loadingResetTimerIdRef.current) + + loadingResetTimerIdRef.current = timerId + }, []) + + const resetLoadingState = useCallback(() => { + retimeLoadingReset() + loadingRef.current = false + }, [retimeLoadingReset]) + + const disconnectObserver = useCallback(() => { + if (!observerRef.current) + return + + observerRef.current.disconnect() + observerRef.current = null + }, []) const handleIntersection = useCallback((entries: IntersectionObserverEntry[]) => { const target = entries[0] @@ -62,27 +83,27 @@ const AppPicker: FC = ({ loadingRef.current = true onLoadMore() - // Reset loading state - setTimeout(() => { + retimeLoadingReset(window.setTimeout(() => { loadingRef.current = false - }, 500) - }, [hasMore, isLoading, onLoadMore]) + retimeLoadingReset() + }, 500)) + }, [hasMore, isLoading, onLoadMore, retimeLoadingReset]) useEffect(() => { if (!isShow) { - if (observerRef.current) { - observerRef.current.disconnect() - observerRef.current = null - } + resetLoadingState() + disconnectObserver() return } let mutationObserver: MutationObserver | null = null const setupIntersectionObserver = () => { - if (!observerTarget.current) + if (!observerTargetRef.current) return + disconnectObserver() + // Create new observer observerRef.current = new IntersectionObserver(handleIntersection, { root: null, @@ -90,12 +111,12 @@ const AppPicker: FC = ({ threshold: 0.1, }) - observerRef.current.observe(observerTarget.current) + observerRef.current.observe(observerTargetRef.current) } // Set up MutationObserver to watch DOM changes mutationObserver = new MutationObserver((_mutations) => { - if (observerTarget.current) { + if (observerTargetRef.current) { setupIntersectionObserver() mutationObserver?.disconnect() } @@ -108,17 +129,15 @@ const AppPicker: FC = ({ }) // If element exists, set up IntersectionObserver directly - if (observerTarget.current) + if (observerTargetRef.current) setupIntersectionObserver() return () => { - if (observerRef.current) { - observerRef.current.disconnect() - observerRef.current = null - } + resetLoadingState() + disconnectObserver() mutationObserver?.disconnect() } - }, [isShow, handleIntersection]) + }, [disconnectObserver, handleIntersection, isShow, resetLoadingState]) const getAppType = (app: App) => { switch (app.mode) { @@ -180,7 +199,7 @@ const AppPicker: FC = ({ background={app.icon_background} imageUrl={app.icon_url} /> -
+
{app.name} ( @@ -188,10 +207,10 @@ const AppPicker: FC = ({ )
-
{getAppType(app)}
+
{getAppType(app)}
))} -
+
{isLoading && (
{t('loading', { ns: 'common' })}
diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx index 5d0fa6d4b8..92960195a4 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx @@ -47,9 +47,8 @@ const AppSelector: FC = ({ onSelect, }) => { const { t } = useTranslation() - const [isShow, onShowChange] = useState(false) + const [isShow, setIsShow] = useState(false) const [searchText, setSearchText] = useState('') - const [isLoadingMore, setIsLoadingMore] = useState(false) const { data, @@ -97,25 +96,16 @@ const AppSelector: FC = ({ const hasMore = hasNextPage ?? true const handleLoadMore = useCallback(async () => { - if (isLoadingMore || isFetchingNextPage || !hasMore) + if (isFetchingNextPage || !hasMore) return - setIsLoadingMore(true) - try { - await fetchNextPage() - } - finally { - // Add a small delay to ensure state updates are complete - setTimeout(() => { - setIsLoadingMore(false) - }, 300) - } - }, [isLoadingMore, isFetchingNextPage, hasMore, fetchNextPage]) + await fetchNextPage() + }, [fetchNextPage, hasMore, isFetchingNextPage]) const handleTriggerClick = () => { if (disabled) return - onShowChange(true) + setIsShow(true) } const [isShowChooseApp, setIsShowChooseApp] = useState(false) @@ -157,7 +147,7 @@ const AppSelector: FC = ({ placement={placement} offset={offset} open={isShow} - onOpenChange={onShowChange} + onOpenChange={setIsShow} > = ({
-
{t('appSelector.label', { ns: 'app' })}
+
{t('appSelector.label', { ns: 'app' })}
= ({ onSelect={handleSelectApp} scope={scope || 'all'} apps={appsForPicker} - isLoading={isLoading || isLoadingMore || isFetchingNextPage} + isLoading={isLoading || isFetchingNextPage} hasMore={hasMore} onLoadMore={handleLoadMore} searchText={searchText} diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 174b7a875c..ae1d44770c 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -5172,9 +5172,6 @@ "app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx": { "no-restricted-imports": { "count": 1 - }, - "tailwindcss/enforce-consistent-class-order": { - "count": 2 } }, "app/components/plugins/plugin-detail-panel/app-selector/app-trigger.tsx": { @@ -5185,9 +5182,6 @@ "app/components/plugins/plugin-detail-panel/app-selector/index.tsx": { "no-restricted-imports": { "count": 1 - }, - "tailwindcss/enforce-consistent-class-order": { - "count": 1 } }, "app/components/plugins/plugin-detail-panel/datasource-action-list.tsx": {