Skip to content

fix(app-shell): key the record feed state by objectName:recordId (#3268) - #3284

Merged
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-3268-feed-state-keyed-by-record
Aug 3, 2026
Merged

fix(app-shell): key the record feed state by objectName:recordId (#3268)#3284
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-3268-feed-state-keyed-by-record

Conversation

@xuyushun441-sys

Copy link
Copy Markdown
Contributor

Fixes #3268

问题

从记录 A 点到记录 B,A 的评论和活动会留在 B 的讨论面板上,B 自己的行归并进来排在一起。这是串数据,不是视觉瑕疵 —— 讨论区显示了另一条记录的内容。

成因是三件事叠在一起:

  1. RecordDetailView 在记录间不会重挂载 —— 任何挂载点都没有 key=(console/AppContent.tsx,以及 ObjectView / ObjectDataPage / InterfaceListPage 的抽屉只是换 recordIdOverride)。这是有意为之(长期治理:客户端失效总线取代 key 重挂载(refreshKey/actionRefreshKey)+ UI 状态分类规则 + URL 参数注册表(#2257 后续) #2269「refresh data, don't rebuild UI」:bump key 会毁掉 scroll、折叠区、tab、在途行内编辑),所以 feed 状态会跨记录存活;
  2. 两处取数回填都是按行 id 归并到 prev;
  3. 全文件没有任何一处把它重置回 []

A 的行和 B 的行 id 不同,所以 Map 去重不掉。

它还压掉了 #3209 刚修好的东西:记录间导航时 feedLoading 确实会转 true,但 RecordActivityTimeline 的分支是 loading && filtered.length === 0(#3205 有意为之 —— 刷新不该把已在屏的 feed 变成 spinner),残留行让 filtered 非空,于是渲染的是上一条记录的内容而不是加载态。两单合起来才是一个能用的功能。

改法:按 objectName:recordId 键存,不加清空的 setState

feed 状态从一个扁平的 FeedItem[] 改成按记录键存的 map。这把钥匙就是 #3209(PR #3270)在同一个文件里为 loading 标志引入的那把,也正是这些行在服务端携带的 thread_id:

const feedRecordKey = objectName && pureRecordId ? `${objectName}:${pureRecordId}` : null;
const feedItems = (feedRecordKey ? feedItemsByRecord[feedRecordKey] : undefined) ?? EMPTY_FEED;
const feedFetchKey =
  dataSource && feedRecordKey && (feedsEnabled || activitiesEnabled) ? feedRecordKey : null;

渲染只读当前键那一份,于是:

  • 「换记录后是空的」是读取一个没有切片的键的自然结果,而不是一次与取数竞速的 setFeedItems([]);
  • 迟到的响应写进它的 effect 闭包捕获的那把键(两处回填都用 threadId),所以一条为 A 发出、在用户已经走到 B 之后才回来的读,更新的是 A 的切片,永远碰不到屏幕上的 B —— 这与 settledFeedKey 给 loading 标志的保证是同一条,本文件里一种写法而不是两种(主令 [WIP] Enhance UI components for forms and layouts #12)。

⛔ 乐观行怎么保证不丢(本单唯一微妙处)

handleAddComment / handleAddReply / handleToggleReaction 往 feed 里塞的本地行,是用户刚发出、尚未落库的评论 —— 它只存在于这份 state 里,清空就是真的删掉了用户的输入。按键存正是保住它的原因,链条是这样闭合的:

  1. 写进它所属记录的切片。 三个 handler 都改成写 feedItemsByRecord[feedRecordKey],feedRecordKeydataSource.create('sys_comment', { thread_id }) 用的是同一个表达式,所以乐观行和它将来的持久化副本从一开始就归在同一把键下。feedRecordKey 进了三个 useCallback 的依赖数组,handleToggleReaction 另加了 if (!feedRecordKey) return 的守卫。
  2. 离开记录不会删任何切片。 这里没有任何删除路径 —— 换记录只是去读另一把键。所以走开再回来,乐观行还在。
  3. 回来时不会变成重复行。 回到该记录时 effect 重新取数,mergeFeedRows按 id 求并:落库副本和乐观行用的是同一个 id(create 时显式传的 newItem.id),于是在同一个 key 上合成一行;若还没落库(或 create 失败被 .catch 吞掉),本地行照样留着。

顺带的好处:因为切片不删,回到看过的记录会立刻显示它的行、再由重读确认,而不是先闪一个 spinner —— 这正是 #3205 那条 loading && filtered.length === 0 想要的行为。

测试

新增 packages/app-shell/src/views/RecordDetailView.feedRecordScope.test.tsx(8 条),沿用 #3209 的装置,并且rerender 复用同一棵树 —— 元素类型与位置不变,React 会复用同一个 RecordDetailView 实例,这才是真实导航(重新 render() 会重挂载,把 bug 藏掉)。断言一律落在渲染结果上,不断言「传了某个 prop」。

  • 原缺陷复现(issue 正文那段的模板):recordIdOverride="rec-A" → 出现 A 的评论 → rerender 到 rec-B → 出现 B 的评论,且断言 A 的评论已经不在;
  • 活动行(sys_activity)同样不许残留 —— 两处回填是独立的,只修一处仍会漏半个记录;
  • 换记录时加载态可见:A 已结算并显示评论 → 切到读数挂起的 B → 出现 activity-loading,且 A 的评论不在、也没有过早断言 B「没有评论」(即 chatter 那条链没有 loading 信号源:RecordDetailView / record:chatter 从不给 RecordChatterPanelloading #3209 在这条路径上生效);
  • B 确实没有 feed 时落到它自己的空态,而不是 spin 到死;
  • 迟到响应:为 A 发出的读在用户走到 B 之后才 resolve,不许覆盖屏幕上的 B;
  • 乐观行往返(经真实 composer 发评论,不是直接调回调):在 A 发一条 → 切到 B(不许跟过去)→ 切回 A(还在,且只有一条 —— 重读拿回的落库副本按 id 合并掉了);
  • create 一直失败(离线/403)时,往返之后本地行依然在;
  • 在 A 发一条、在 B 发一条,各自只出现在自己的面板上,且 create 收到的 thread_id 是 B 的。

mutation 验证(退回归并写法,证明测试转红)

只把状态的键退回扁平(feedRecordKey 恒为 'FLAT',两处回填写 ['FLAT']),feedFetchKey 保持按记录键 —— 即精确还原修复前的状态,不动 #3209:

❯ packages/app-shell/src/views/RecordDetailView.feedRecordScope.test.tsx (8 tests | 8 failed)
  × drops record A's comment when the view navigates to record B 324ms
  × drops record A's ACTIVITY rows too, not just its comments 183ms
  × shows the LOADING state on record→record navigation, not the previous record (#3209 reaches this path)
  × settles record B onto its own empty state when B genuinely has no feed
  × does not let a SLOW response for the record just left overwrite the record now on screen
  × keeps a just-posted comment on its own record across a navigation round-trip
  × keeps an unpersisted comment when the write never lands
  × files the optimistic comment under the record it was written on, not the one visited next

AssertionError: expected < p …(1) >< /p > to be null            ← A 的评论还在 B 的面板上
TestingLibraryElementError: Unable to find an element by: [data-testid="activity-loading"]   ← 加载态被残留行压掉

 Test Files  1 failed | 1 passed (2)
      Tests  8 failed | 11 passed (19)

RecordDetailView.feedLoading.test.tsx(#3209 的 11 条)在 mutation 下全绿 —— 说明这组新测试钉的正好是本单的缺陷,没有和 #3209 重叠。

绿的证据

$ pnpm --filter @object-ui/app-shell test
 Test Files  20 passed (20)
      Tests  183 passed (183)

$ pnpm --filter @object-ui/app-shell type-check
> tsc --noEmit && tsc -p tsconfig.typetests.json      (无输出 = 通过)

$ pnpm --filter @object-ui/app-shell lint
✖ 2125 problems (0 errors, 2125 warnings)
# RecordDetailView.tsx:改前 122 warnings / 改后 122 warnings,0 errors —— 未新增

$ node scripts/check-changeset-no-major.mjs
✅  No changeset declares a `major` bump.

scope

只动了 packages/app-shell/src/views/RecordDetailView.tsx(+ 新测试 + changeset)。

packages/plugin-detail 一行没动。 loading && filtered.length === 0#3205 刻意写的,feed 生命周期修对之后它自己就是对的 —— 放宽它属于在消费端治标,而 #3165 / #3205 / #3209 一路都在刻意避免这件事。


🤖 Generated with Claude Code

https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa


Generated by Claude Code

The discussion panel showed the PREVIOUS record's comments and activity.
`RecordDetailView` is deliberately not remounted between records (no `key=`
at any mount site — #2269 "refresh data, don't rebuild UI"), so its
`feedItems` survived the navigation, and both reads merged into it BY ROW ID.
A's rows and B's rows have different ids, so the merge could not dedupe them
and nothing anywhere reset the list.

It also suppressed #3209: `feedLoading` did flip true on record→record
navigation, but the timeline's branch is `loading && filtered.length === 0`
(#3205, deliberate), and the leftover rows kept `filtered` non-empty — so the
panel rendered the previous record's content where a loading state belonged.

The feed state is now a map keyed by `objectName:recordId` — the same key
#3209 introduced in this file for the loading flag, and the `thread_id` the
rows carry server-side. The render reads only the current key's slice, so
"empty for a new record" falls out of reading a key with no slice rather than
from a `setFeedItems([])` racing the fetch. A response that lands after the
user navigated away is written under the key its effect closed over, so it can
never bleed into the record now on screen.

Optimistic rows (a posted-but-not-yet-persisted comment, a reply, a reaction)
ride in their own record's slice, so navigating away and back finds them
again; the re-read folds the persisted copy onto them by id — the same id they
were created under — so nothing duplicates when it lands.

`RecordActivityTimeline` is untouched: `loading && filtered.length === 0` is
right on its own once the feed's lifecycle is correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa
@github-actions github-actions Bot added the tests label Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.1 KB 350 KB
Entry file index-BXpcpZ0S.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.47KB 3.09KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.12KB 3.41KB
auth (LoginForm.js) 17.86KB 5.29KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.43KB 2.09KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 18.38KB 4.49KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 3.65KB 1.42KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.25KB 0.53KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 476.42KB 104.61KB
core (index.js) 2.25KB 0.80KB
create-plugin (index.js) 9.28KB 2.98KB
data-objectstack (index.js) 136.23KB 34.75KB
fields (index.js) 223.53KB 54.78KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.46KB 0.96KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 5.37KB 1.72KB
i18n (useObjectLabel.js) 26.14KB 6.07KB
i18n (useSafeTranslation.js) 3.26KB 1.44KB
layout (index.js) 37.96KB 10.54KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.05KB 1.53KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 44.98KB 12.37KB
plugin-charts (index.js) 60.54KB 17.13KB
plugin-chatbot (index.js) 180.09KB 42.72KB
plugin-dashboard (index.js) 112.01KB 28.86KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 230.56KB 56.80KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 111.49KB 26.95KB
plugin-gantt (index.js) 162.25KB 39.55KB
plugin-grid (index.js) 185.08KB 49.04KB
plugin-kanban (index.js) 47.89KB 13.18KB
plugin-list (index.js) 104.94KB 25.32KB
plugin-map (index.js) 16.81KB 5.24KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.55KB 10.59KB
plugin-timeline (index.js) 25.76KB 7.33KB
plugin-tree (index.js) 8.34KB 2.82KB
plugin-view (index.js) 83.67KB 20.43KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 19.28KB 6.38KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.02KB 0.55KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.46KB 1.21KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 0.20KB 0.18KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

@xuyushun441-sys
xuyushun441-sys marked this pull request as ready for review August 3, 2026 10:44
@xuyushun441-sys
xuyushun441-sys added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit a8aa576 Aug 3, 2026
17 checks passed
@xuyushun441-sys
xuyushun441-sys deleted the claude/issue-3268-feed-state-keyed-by-record branch August 3, 2026 10:45
Sign up for free to 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.

RecordDetailView 切换记录时不清空 feedItems:上一条记录的评论/活动会留在下一条记录的讨论面板上

2 participants