Prompt to reload when the Host release changes instead of hot-replacing across releases
Host release 变化时提示刷新页面,而不是跨 release 热替换
After `dsh web` is upgraded, or after a developer restarts `pnpm run dev:web` at another commit, an open browser page reconnects to the new Host automatically while its static shell stays at the old release. When the protocol is no longer compatible, the page keeps failing to reconnect or shows errors, and the user is not told that a reload is required. Technical causes: - After a reconnect, [client-hmr](../../../../
English
Problem
After dsh web is upgraded, or after a developer restarts pnpm run dev:web at another commit, an open browser page reconnects to the new Host automatically while its static shell stays at the old release. When the protocol is no longer compatible, the page keeps failing to reconnect or shows errors, and the user is not told that a reload is required.
Technical causes:
- After a reconnect, client-hmr receives the new Host's complete graph, and ClientEntries hot-replaces plugins by each row's
rev(the bundle's mtime, ctime, and size). The Vite shell, static libraries, and the bootstrap are never replaced during the page's lifetime. - The API connection validates its ready frame against an exact key set (remote-events.ts), so an old page cannot recover from a protocol change by itself.
- The page has no way to learn that the Host now runs another release.
- Desktop is unaffected: after its Host is replaced, the main process navigates the window again (main.ts).
The client plugin loading model owns the loading chain and hot replacement. It states that static libraries and the shell require a reloaded page; this note owns how the page learns that the Host's release changed and what it does about it.
Decision
A release is the Host's version identity: a version plus an optional source commit. The name deliberately differs from "build", which names the build process and its artifacts.
The change touches three owners: client-modules (producing, publishing, and comparing the release identity), the shared client build preset (fixing the commit into the Host artifact), and ui-settings-general (the confirmation dialog). web-app and client-hmr are unchanged.
Behavior
A page applies Host graphs and rebuilt frames only while the connected Host serves the release the page booted from. Every module the page runs therefore belongs to its boot release: either the boot artifacts unchanged, or the boot artifacts plus hot replacements published by a Host of that same release.
| Situation | Behavior |
|---|---|
| The Host serves the boot release; its graph equals the applied graph | Nothing happens. |
| The Host serves the boot release; some module revisions differ | Hot replacement of exactly the changed modules, as before; a shown dialog closes. |
| The Host serves another release (version or commit differs) | Nothing from it is applied, rebuilt frames are ignored, and a dialog shows the Host's and the page's transition. |
| The user chooses Reload to load the new version | location.reload() boots the page from the Host's release. |
| The user chooses Not now, the close control, a mask click, or Escape | The dialog closes; the page stays at its boot release and applies nothing from that Host release; later graphs of that release no longer ask. |
| The Host returns to the boot release | The dialog closes and the usual revision comparison replaces exactly the changed modules; unchanged modules stay. |
| The Host moves to a release other than the dismissed one | The dialog appears again. |
The page remembers only the last dismissed Host release, in page memory; nothing is written to localStorage or IndexedDB. After a reload, the new boot release is the comparison basis.
Release identity
- Fields:
{ version, commit? }; two releases are the same only when both fields are equal. Every complete build, released builds included, records the repository commit, so pages compare both fields; a release changes both together. versionis client-modules' own package.json version. All packages release under one version, so it equals the CLI version.commitis fixed at build time. The shared client build preset tsdown.client.ts injects the same publicprocess.env.DSH_CLIENT_*values into the Node half of every client package that it injects into the browser half (clientBuildValueDefinesreplaces the named variables and leavesprocess.envitself in place). The client-modules Host code readsprocess.env.DSH_CLIENT_COMMIT_HASH, which is a constant in the built artifact.- A Host started from TypeScript source has no build step:
pnpm run dev:webplaces the build values in the Host process environment, so the Host reads the same commit; a source launch without dev-web publishes the version alone. - Field values are wire data that the dialog renders verbatim, without localization.
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 computes the release once at construction and
compose()writes it into every graph;releasestays outsidegraph.rev. There is no new config field and the composition is unchanged. __DSH_BOOT__and every SSEgraphframe carry the field, so one graph frame carries both the Host release and its complete module graph; the client-hmr Host half is unchanged. A rebuilt frame comes from the same Host process as the graph frame before it on that SSE connection, so it inherits that frame's release.parseBootManifestaccepts an absentrelease, rejects a non-stringversionorcommit, and drops members outside the identity, so equality compares only those two fields.readReleasereads only thereleasemember of a received graph, so a page compares releases before it validates anything else; a release it cannot read counts as absent. Older pages that do not know the field ignore it.
Page side
The release comparison lives in release-change.ts; ClientEntries consults it from sync and 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
}
Order of operations:
ClientEntriescreates the gate from the boot manifest'srelease.sync(graph)reads the graph's release withreadReleaseand passes it toobservebefore parsing the rest, so a graph of another release is announced even when this page cannot read its format. A graph of the boot release is parsed and reconciles as before, and the existing revision comparison replaces exactly the changed modules. A graph of another release returns without entering the reconciliation queue; its first appearance logs the Host's previous and current identities throughconsole.warnand publishes the change unless that release was the last one dismissed.reload(id, rev)(a rebuilt frame) returns at once while the Host serves another release.retry()keeps reconciling the last applied graph, which always belongs to the boot release.- The UI's inject face reloads the page through
location.reload();ClientEntriesdoes not navigate.
UI
- Owner: ui-settings-general, which already owns the connection pill (
ConnectionIndicator) and the Desktop update prompt; itsinjectaddsmodules. - It registers the
shell.overlayentryhost-release-changethroughctx.slots.inject('shell.overlay', ...)and renders it withModal. The inject face provideshooks: { releaseChange: ctx.modules.entries.releaseChange },dismissRelease, andreloadPage. - Copy lives in the zh/en locale dictionaries: a title, one sentence stating that live updates are paused until a reload, and two gray-labelled lines:
- Service:
previous(gray) →host(solid), joined by a 1px solid green arrow because the Host already switched. - Page:
page(solid) →host(gray), joined by a 1px dashed gray arrow because only a reload completes the switch. Both connectors are a fixed 56px and do not stretch across the dialog. - Each line shows versions when its two ends differ in version and commits otherwise; a release or field the page cannot read, including one the Host did not publish, shows Unknown. Screen readers read the two lines as text; the arrows are decorative.
- Service:
- The buttons are Not now, which carries
data-modal-autofocus, and Reload to load the new version (primary). The dialog opens without a user gesture, so initial focus keeps the page and an Enter typed into the composer cannot reload it and discard a draft. The close control, a mask click, and Escape act as Not now through the sharedModalclose behavior.
Alternatives considered
- Call
location.reload()automatically: it can discard unsaved input, and the user should see and confirm the reload. - Carry the identity in the API ready frame (
ConnectionHostInfo): when the protocol is incompatible, the ready frame itself may fail to parse, so detection would never run; pausing hot replacement would still belong toClientEntries. - Let web-app compose the identity (CLI version + web-app version + commit) and pass it to modules through the
webAppservice and the composition: it changes web-app's service interface, tests, docs, and the generated API catalog, and makes modules wait for web-runtime activation; the two versions release together, so reading them separately adds nothing. - Add a client-modules
commitconfig field set from!!js process.env.DSH_CLIENT_COMMIT_HASHin the composition: it reads the environment at run time, and releaseddsh weblaunches do not set the variable; the commit must be fixed at build time. - Read the CLI version by depending on app-boot from client-modules: it adds a dependency edge, a dependency-policy classification, a tsconfig reference, and a lockfile change, while the package's own version is the same number.
- Keep the decision logic inside
ClientEntries: reconciliation and release comparison are independent concerns; the separate gate keepsClientEntriesto a few added lines. - Let Not now apply the other release's plugin updates: the page would run plugins of one release on the shell and static libraries of another, its state could no longer be named by one release, and returning to the boot release would need a second prompt whose two choices end in the same state.
- Name the field
build: it is easily confused with the build process and build artifacts. - Reopen a permanently closed SSE stream in client-hmr: in end-to-end restarts the 404 window never appeared, because the browser's own retry covered connection refusals; the gap is a documented limitation.
- Store the decision in localStorage: the decision concerns one page's code, and a reload replaces that code, so it should not outlive the page.
- Remember every dismissed release: a page that moves between several Host releases is rare, and asking again after another release appeared costs one click.
- Keep the identity an opaque string and let the page split version and commit: the display rule needs the two parts separately, and parsing a concatenated string breaks whenever its format changes.
- Create a separate UI package: it needs three more registrations, while ui-settings-general already owns connection state and update prompts.
Consequences
- An upgraded Host or a dev Host at another commit never hot-replaces plugin code into a page of another release; the page keeps working at its own release until the user reloads.
- While the Host serves another release, plugin updates, including enabling or disabling a plugin on the Host, reach the page only after a reload.
- When a Host returns to the page's release, only modules whose revisions differ are replaced. Revisions come from artifact metadata, so a rebuild of identical code still replaces the rebuilt modules once, without a prompt.
- A composition without the dialog registered still pauses updates from another release and logs the warning; the user sees no prompt.
- The API connection and SSE are independent channels, so an old page can show a protocol error before the dialog appears.
- If a restarting Host answers
/plugins/eventswith a non-200 status before registering the route, the browser closes that SSE stream for good, and the page cannot detect a release change until it reloads. - A source launch without dev-web has no commit, so development detection falls back to version changes.
- Each tab prompts and remembers independently.
- Unit tests pin the gate through
ClientEntries(same-release replacement, another release applying nothing, the return to the boot release replacing only changed modules, dismissal, absent releases), the manifest parser, the Host-published release, the shared preset's Node-half defines, and the dialog's rendering and callbacks.
中文
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,以及确认框的渲染与回调。