ADR 0122 — SessionPage.tsx 拆分为五个机械式 PR,并事先声明
- 状态: 已接受
- 日期: 2026-08-29
- 参考(不编辑): ADR 0121 (推理方式与本 ADR 相同——一个共享文件增长到"只是绕过去"已经比划一条边界更昂贵的地步——但执行方式不同:0121 是一个零 diff 的提交,本 ADR 是依次进行的五个 PR,因为该文件正处于活跃的功能开发中,一个单独的 PR 要么冻结代码,要么与其他所有改动赛跑)。
背景
apps/web/src/routes/SessionPage.tsx 有 3 807 行、169 KiB——docs/explanation/backlog.md 的技术债务审查里已经有一行写它:
Decompose
SessionPage.tsx/ProjectSettingsTab.tsx| G |SessionPage.tsxis 169 KiB with 25 test files importing it;ProjectSettingsTab.tsxis 90 KiB | already informally called a "disputed file" in historico-de-fases.md, but never promoted to a tracked debt item; per the standing rule this is its own phase, never a drive-by refactor
三项具体成本让这不只是对体积的抱怨:
- 25 个测试文件依赖它(
SessionPage.*.test.tsx加上SessionPage.ponto.test.ts)——任何结构性改动都有大范围波及的风险,而文件的体积让人难以事先知道某个改动可能触及这 25 个中的哪些。 - 该文件处于活跃修改中,不是遗留代码。 有 50 个提交触及过它;最近的 15 个几乎全是
feat新增(结构化问题、handoff、轮次活动条、RN-172 的轮次/结局排序……),其中没有一个是抽取——这是该文件历史上第一次尝试划出内部边界。这排除了一次性重写:为一个大 PR 冻结文件,会阻塞所有落在它上面的其他功能,或者迫使人们反复进行痛苦的 rebase。 - 测试套件已经划出了部分边界。 在本 PR 之前,
SessionPage.ordenacao-e-avisos.test.tsx从./SessionPage按名字直接导入aberturasDeTurno、turnoDoSeq、afundarDesfechos、ordemDaAcaoNaTimeline和agruparNarracoesDoTurno(await import('./SessionPage'),解构),SessionPage.ponto.test.ts对pontoDaSessao也是如此——不是渲染组件,而是作为普通函数调用。测试已经认定这五个(加上agruparNarracoesDoTurno)是独立单元;本 ADR 把这条既有边界提升为真正的模块边界,而不是发明一条新的。
鉴于活跃修改与波及范围,CLAUDE.md 的长期规则适用——"不是顺手重构"——所以这是一个独立阶段,规划为一系列可独立合并、行为中立的 PR,每一个都由同样的 25 个测试文件不经编辑通过来证明。
决策
五个 PR,逐个执行、逐个合并(绝不并行——每个都在下一个开始前进入 dev),顺序如下:
-
时间线/轮次的纯辅助函数 →
apps/web/src/lib/session-timeline.ts(本 PR)。aberturasDeTurno、turnoDoSeq、afundarDesfechos、pontoDaSessao、ordemDaAcaoNaTimeline以及它们共享的类型TimelineEntry。这五个已确认是纯的——没有 JSX,没有引用styles/CSS module,没有对组件状态的闭包——这使它成为风险最低、最适合打头的一片:一次机械的.ts改动,与 React 的渲染周期无需任何调和。agruparNarracoesDoTurno有意留在SessionPage.tsx,尽管它位于同一区域、测试方式也相同:它返回 JSX(<Disclosure>),并从SessionPage.module.css读取三个styles.narracoes*类名。移动它意味着决定lib/接受它的第一个.tsx文件——如今它只有.ts源文件(有一些.tsx测试,但.tsx源文件为零)——而这个先例属于真正处理 JSX 子组件的 PR(PR 2),而不属于一个本应只是移动纯函数的 PR。 -
StorySlide→apps/web/src/routes/StorySlide.tsx。 第一个被移动的 JSX 子组件,也是最简单的:一个单独的叶子组件(晋升轮播的幻灯片,RN-148),没有同样需要移动的子组件。正是这个 PR 决定SessionPage.module.css的共享问题,见下文。 -
StructuredQuestionCard+permiteOutra→apps/web/src/routes/StructuredQuestionCard.tsx。 另一个自包含的 JSX 子组件(RN-162/RN-171 的结构化问题表单),加上它私下依赖的唯一纯辅助函数——一起移动,因为把一个组件与只有它调用的唯一辅助函数分开,只会多一个文件,对任何读者都无益。 -
backlog 树的辅助函数 +
ItemDeBacklog+ContextAside→apps/web/src/lib/session-backlog-tree.ts+apps/web/src/routes/ContextAside.tsx。 最大的一片:组装树的纯辅助函数(它们的自然归宿是lib/,与 PR 1 同理)与渲染它们的 JSX(ItemDeBacklog、ContextAside——自然归宿是routes/,与 PR 2–3 同理)放在一起。它排在更简单的单组件移动之后,正因为它是两个文件、两种类型的抽取,并且受益于 PR 2–3 已经回答过一次 CSS 共享的问题。 -
就绪标志 →
apps/web/src/lib/session-readiness.ts,一个useSessionReadinesshook。 最后一个,也是这组中唯一不是文件移动的 PR:就绪标志(Arquiteto/PO 按钮上 RN-160/RN-161 的关口)是由位于SessionPage.tsx的状态和 props 内联计算的,所以抽取它们意味着编写一个具有明确输入/输出契约的新 hook,而不是挪动一个现有的独立函数或组件。它排在最后,因为它是五个中风险最高的——hook 边界可能改变某个东西何时被重新计算,即使公式不变——而到 PR 5 时,其他四个已经把文件缩小到足以单独审查这一风险。
SessionPage.module.css 的共享问题,一次性回答
SessionPage.module.css 如今恰好只有一个导入者——SessionPage.tsx 本身(已核实:在 apps/web/src 中、测试文件之外 grep 该说明符,只返回一个结果)。这意味着 PR 2–4 可以让新抽取出的同级文件(StorySlide.tsx、StructuredQuestionCard.tsx、ContextAside.tsx)直接导入 ../routes/SessionPage.module.css,与 SessionPage.tsx 今天的做法一样——不拆分 CSS,不新建共享样式表,也不存在两份副本分叉的风险,因为从来就没有第二份。这是一个值得在这里写一次的决定,而不必在三次独立的 PR 审查中反复讨论。
明确不在整个五 PR 工作范围之内
有两样东西本 ADR 和这五个 PR 都不触碰:
- 轮次通道的状态分组:
turnoViaCanal、statusAgent、pensandoVisivel、atividadeDoTurno。这四个状态被四个不同的轮次入口 handler(handleSend、handleReadiness、handleArchitectureReadiness、handleAcceptHandoff)读和写,并被finalizarTurnoDoAgente以闭包捕获,代码中的注释称其为"o ÚNICO lugar que finaliza um turno"——这四个 handler 每一个都会打开turnoViaCanal,而只有finalizarTurnoDoAgente会关闭它。它不是一棵连续的 JSX 子树,也不是一组纯函数;它是交织在一起的控制流,移动它是关于轮次生命周期状态应放在哪里的设计决策,而不是机械搬迁。当这项工作被界定时,它会有自己的、之后的、编号独立的 ADR。 apps/web/src/routes/ProjectSettingsTab.tsx(约 90 KiB,backlog 同一行中提到的另一个文件)。它更小,已经有自己的第一步在进行(文件内的具名函数,尚未抽取到模块),并且是一个独立的范围决策——本 ADR 只覆盖SessionPage.tsx。
后果
- 五个 PR 合入后,两个人可以在
SessionPage.tsx的不同角落工作,重叠更少——与 ADR 0121 为 schema 拆分描述的收益相同,只是规模更小(抽取五个单元,而不是十六个文件)。 - 这一组中每个 PR 的验收标准都相同:可观察行为零变化,由同样的 25 个
SessionPage.*.test.tsx文件在每个 PR 之后不经编辑通过来证明。这一组中如果某个 PR 需要编辑这 25 个文件之一才能通过,它实际上就不是机械的,应当停下来,而不是去"修"测试。 - 轮次通道分组和
ProjectSettingsTab.tsx仍与之前一样有争议。 本 ADR 不降低它们的风险,也不声称降低——在这里点名它们,才让切分保持为声明的,而不是被悄悄收窄。将来触及轮次通道分组的 PR 需要一个自己的、新编号的 ADR 并引用本 ADR;它不修补本 ADR,依据已接受的 ADR 永不编辑这一长期规则。 lib/得到它的第一批带有类似TimelineEntry形式的 JSX 邻近类型的文件(PR 1 的TimelineEntry有一个ReactNode字段),却没有得到它的第一个.tsx源文件——正是agruparNarracoesDoTurno留在SessionPage.tsx中,才在 PR 1 中保住了这一性质。PR 2 才是有意地、一次性地、经过自己的审查用掉这个先例的那一个。SessionPage.tsx分五个可见的步骤缩小(3 807 → 每个 PR 更小),而不是一个大 diff——更容易审查,但也意味着该文件在四个 PR 的时间里处于部分分解的状态,这在此被接受为不冻结活跃功能工作的代价。
被放弃的替代方案
- 一个大 PR 一次性移动所有内容。 被拒绝:该文件处于活跃修改中(50 个提交,最后 15 个大多是功能新增),所以单个 PR 要么冻结
SessionPage.tsx上的所有其他工作直到合并,要么积累合并冲突的速度快过它能被审查的速度。拆成五个可独立合并的 PR,让正常的功能工作可以在它们之间继续。 - 在 PR 1 中把
agruparNarracoesDoTurno与其他五个一起移动,因为它位于源码的同一区域(第 281–345 行)且测试方式相同。被拒绝:它返回 JSX 并读取styles.narracoes*,所以移动它会悄悄决定lib/接受.tsx源文件——一个值得单独审查的真实先例,而不是一个被描述为"纯函数移动"的 PR 的副作用。 - 把轮次通道分组的抽取作为同一计划的一部分,因为它是文件中另一大块状态。直接被拒绝:它是四个 handler 之间耦合的控制流,不是测试套件已经划出的边界,而且代码中的注释已经把它点名为唯一需要保持同步的地方——这正是本 ADR"机械的、可证明行为中立"的验收标准无法诚实声称覆盖的那类改动。