Skip to content

fix(console): System Hub 计数失败回落 null 而非冒充 0,单卡隔离 (#3679) - #3686

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3679-hub-counts-error-state
Aug 7, 2026
Merged

fix(console): System Hub 计数失败回落 null 而非冒充 0,单卡隔离 (#3679)#3686
yinlianghui merged 1 commit into
mainfrom
claude/issue-3679-hub-counts-error-state

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3679

先复核前提(#3680 的三行矩阵,逐条对 origin/main @ 074ec53d6 重测)

issue 的三条论断全部成立,没有一条过期:

  1. 404 到不了页面这一层packages/data-objectstack/src/index.ts:1147is404Error(err) 命中后把资源记进 missingResourcesreturn { data: [], total: 0 }(find() 开头 :1117 还有一条同名短路),给的是 resolve 不是 reject。这是契约,本 PR 一个字节没碰。
  2. 逐 promise 的 .catch(() =({ data: [] })) 实际覆盖的只有非 404:适配器对非 404 一律 throw err(:1152)。
  3. 外层 catch { /* Keep nulls on failure */ } 确实进不去:每个 promise 自带 .catch,Promise.all 不可能 reject。

另外核实了 issue 没说、但决定「复活什么」的一条:null 的渲染分支是活的,不是死的。徽章写作 {card.count !== null && (Badge…)},而 Applications / Metadata / Settings / Profile 等卡片本来就是 count: null 且不显示徽章 —— 也就是说「不显示徽章 = 不知道」这个形态页面现在就在用,本 PR 只是让错误态也走进去,没有发明任何新 UI。(若这条不成立,派发要求停手报告;它成立。)

修法:逐 promise 回 null,而不是让外层接手

派发把两条路留给实测取舍,实测结论:外层接手做不到逐卡,必须逐 promise

外层 catch 是全军覆没式的 —— 一个 reject 就让五张卡全部回 null。而 issue 点名的最易复现场景恰恰是单个对象上的权限拒绝:一个能进 System Hub、但对 sys_audit_log 没有读权限的管理员。让 Audit Log 那张卡失去数字是对的;顺带把 Users / Organizations / Positions 三张已经答复成功的卡的真实数字一起抹掉,是把一个局部故障放大成全局故障。所以 .catch 留在每个调用上,只是把返回值从「空页」换成 null

这不是推理,是量出来的 —— 见下方逆向验证的扰动 B

dataSource.find('sys_user').catch(()=>null),// 原:.catch(() => ({ data: [] }))users: countOrUnknown(usersRes),// 原:usersRes.data?.length ?? 0
functioncountOrUnknown(result: {data?: unknown[]}|null): number|null{returnresult===null ? null : (result.data?.length??0);}

0 是一个主张(后端答复了,答案是「没有」);null 是唯一能说「不知道」的形态。

修前后行为矩阵

后端实际发生的事适配器页面修前屏幕修后屏幕
对象不存在(404 OBJECT_NOT_FOUND)吃掉,resolve 空.catch 未触发00(不变 —— 契约,不是错误)
对象存在但确实没有记录resolve 空.catch 未触发00(不变)
500 / 401 / 403 / 断网 / 超时(单个对象)rethrow.catchnull该卡 0,邻卡正常该卡无徽章,邻卡数字不受影响
同上,但五个全失败rethrow五个 .catchnull五张全 0五张全无徽章

第一、二行是 #3655 / #3670-permissions 的账,⛔ 未碰;#3680 落的 Permissions 恒 0 的 MEASUREMENT 钉子原样保留。

外层那个 catch:照实说,没动它,也没为它写测试

派发允许「删逐 promise catch 让外层接手」,上面已说明为什么不走。剩下的问题是外层 catch 本身要不要一并改成显式 setCounts(全 null)没改,理由是能被诚实钉住的只有它现在的样子:

  • 它唯一的入口是 dataSource.find同步抛出(.catch 挂在返回的 promise 上,rejection 到不了外层)。
  • 走到那条路时,counts 还停在初始的全 null —— 所以「所有卡都没有数字」这条断言无论外层 catch 做什么都会绿,包括什么都不做。那是「因为什么都没产生所以绿」,不是「因为逻辑对所以绿」。
  • 唯一能区分的场景是重取(前一轮成功的数字残留在屏上)。而 fetchCountsdataSource 记忆化,AdapterProvider 的 adapter 每次挂载只产生一次身份变化(useState(externalAdapter ?? null) → effect 里 setAdapter,依赖 [externalAdapter]),所以今天这条路根本走不到,想钉住它得改掉测试里「adapter 是稳定单例」的脚手架。

所以修的是结果被写下的那一处(setCounts),测试也钉在那里;外层 catch 只把注释订正为它真实的可达条件。测试文件里留了一段说明,免得下一个读者以为这里漏了一条用例。

逆向验证(两个扰动,方向都先预测后运行)

扰动 A —— 把逐 promise 的 .catch 退回 ({ data: [] })(即复现缺陷)

预测:4 红 | 9 绿(本文件 4 红 5 绿 + metadataCards 4 绿)。红的应是新增那 4 条错误态用例,方向是又见确定的 0;而这 4 条用例内部的「邻卡仍显示真实数字」「真实空集仍是 0」那些断言在两个方向下都绿 —— 它们钉的是不该动的东西,不是这次改动的证据,这点照实预测,不假装它们会跟着红。

实测:Tests 4 failed | 5 passed (9),逐条吻合。 失败现场直接把缺陷打印出来:

× a 500 on one object blanks that card instead of collapsing it into 0
× blanks only the card that failed and leaves its neighbours their real counts
× keeps `0` for the two things that really are zero, and blanks only the failure
× shows no counts at all when every lookup fails, and still renders the hub
AssertionError: expected div …(1)/div to be null
+ Received:
0
users

扰动 B —— 删掉逐 promise 的 .catch,让外层接手(派发点名的另一条修法)

这一扰动才是「逐 promise vs 外层」的实测取舍,方向和 A 不同,所以单独预测:

预测:3 红 | 6 绿,且红法不是断言 diff 而是等待锚超时 —— 前三条用例都以一张「答复成功」的邻卡徽章作为 settle 锚(2 organizations / 3 users),外层接手后这些邻卡连同失败的那张一起没了数字,锚永远等不到。第 4 条(五个全失败)两种形态下结果相同,应保持绿

实测:Tests 3 failed | 6 passed (9),逐条吻合:

× a 500 on one object blanks that card … 1058ms
× blanks only the card that failed … 1031ms
× keeps `0` for the two things that really are zero … 1037ms
TestingLibraryElementError: Unable to find an element with the text: 2 organizations
TestingLibraryElementError: Unable to find an element with the text: 3 users

「答复成功的邻卡失去了它的数字」正是选择逐 promise 的理由,现在是量出来的。

测试

SystemHubPage.counts.test.tsx 新增 4 条(#3680 落的 6 条中,5 条原样未动):

用例钉住什么
a 500 on one object blanks that card instead of collapsing it into 0#3680 第三条 MEASUREMENT 的重写(同 fixture,期望反转)
blanks only the card that failed and leaves its neighbours their real counts硬验收:sys_audit_log 403 → 该卡无徽章;其余四张仍是 3 users / 2 organizations / 4 positions / 0 permissions(单卡隔离)
keeps 0 for the two things that really are zero, and blanks only the failure三行矩阵同屏:真实空集 0 entries、未注册对象 0 permissions、403 无徽章
shows no counts at all when every lookup fails, and still renders the hub断网:五张全无徽章,页面照常渲染,链接仍可用

关于那条被重写的 MEASUREMENT:派发写的是测试「只加不改」,但 #3680 落的 6 条里有一条 —— MEASUREMENT: a non-404 failure is collapsed into 0 as well, with no error affordance —— 钉的正是本单要修的行为,它自己的注释也写明「changing the error handling is a separate class of work, filed as objectui#3679」。留着它必红,故按其原意重写(同 fixture、期望反转、迁进新 describe),并在原 MEASUREMENT 段落注明「原本三条,第三条已完成并搬走」。另外两条 MEASUREMENT 钉的是 #3655 的缺口,未动

从仓库根跑,重活走共享 flock + --max-old-space-size=4096 + --maxWorkers=2:

消费半径清扫:全仓 hub-card-* testid 的产出者只有 SystemHubPage(本文件)与无关的 DeveloperHubPage;同目录 SystemHubPage.metadataCards.test.tsx 不断言计数,已随全量跑绿。计数徽章文案(0 users 等)在 packages/** 的唯一命中是 AssignedUsersSection.tsx 里的一句注释,与本页无关。

文件面

  • apps/console/src/pages/system/SystemHubPage.tsx —— 五个 .catchnull + countOrUnknown 辅助函数 + 说明为什么是逐 promise、为什么 404 不走这里、外层 catch 真实可达条件
  • apps/console/src/pages/system/__tests__/SystemHubPage.counts.test.tsx —— 新增 describe(4 条)+ 重写上述那条 MEASUREMENT
  • .changeset/system-hub-count-error-state-3679.md —— patch(用户可见:错误态不再冒充 0)

⛔ 未触碰:packages/data-objectstackmissingResources / is404Error 设计、卡片 href、Permissions 的对象名与其 MEASUREMENT 钉、其他任何文件。

越界发现(只报不改,已单独立单、未认领)


🤖 Generated with Claude Code

https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt


Generated by Claude Code

…ookup fails (#3679)
Each count on the System Hub caught its own failure with an empty page, so a
500 / 401 / 403 / offline rendered the same confident `0` as a table that
really is empty. The most reachable case is a permission denial on ONE object:
an admin who may open the hub but cannot read `sys_audit_log` was shown
"0 entries".
Each `.catch` now resolves to `null`, which flows into `counts` and hits the
badge's existing `count !== null` branch — the card shows no number instead of
a wrong one. The catch stays on each call rather than moving out around the
`Promise.all`, so one object's failure blanks only its own card; measured, not
assumed (see the PR's reverse verification).
404 is unchanged: the adapter resolves unregistered objects as an empty page by
design, so those still count `0`. The outer `catch` is left alone — it is
reachable only by a synchronous throw, where the counts are still at their
initial `null` anyway.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
@vercel

vercelBot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 7, 2026 9:13pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-eEqlis5G.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.66KB3.13KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)480.72KB105.64KB
core (index.js)2.96KB1.13KB
create-plugin (index.js)9.28KB2.98KB
data-objectstack (index.js)137.51KB35.11KB
fields (index.js)230.87KB56.83KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)26.14KB6.07KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.53KB10.71KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.98KB12.37KB
plugin-charts (index.js)61.04KB17.31KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)115.50KB29.96KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)232.79KB57.42KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)112.10KB27.10KB
plugin-gantt (index.js)162.55KB39.57KB
plugin-grid (index.js)186.61KB49.34KB
plugin-kanban (index.js)48.30KB13.28KB
plugin-list (index.js)105.12KB25.48KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.71KB1.34KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

console: System Hub 计数的 .catch(() =&gt; ({ data: [] })) 把 500 / 401 / 403 / 断网都渲染成一个确定的 0(404 根本到不了它)

2 participants

@yinlianghui@claude