From cd1e9f0b03d71b5601b6cc92eafe6fbf57fa9c69 Mon Sep 17 00:00:00 2001 From: HandSonic <8078023+handsonic@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:39:07 +0800 Subject: [PATCH] fix(client): ignore stale dashboard details --- chat2db-community-client/package.json | 3 +- .../scripts/run-dashboard-store-tests.cjs | 25 +++++ .../store/dashboard/slices/common/action.ts | 11 ++- .../common/dashboardDetailRequest.test.ts | 92 +++++++++++++++++++ .../slices/common/dashboardDetailRequest.ts | 16 ++++ 5 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 chat2db-community-client/scripts/run-dashboard-store-tests.cjs create mode 100644 chat2db-community-client/src/store/dashboard/slices/common/dashboardDetailRequest.test.ts create mode 100644 chat2db-community-client/src/store/dashboard/slices/common/dashboardDetailRequest.ts diff --git a/chat2db-community-client/package.json b/chat2db-community-client/package.json index d93ebd176c..75efb19ab5 100644 --- a/chat2db-community-client/package.json +++ b/chat2db-community-client/package.json @@ -12,7 +12,7 @@ "build": "yarn run build:web:community", "build:prod": "yarn run build:web:community", "build:web": "umi build", - "prebuild:web:community": "yarn test:community-boundary && yarn test:database-capabilities && yarn test:table-editor && yarn test:retired-ai && yarn test:tree-title-highlight && yarn test:tree-search-lifecycle && yarn test:driver-upload && yarn test:tree-loading && yarn test:tree-node-lookup && yarn test:tree-node-path && yarn test:data-source-authorization && yarn test:active-transactions && yarn test:data-source-mutation-refresh && yarn test:ai-model-config && yarn test:ai-model-select && yarn test:export-connections && yarn test:main-page-navigation && yarn test:shortcut && yarn test:console-tab-name && yarn test:workspace-tab-scroll && yarn test:workspace-split-lifecycle && yarn test:sql-completion-context && yarn test:file-manager-label && yarn test:local-file-encoding && yarn test:local-file-save && yarn test:local-file-tree-path && yarn test:saved-console-dirty && yarn test:editor-close && yarn test:result-set-ui && yarn test:result-pagination && yarn test:sql-execution-stream && yarn test:result-table-layout && yarn test:result-status && yarn test:data-source-identity && yarn test:data-source-execution-snapshot && yarn test:data-source-watermark && yarn test:hot-update && yarn test:task-center && yarn test:application-exit && yarn test:monaco-lifecycle && yarn test:result-set-editor && yarn test:account-grants-request", + "prebuild:web:community": "yarn test:community-boundary && yarn test:database-capabilities && yarn test:table-editor && yarn test:retired-ai && yarn test:tree-title-highlight && yarn test:tree-search-lifecycle && yarn test:driver-upload && yarn test:tree-loading && yarn test:tree-node-lookup && yarn test:tree-node-path && yarn test:data-source-authorization && yarn test:active-transactions && yarn test:data-source-mutation-refresh && yarn test:ai-model-config && yarn test:ai-model-select && yarn test:export-connections && yarn test:main-page-navigation && yarn test:shortcut && yarn test:console-tab-name && yarn test:workspace-tab-scroll && yarn test:workspace-split-lifecycle && yarn test:sql-completion-context && yarn test:file-manager-label && yarn test:local-file-encoding && yarn test:local-file-save && yarn test:local-file-tree-path && yarn test:saved-console-dirty && yarn test:editor-close && yarn test:result-set-ui && yarn test:result-pagination && yarn test:sql-execution-stream && yarn test:result-table-layout && yarn test:result-status && yarn test:data-source-identity && yarn test:data-source-execution-snapshot && yarn test:data-source-watermark && yarn test:hot-update && yarn test:task-center && yarn test:application-exit && yarn test:monaco-lifecycle && yarn test:result-set-editor && yarn test:account-grants-request && yarn test:dashboard-store", "postbuild:web:community": "node ./scripts/verify-production-bundles.cjs", "build:web:2java": "yarn run build:web:community && rm -rf ../chat2db-community-server/chat2db-community-start/src/main/resources/thymeleaf/* && cp -r dist/index.html ../chat2db-community-server/chat2db-community-start/src/main/resources/thymeleaf/", "build:web:community": "cross-env UMI_ENV=community cross-env APP_NAME=chat2db-community cross-env APP_VERSION=${npm_config_app_version} cross-env PRINT_LOGS=${npm_config_print_logs} cross-env APP_PORT=${npm_config_app_port} umi build", @@ -30,6 +30,7 @@ "test:retired-ai": "tsx src/blocks/AI/retiredAiSurface.test.ts && tsx src/blocks/AI/components/AIChatInput/mentionSelection.test.ts && tsx src/blocks/AI/knowledgeSelection.test.ts && tsx src/blocks/AI/messageNavigation.test.ts", "test:hot-update": "tsx src/store/global/slices/hotUpdate/action.test.ts", "test:active-transactions": "tsx src/blocks/NewTree/components/ActiveTransactionsContent/activeTransactionUtils.test.ts && tsx src/blocks/NewTree/treeMenuGrouping.test.ts", + "test:dashboard-store": "node scripts/run-dashboard-store-tests.cjs", "test:result-pagination": "tsx src/blocks/SearchResult/components/ResultSet/pagination.test.ts", "test:tree-node-path": "tsx src/blocks/NewTree/utils/treeNodePath.test.ts", "test:ai-model-config": "tsx src/blocks/AI/components/AIModelConfigModal/modelConfigDefaults.test.ts", diff --git a/chat2db-community-client/scripts/run-dashboard-store-tests.cjs b/chat2db-community-client/scripts/run-dashboard-store-tests.cjs new file mode 100644 index 0000000000..02ca14314f --- /dev/null +++ b/chat2db-community-client/scripts/run-dashboard-store-tests.cjs @@ -0,0 +1,25 @@ +const { existsSync } = require('node:fs'); +const { spawnSync } = require('node:child_process'); + +const testFiles = [ + 'src/store/dashboard/slices/common/dashboardDetailRequest.test.ts', + 'src/store/dashboard/slices/common/dashboardMutation.test.ts', + 'src/store/dashboard/slices/common/refreshCurrentDashboard.test.ts', + 'src/blocks/BI/ChartCardBox/DingChartModal/pinChartToDashboard.test.ts', +]; +const tsxCli = require.resolve('tsx/cli'); +let executed = 0; + +for (const testFile of testFiles) { + if (!existsSync(testFile)) continue; + executed += 1; + const result = spawnSync(process.execPath, [tsxCli, testFile], { stdio: 'inherit' }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +if (!executed) { + console.error('No dashboard store tests were found.'); + process.exit(1); +} diff --git a/chat2db-community-client/src/store/dashboard/slices/common/action.ts b/chat2db-community-client/src/store/dashboard/slices/common/action.ts index a6b03ff94b..b2afd4273a 100644 --- a/chat2db-community-client/src/store/dashboard/slices/common/action.ts +++ b/chat2db-community-client/src/store/dashboard/slices/common/action.ts @@ -12,6 +12,9 @@ import { import i18n from '@/i18n'; import { staticMessage } from '@chat2db/ui'; import { filterSchemaByChartIds } from '@/utils/dashboard'; +import { DashboardDetailRequestOwner } from './dashboardDetailRequest'; + +const dashboardDetailRequestOwner = new DashboardDetailRequestOwner(); export interface CommonAction { /** Set up Dashboard list */ @@ -74,6 +77,7 @@ export const createCommonAction: StateCreator { + dashboardDetailRequestOwner.invalidate(); set({ currentDashboard: dashboard }); }, updateDashboard: (dashboard) => { @@ -127,9 +131,10 @@ export const createCommonAction: StateCreator { - set({ currentDashboard: res }); - }); + return dashboardDetailRequestOwner.run( + () => getDashboardById({ id }), + (dashboard) => set({ currentDashboard: dashboard }), + ); }, refreshCurrentDashboard: () => { const currentDashboardId = get().currentDashboard?.id; diff --git a/chat2db-community-client/src/store/dashboard/slices/common/dashboardDetailRequest.test.ts b/chat2db-community-client/src/store/dashboard/slices/common/dashboardDetailRequest.test.ts new file mode 100644 index 0000000000..acb89b9adf --- /dev/null +++ b/chat2db-community-client/src/store/dashboard/slices/common/dashboardDetailRequest.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { DashboardDetailRequestOwner } from './dashboardDetailRequest'; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} + +function deferred(): Deferred { + let resolvePromise: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { + promise, + resolve: (value) => resolvePromise?.(value), + }; +} + +async function testLatestDashboardDetailOwnsCurrentState() { + const owner = new DashboardDetailRequestOwner(); + const firstRequest = deferred<{ id: number }>(); + const latestRequest = deferred<{ id: number }>(); + let currentDashboard: { id: number } | null = null; + const commit = (dashboard: { id: number }) => { + currentDashboard = dashboard; + }; + + const firstLoad = owner.run(() => firstRequest.promise, commit); + const latestLoad = owner.run(() => latestRequest.promise, commit); + latestRequest.resolve({ id: 2 }); + await latestLoad; + assert.equal(currentDashboard?.id, 2); + + firstRequest.resolve({ id: 1 }); + await firstLoad; + assert.equal(currentDashboard?.id, 2, 'an older response must not replace the latest dashboard'); +} + +async function testDirectSelectionInvalidatesPendingDetail() { + const owner = new DashboardDetailRequestOwner(); + const request = deferred<{ id: number }>(); + let currentDashboard: { id: number } | null = null; + const load = owner.run( + () => request.promise, + (dashboard) => { + currentDashboard = dashboard; + }, + ); + + owner.invalidate(); + currentDashboard = { id: 2 }; + request.resolve({ id: 1 }); + await load; + + assert.equal(currentDashboard.id, 2, 'a direct selection must not be overwritten by an older request'); +} + +async function testLatestFailureStillInvalidatesOlderSuccess() { + const owner = new DashboardDetailRequestOwner(); + const olderRequest = deferred<{ id: number }>(); + let currentDashboard: { id: number } | null = null; + const olderLoad = owner.run( + () => olderRequest.promise, + (dashboard) => { + currentDashboard = dashboard; + }, + ); + const latestError = new Error('latest request failed'); + await assert.rejects( + owner.run( + () => Promise.reject(latestError), + () => undefined, + ), + latestError, + ); + + olderRequest.resolve({ id: 1 }); + await olderLoad; + assert.equal(currentDashboard, null, 'an older success must stay stale after the latest request fails'); +} + +void Promise.all([ + testLatestDashboardDetailOwnsCurrentState(), + testDirectSelectionInvalidatesPendingDetail(), + testLatestFailureStillInvalidatesOlderSuccess(), +]) + .then(() => console.log('Dashboard detail request ownership tests passed')) + .catch((error) => { + console.error(error); + process.exitCode = 1; + }); diff --git a/chat2db-community-client/src/store/dashboard/slices/common/dashboardDetailRequest.ts b/chat2db-community-client/src/store/dashboard/slices/common/dashboardDetailRequest.ts new file mode 100644 index 0000000000..79d44b43e6 --- /dev/null +++ b/chat2db-community-client/src/store/dashboard/slices/common/dashboardDetailRequest.ts @@ -0,0 +1,16 @@ +export class DashboardDetailRequestOwner { + private generation = 0; + + invalidate() { + this.generation += 1; + } + + async run(request: () => Promise, commit: (result: T) => void): Promise { + const generation = this.generation + 1; + this.generation = generation; + const result = await request(); + if (this.generation === generation) { + commit(result); + } + } +}