13 KiB
13 KiB
Agent Note: Host release 变化时提示刷新页面,而不是跨 release 热替换
Status: implemented
English | 中文
Problem
dsh web 升级后,或开发者在另一个 commit 上重启 pnpm run dev:web 后,已打开的浏览器页面会自动重连到新 Host,但页面的静态 shell 仍停在旧 release。协议一旦不兼容,页面会一直重连失败或报错,用户也不知道需要刷新。
技术原因:
- 重连后,client-hmr 会收到新 Host 的完整 graph,ClientEntries 按每行的
rev(bundle 的 mtime、ctime 和大小)热替换插件。Vite shell、静态库和 bootstrap 在页面生命周期内从不替换。 - API 连接按精确键集合校验 ready 帧(remote-events.ts),协议一变,旧页面无法自行恢复。
- 页面没有任何途径得知 Host 已换成另一个 release。
- Desktop 不受影响:Host 被替换后,main 进程会重新导航窗口(main.ts)。
client plugin loading model 负责加载链与热替换,它写明静态库和 shell 需要刷新页面;本 Note 负责页面如何得知 Host 的 release 变了,以及之后怎么处理。
Decision
release 指 Host 的版本身份:版本号加可选的源码 commit。名字刻意区别于 build,后者指构建过程和构建产物。
改动落在三处:client-modules(产生、下发和比较 release 身份)、共享 client 构建预设(把 commit 固化进 Host 产物)、ui-settings-general(确认框)。web-app 和 client-hmr 不改。
行为
只有当前连接的 Host 提供的是页面启动时的 release,页面才应用 Host 的 graph 和 rebuilt 帧。所以页面运行的每个模块都属于启动 release:要么是原封不动的启动产物,要么是启动产物加上同一 release 的 Host 发布的热替换。
| 场景 | 行为 |
|---|---|
| Host 提供启动 release,graph 与已应用的相同 | 什么都不做。 |
| Host 提供启动 release,部分模块 revision 不同 | 照常只热替换变化了的模块;已弹出的对话框关闭。 |
| Host 提供另一个 release(版本号或 commit 不同) | 不应用它的任何内容,忽略 rebuilt 帧,对话框显示 Host 和页面的版本变化。 |
| 用户选择刷新以加载新版本 | location.reload() 以 Host 的 release 启动页面。 |
| 用户选择暂不刷新、关闭按钮、点击遮罩或 Escape | 对话框关闭;页面保持启动 release,不应用该 Host release 的任何内容;该 release 的后续 graph 不再提示。 |
| Host 回到启动 release | 对话框关闭,照常按 revision 对比,只替换变化了的模块,没变的保持不动。 |
| Host 换到被撤回的那个以外的 release | 再次弹出对话框。 |
页面只在内存里记住最近一次撤回的 Host release,不写 localStorage 或 IndexedDB。刷新后以新的启动 release 为比较基准。
Release 身份
- 字段:
{ version, commit? },两个字段都相等才算同一 release。每次完整构建(包括正式发布)都会记录仓库 commit,所以页面比较的是两个字段;发布新版本时两者一起变。 version取 client-modules 自己 package.json 的版本。所有包同一版本号发布,所以它等于 CLI 版本。commit在构建时固化。共享 client 构建预设 tsdown.client.ts 给每个 client 包的 Node 半侧注入与浏览器半侧相同的公开process.env.DSH_CLIENT_*取值(clientBuildValueDefines只替换具体变量,保留process.env本身)。client-modules 的 Host 代码读process.env.DSH_CLIENT_COMMIT_HASH,在构建产物里是常量。- 从 TypeScript 源码启动的 Host 没有构建这一步:
pnpm run dev:web把构建取值放进 Host 进程环境,因此读到的是同一个 commit;不经 dev-web 的源码启动只发布版本号。 - 字段值属于 wire 数据,对话框原样显示,不做本地化。
Wire
WebBootRelease { version: string; commit?: string }
WebBootGraph {
rev; entries; batches // unchanged
/** Host release identity; absent from a Host that publishes none. */
release?: WebBootRelease
}
BootManifest { ...; release?: WebBootRelease }
- ClientModuleRegistry 在构造时计算一次 release,由
compose()写入每个 graph;release不计入graph.rev。不新增配置项,组合配置不变。 __DSH_BOOT__和每个 SSEgraph帧都带这个字段,所以一个 graph 帧同时携带 Host 的 release 和完整模块图;client-hmr 的 Host 半侧不变。rebuilt 帧与同一条 SSE 连接上前一个 graph 帧来自同一个 Host 进程,因此沿用那一帧的 release。parseBootManifest允许release缺省,拒绝非字符串的version或commit,并丢弃身份以外的成员,因此相等判断只看这两个字段。readRelease只读取收到的 graph 的release成员,所以页面先比较 release,再校验其余内容;读不出的 release 视为缺省。不认识该字段的旧页面会忽略它。
页面侧
release 比较在 release-change.ts;ClientEntries 在 sync 和 reload 中调用它。
interface ClientReleaseChange {
/** Release this page booted from; every module the page runs belongs to it. */
readonly page: WebBootRelease | undefined
/** Release the Host served before its latest release change. */
readonly previous: WebBootRelease | undefined
/** Release of the connected Host. */
readonly host: WebBootRelease | undefined
}
class ReleaseChangeGate {
/** Release change to announce; undefined while the Host serves the boot release or a dismissed one. */
readonly change: ObservableSnapshot<ClientReleaseChange | undefined>
/** Whether the connected Host serves the boot release; graphs and rebuilt frames apply only then. */
get matches(): boolean
/** Record a received graph's release and update the announcement; true when the graph may be applied. */
observe(release: WebBootRelease | undefined): boolean
/** Stop announcing the connected Host's release. */
dismiss(): void
}
class ClientEntries {
/** The gate's change. */
readonly releaseChange: ObservableSnapshot<ClientReleaseChange | undefined>
/** Stop announcing the connected Host's release; updates stay paused until the Host returns or the page reloads. */
dismissRelease(): void
}
调用顺序:
ClientEntries以启动 manifest 的release创建 gate。sync(graph)先用readRelease读出 graph 的 release 交给observe,再解析其余部分,所以即使页面读不懂另一个 release 的格式也会弹框。启动 release 的 graph 经过解析后照常调和,原有的 revision 对比只替换变化了的模块。其他 release 的 graph 直接返回,不进入调和队列;它首次出现时用console.warn记录 Host 之前和当前的身份并发布变化,除非它就是最近一次撤回的 release。- Host 提供其他 release 期间,
reload(id, rev)(rebuilt 帧)立即返回。 retry()继续调和上次应用的 graph,它始终属于启动 release。- UI 的 inject 面通过
location.reload()刷新页面;ClientEntries不负责导航。
UI
- 归属:ui-settings-general,它已经拥有连接状态药丸(
ConnectionIndicator)和 Desktop 更新提示;其inject增加modules。 - 它通过
ctx.slots.inject('shell.overlay', ...)注册shell.overlay条目host-release-change,用Modal渲染。inject 面提供hooks: { releaseChange: ctx.modules.entries.releaseChange }、dismissRelease和reloadPage。 - 文案在中英 locale 字典里:一个标题、一句说明热更新已暂停直到刷新,以及两行灰色标签的版本:
- 服务:
previous(灰)→host(实色),1px 绿色实线箭头,表示 Host 已经切换。 - 页面:
page(实色)→host(灰),1px 灰色虚线箭头,表示刷新后才会切换。两条连接线固定 56px,不撑满对话框。 - 每行两端版本号不同时显示版本号,否则显示 commit;页面读不出的 release 或字段(包括 Host 没发布的)显示未解析。读屏把两行当文字读,箭头只是装饰。
- 服务:
- 按钮为暂不刷新(带
data-modal-autofocus)和刷新以加载新版本(主按钮)。对话框不是用户操作打开的,初始焦点落在保留页面一侧,用户在输入框里按下的回车不会刷新页面、丢掉草稿。关闭按钮、点击遮罩和 Escape 通过共用的Modal关闭行为等同暂不刷新。
Alternatives considered
- 自动调用
location.reload():可能丢失尚未保存的输入,而且应当让用户看到并确认刷新。 - 把身份放进 API ready 帧(
ConnectionHostInfo):协议不兼容时 ready 帧本身就可能解析失败,检测根本不会发生;暂停热替换仍然属于ClientEntries。 - 由 web-app 拼身份(CLI 版本 + web-app 版本 + commit),通过
webApp服务和组合配置交给 modules:要改 web-app 的服务接口、测试、文档和生成的 API 清单,还让 modules 等待 web-runtime 激活;两个版本号一起发布,分开读取没有区别。 - 给 client-modules 加
commit配置项,在组合配置里写!!js process.env.DSH_CLIENT_COMMIT_HASH:运行时读环境变量,而正式发布的dsh web不设置这个变量;commit 必须在构建时固化。 - client-modules 依赖 app-boot 读取 CLI 版本:要新增依赖边、依赖策略分类、tsconfig 引用和 lockfile 改动,而本包自己的版本就是同一个数。
- 把判断逻辑留在
ClientEntries里:调和与 release 比较是两件独立的事;拆出 gate 后ClientEntries只多几行。 - 选择暂不刷新后应用另一个 release 的插件更新:页面会在一个 release 的 shell 和静态库上运行另一个 release 的插件,其状态无法再用一个 release 描述;回到启动 release 时还得再问一次,而两个选择的结果完全相同。
- 字段命名为
build:容易和构建过程、构建产物混淆。 - 在 client-hmr 里重开被永久关闭的 SSE:端到端重启测试中从未出现 404 窗口,浏览器自带的重试已覆盖连接被拒;这个缺口记为已知限制。
- 把选择写进 localStorage:这个选择只关乎一个页面的代码,而刷新会替换这些代码,所以不应比页面活得更久。
- 记住每一个撤回过的 release:页面在多个 Host release 之间来回切换的情况很少,出现其他 release 后再问一次只多一次点击。
- 身份保持一个不透明字符串,由页面拆出版本号和 commit:显示规则需要分开这两部分,从拼接字符串里拆解,格式一变就会出错。
- 新建一个独立 UI 包:要多做三处注册,而 ui-settings-general 已经拥有连接状态和更新提示。
Consequences
- 升级后的 Host 或换了 commit 的开发 Host,永远不会把插件代码热替换进另一个 release 的页面;页面按自己的 release 继续工作,直到用户刷新。
- Host 提供其他 release 期间,插件更新(包括在 Host 上启用或停用插件)要等刷新后才会到达页面。
- Host 回到页面的 release 时,只替换 revision 不同的模块。revision 取自产物元数据,所以即使重新构建出的代码完全相同,也会把重建过的模块替换一次,不弹框。
- 组合里没有注册确认框时,仍会暂停来自其他 release 的更新并记录警告,用户看不到提示。
- API 连接和 SSE 是两条独立通道,旧页面可能在确认框出现之前就已显示协议错误。
- 重启中的 Host 若在注册路由前对
/plugins/events返回非 200 状态,浏览器会永久关闭这条 SSE,页面在刷新前检测不到 release 变化。 - 不经 dev-web 的源码启动没有 commit,开发期只能检测到版本号变化。
- 每个标签页各自提示、各自记录。
- 单元测试通过
ClientEntries固定 gate 的行为(同一 release 的替换、其他 release 不应用任何内容、回到启动 release 只替换变化了的模块、撤回、缺失 release),并覆盖 manifest 解析、Host 发布的 release、共享预设给 Node 半侧的 define,以及确认框的渲染与回调。