fix(app-shell): key the record feed state by objectName:recordId (#3268) - #3284
Merged
xuyushun441-sys merged 1 commit intoAug 3, 2026
Merged
Conversation
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
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
xuyushun441-sys
marked this pull request as ready for review
August 3, 2026 10:44
xuyushun441-sys
deleted the
claude/issue-3268-feed-state-keyed-by-record
branch
August 3, 2026 10:45
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3268
问题
从记录 A 点到记录 B,A 的评论和活动会留在 B 的讨论面板上,B 自己的行归并进来排在一起。这是串数据,不是视觉瑕疵 —— 讨论区显示了另一条记录的内容。
成因是三件事叠在一起:
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 状态会跨记录存活;[]。A 的行和 B 的行 id 不同,所以 Map 去重不掉。
它还压掉了 #3209 刚修好的东西:记录间导航时
feedLoading确实会转 true,但RecordActivityTimeline的分支是loading && filtered.length === 0(#3205 有意为之 —— 刷新不该把已在屏的 feed 变成 spinner),残留行让filtered非空,于是渲染的是上一条记录的内容而不是加载态。两单合起来才是一个能用的功能。改法:按
objectName:recordId键存,不加清空的 setStatefeed 状态从一个扁平的
FeedItem[]改成按记录键存的 map。这把钥匙就是 #3209(PR #3270)在同一个文件里为 loading 标志引入的那把,也正是这些行在服务端携带的thread_id:渲染只读当前键那一份,于是:
setFeedItems([]);threadId),所以一条为 A 发出、在用户已经走到 B 之后才回来的读,更新的是 A 的切片,永远碰不到屏幕上的 B —— 这与settledFeedKey给 loading 标志的保证是同一条,本文件里一种写法而不是两种(主令 [WIP] Enhance UI components for forms and layouts #12)。⛔ 乐观行怎么保证不丢(本单唯一微妙处)
handleAddComment/handleAddReply/handleToggleReaction往 feed 里塞的本地行,是用户刚发出、尚未落库的评论 —— 它只存在于这份 state 里,清空就是真的删掉了用户的输入。按键存正是保住它的原因,链条是这样闭合的:feedItemsByRecord[feedRecordKey],feedRecordKey与dataSource.create('sys_comment', { thread_id })用的是同一个表达式,所以乐观行和它将来的持久化副本从一开始就归在同一把键下。feedRecordKey进了三个useCallback的依赖数组,handleToggleReaction另加了if (!feedRecordKey) return的守卫。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」。recordIdOverride="rec-A"→ 出现 A 的评论 → rerender 到rec-B→ 出现 B 的评论,且断言 A 的评论已经不在;sys_activity)同样不许残留 —— 两处回填是独立的,只修一处仍会漏半个记录;activity-loading,且 A 的评论不在、也没有过早断言 B「没有评论」(即 chatter 那条链没有 loading 信号源:RecordDetailView /record:chatter从不给RecordChatterPanel传loading#3209 在这条路径上生效);create一直失败(离线/403)时,往返之后本地行依然在;create收到的thread_id是 B 的。mutation 验证(退回归并写法,证明测试转红)
只把状态的键退回扁平(
feedRecordKey恒为'FLAT',两处回填写['FLAT']),feedFetchKey保持按记录键 —— 即精确还原修复前的状态,不动 #3209:RecordDetailView.feedLoading.test.tsx(#3209 的 11 条)在 mutation 下全绿 —— 说明这组新测试钉的正好是本单的缺陷,没有和 #3209 重叠。绿的证据
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