feat(client-modules): apply no plugin update from another host release
This commit is contained in:
1 parent
ac5a3e336c
commit
f52abc04fa
20 files changed
+303
-292
No files matched your search
+13
-13
@@ -12,23 +12,23 @@
|
||||
en: d39be9788fe2a0c9
|
||||
zh: 1a68a3ae4d73930d
|
||||
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/behavior:
|
||||
en: 4c39cb393c7ac431
|
||||
zh: 9c74fef799c008fa
|
||||
en: f85ebe23d677ae93
|
||||
zh: a829e0692e711660
|
||||
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/release-identity:
|
||||
en: 1b6efaf08ab3dabc
|
||||
zh: a4c8152793542fd4
|
||||
zh: 96330f89144c5dbd
|
||||
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/wire:
|
||||
en: 5fb50fc66b231ada
|
||||
zh: 28a72c2b8bdd08e6
|
||||
en: 3e99fce22c1e52f4
|
||||
zh: 5c1b5b82fed001f8
|
||||
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/page-side:
|
||||
en: 820a19ae80b31ca5
|
||||
zh: 0f0df1fa646de36f
|
||||
en: ffe1d95f71f1f4da
|
||||
zh: ba377dac4bd3a517
|
||||
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/decision/ui:
|
||||
en: c1780e99dbac5513
|
||||
zh: fa53d0867820bde2
|
||||
en: 90bc33052e84f162
|
||||
zh: 749bad3e829698e2
|
||||
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/alternatives-considered:
|
||||
en: 999b2753fbf7e398
|
||||
zh: 4b5dc90e347db66f
|
||||
en: abb9d7ab9ef1df15
|
||||
zh: 1704732daf5f2886
|
||||
/agent-note-prompt-to-reload-when-the-host-release-changes-instead-of-hot-replacing-across-releases/consequences:
|
||||
en: cd683709b4c8f293
|
||||
zh: 0d86c525934d5820
|
||||
en: 815b41dc4f9ffcee
|
||||
zh: b57388264cc459c7
|
||||
+41
-38
@@ -25,16 +25,19 @@ The change touches three owners: client-modules (producing, publishing, and comp
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
| Reconnect to the same release | Unchanged: plugins hot-replace by `rev`. |
|
||||
| Reconnect to another release | Plugin replacement pauses and a dialog shows the service and page version changes. |
|
||||
| The user chooses **Reload to load the new version** | `location.reload()` loads the new shell and plugins from the new Host. |
|
||||
| The user chooses **Not now**, the close control, a mask click, or Escape | The dialog closes, the new release becomes the page's current release, and hot replacement resumes from the latest graph. |
|
||||
| Reconnect to the current release later | No prompt; plugins hot-replace directly. |
|
||||
| Reconnect to any release other than the current one, the boot release included | The dialog appears again. |
|
||||
| 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 one current release, the release of the latest graph it applied, in page memory only; nothing is written to localStorage or IndexedDB. After a reload, the new boot release is the current release.
|
||||
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
|
||||
|
||||
@@ -57,59 +60,57 @@ BootManifest { ...; release?: WebBootRelease }
|
||||
```
|
||||
|
||||
- [ClientModuleRegistry](../../../../packages/client/modules/src/index.ts) computes the release once at construction and `compose()` writes it into every graph; `release` stays outside `graph.rev`. There is no new config field and the composition is unchanged.
|
||||
- `__DSH_BOOT__` and every SSE `graph` frame carry the field; the client-hmr Host half is unchanged.
|
||||
- `__DSH_BOOT__` and every SSE `graph` frame 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.
|
||||
- `parseBootManifest` accepts an absent `release`, rejects a non-string `version` or `commit`, and drops members outside the identity, so equality compares only those two fields. Older pages that do not know the field ignore it.
|
||||
|
||||
### Page side
|
||||
|
||||
The decision logic lives in [release-change.ts](../../../../packages/client/modules/src/client/release-change.ts); `ClientEntries` consults it from `sync`, `reload`, and `acceptRelease`.
|
||||
The release comparison lives in [release-change.ts](../../../../packages/client/modules/src/client/release-change.ts); `ClientEntries` consults it from `sync` and `reload`.
|
||||
|
||||
```
|
||||
interface ClientReleaseChange {
|
||||
/** Release this page booted from; its shell and static modules stay at it until a reload. */
|
||||
/** Release this page booted from; every module the page runs belongs to it. */
|
||||
readonly page: WebBootRelease | undefined
|
||||
/** Release of the latest graph this page applied: its boot release or an accepted one. */
|
||||
readonly current: WebBootRelease | undefined
|
||||
/** Release of the latest Host graph. */
|
||||
readonly next: 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 {
|
||||
/** Pending release change; undefined while no change is pending. */
|
||||
/** Release change to announce; undefined while the Host serves the boot release or a dismissed one. */
|
||||
readonly change: ObservableSnapshot<ClientReleaseChange | undefined>
|
||||
/** Whether a graph of another release is held. */
|
||||
get holding(): boolean
|
||||
/** Pass a graph of the current release; hold any other one and announce each new release once. */
|
||||
admit(manifest: BootManifest): boolean
|
||||
/** Release the held graph; undefined when no change is pending. */
|
||||
accept(): BootManifest | undefined
|
||||
/** Record an applied graph as the current release and withdraw the pending change. */
|
||||
applied(manifest: BootManifest): void
|
||||
/** 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>
|
||||
/** Accept the pending release and apply its latest graph; resolves at once without a pending change. */
|
||||
acceptRelease(): Promise<void>
|
||||
/** Stop announcing the connected Host's release; updates stay paused until the Host returns or the page reloads. */
|
||||
dismissRelease(): void
|
||||
}
|
||||
```
|
||||
|
||||
Order of operations:
|
||||
|
||||
1. `ClientEntries` creates the gate from the boot manifest's `release`.
|
||||
2. `sync(graph)` parses the graph and passes it to `admit`. A graph of the current release reconciles as before. A graph of another release is held; its first appearance logs the old and new identities through `console.warn` and publishes the change without entering the reconciliation queue. Later graph frames of the held release replace the held graph without publishing again; `retry()` keeps reconciling the last applied graph.
|
||||
3. While a graph is held, `reload(id, rev)` (a rebuilt frame) is ignored, because every Host rebuild is followed by a complete graph frame.
|
||||
4. When a graph of the current release arrives (including a Host that returned to it), or `acceptRelease()` takes the held graph, `ClientEntries` calls `applied` and reconciles as usual.
|
||||
2. `sync(graph)` parses the graph and passes its release to `observe`. A graph of the boot release 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 through `console.warn` and publishes the change unless that release was the last one dismissed.
|
||||
3. `reload(id, rev)` (a rebuilt frame) returns at once while the Host serves another release.
|
||||
4. `retry()` keeps reconciling the last applied graph, which always belongs to the boot release.
|
||||
5. The UI's inject face reloads the page through `location.reload()`; `ClientEntries` does not navigate.
|
||||
|
||||
### UI
|
||||
|
||||
- Owner: [ui-settings-general](../../../../packages/client/ui-settings-general/src/client/index.ts), which already owns the connection pill (`ConnectionIndicator`) and the Desktop update prompt; its `inject` adds `modules`.
|
||||
- It registers the `shell.overlay` entry `host-release-change` through `ctx.slots.inject('shell.overlay', ...)` and renders it with `Modal`. The inject face provides `hooks: { releaseChange: ctx.modules.entries.releaseChange }`, `acceptRelease`, and `reloadPage`.
|
||||
- Copy lives in the zh/en locale dictionaries. The title is **Service version changed** (服务版本变化); the description is "After the connection was re-established, versions changed as follows." (连接重新建立后版本信息变化如下). Two gray-labelled lines follow:
|
||||
- **Service**: `current` (gray) → `next` (solid), joined by a 1px solid green arrow because the service already switched.
|
||||
- **Page**: `page` (solid) → `next` (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.
|
||||
- It registers the `shell.overlay` entry `host-release-change` through `ctx.slots.inject('shell.overlay', ...)` and renders it with `Modal`. The inject face provides `hooks: { releaseChange: ctx.modules.entries.releaseChange }`, `dismissRelease`, and `reloadPage`.
|
||||
- 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 Host did not publish shows **Not labeled**. Screen readers read the two lines as text; the arrows are decorative.
|
||||
- The buttons are **Not now** and **Reload to load the new version** (primary, with `data-modal-autofocus`). The close control, a mask click, and Escape act as **Not now** through the shared `Modal` close behavior.
|
||||
|
||||
@@ -121,20 +122,22 @@ Order of operations:
|
||||
- **Add a client-modules `commit` config field set from `!!js process.env.DSH_CLIENT_COMMIT_HASH` in the composition**: it reads the environment at run time, and released `dsh web` launches 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 keeps `ClientEntries` to 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 a set of accepted releases (the boot release plus every accepted one)**: after accepting B, a Host back on boot release A would not prompt although the page runs B's plugins; one current release is simpler and accurate.
|
||||
- **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 no longer hot-replaces plugin code into a page whose shell belongs to another release without asking; the user decides when to reload.
|
||||
- **Not now** keeps hot-replacing across releases, with the same risk as before this change, by the user's choice.
|
||||
- A composition without the dialog registered only pauses hot replacement and logs the warning; the user sees no prompt.
|
||||
- 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/events` with a non-200 status before registering the route, the browser closes that SSE stream for good, and the page cannot detect the release change until it reloads.
|
||||
- If a restarting Host answers `/plugins/events` with 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's behavior through `ClientEntries`, the manifest parser, the Host-published release, the shared preset's Node-half defines, and the dialog's rendering and callbacks; the end-to-end restart scenario was verified against a real Host.
|
||||
- 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.
|
||||
+41
-38
@@ -25,16 +25,19 @@ release 指 Host 的版本身份:版本号加可选的源码 commit。名字
|
||||
|
||||
### 行为
|
||||
|
||||
只有当前连接的 Host 提供的是页面启动时的 release,页面才应用 Host 的 graph 和 rebuilt 帧。所以页面运行的每个模块都属于启动 release:要么是原封不动的启动产物,要么是启动产物加上同一 release 的 Host 发布的热替换。
|
||||
|
||||
| 场景 | 行为 |
|
||||
|---|---|
|
||||
| 重连到同一 release | 维持现状:按 `rev` 热替换。 |
|
||||
| 重连到另一个 release | 暂停插件替换,弹出确认框,显示服务和页面两条版本变化。 |
|
||||
| 用户选择**刷新以加载新版本** | `location.reload()` 从新 Host 加载新的 shell 和插件。 |
|
||||
| 用户选择**暂不刷新**、关闭按钮、点击遮罩或 Escape | 关闭确认框,新 release 成为页面的当前 release,并按最新 graph 恢复热替换。 |
|
||||
| 之后重连到当前 release | 不提示,直接热替换。 |
|
||||
| 重连到当前 release 以外的任何 release(含启动 release) | 再次弹出确认框。 |
|
||||
| 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 | 再次弹出对话框。 |
|
||||
|
||||
页面只记一个当前 release,即它最近一次应用的 graph 的 release,只存在页面内存里,不写 localStorage 或 IndexedDB。刷新后,新的启动 release 成为当前 release。
|
||||
页面只在内存里记住最近一次撤回的 Host release,不写 localStorage 或 IndexedDB。刷新后以新的启动 release 为比较基准。
|
||||
|
||||
### Release 身份
|
||||
|
||||
@@ -42,7 +45,7 @@ release 指 Host 的版本身份:版本号加可选的源码 commit。名字
|
||||
- `version` 取 client-modules 自己 package.json 的版本。所有包同一版本号发布,所以它等于 CLI 版本。
|
||||
- `commit` 在构建时固化。共享 client 构建预设 [tsdown.client.ts](../../../../packages/client/tsdown.client.ts) 给每个 client 包的 Node 半侧注入与浏览器半侧相同的公开 `process.env.DSH_CLIENT_*` 取值([`clientBuildValueDefines`](../../../../scripts/client-build-environment.ts) 只替换具体变量,保留 `process.env` 本身)。client-modules 的 Host 代码读 `process.env.DSH_CLIENT_COMMIT_HASH`,在构建产物里是常量。
|
||||
- 从 TypeScript 源码启动的 Host 没有构建这一步:`pnpm run dev:web` 把构建取值放进 Host 进程环境,因此读到的是同一个 commit;不经 dev-web 的源码启动只发布版本号。
|
||||
- 字段值属于 wire 数据,确认框原样显示,不做本地化。
|
||||
- 字段值属于 wire 数据,对话框原样显示,不做本地化。
|
||||
|
||||
### Wire
|
||||
|
||||
@@ -57,59 +60,57 @@ BootManifest { ...; release?: WebBootRelease }
|
||||
```
|
||||
|
||||
- [ClientModuleRegistry](../../../../packages/client/modules/src/index.ts) 在构造时计算一次 release,由 `compose()` 写入每个 graph;`release` 不计入 `graph.rev`。不新增配置项,组合配置不变。
|
||||
- `__DSH_BOOT__` 和每个 SSE `graph` 帧都带这个字段;client-hmr 的 Host 半侧不变。
|
||||
- `__DSH_BOOT__` 和每个 SSE `graph` 帧都带这个字段,所以一个 graph 帧同时携带 Host 的 release 和完整模块图;client-hmr 的 Host 半侧不变。rebuilt 帧与同一条 SSE 连接上前一个 graph 帧来自同一个 Host 进程,因此沿用那一帧的 release。
|
||||
- `parseBootManifest` 允许 `release` 缺省,拒绝非字符串的 `version` 或 `commit`,并丢弃身份以外的成员,因此相等判断只看这两个字段。不认识该字段的旧页面会忽略它。
|
||||
|
||||
### 页面侧
|
||||
|
||||
判断逻辑在 [release-change.ts](../../../../packages/client/modules/src/client/release-change.ts);`ClientEntries` 在 `sync`、`reload` 和 `acceptRelease` 中调用它。
|
||||
release 比较在 [release-change.ts](../../../../packages/client/modules/src/client/release-change.ts);`ClientEntries` 在 `sync` 和 `reload` 中调用它。
|
||||
|
||||
```
|
||||
interface ClientReleaseChange {
|
||||
/** Release this page booted from; its shell and static modules stay at it until a reload. */
|
||||
/** Release this page booted from; every module the page runs belongs to it. */
|
||||
readonly page: WebBootRelease | undefined
|
||||
/** Release of the latest graph this page applied: its boot release or an accepted one. */
|
||||
readonly current: WebBootRelease | undefined
|
||||
/** Release of the latest Host graph. */
|
||||
readonly next: 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 {
|
||||
/** Pending release change; undefined while no change is pending. */
|
||||
/** Release change to announce; undefined while the Host serves the boot release or a dismissed one. */
|
||||
readonly change: ObservableSnapshot<ClientReleaseChange | undefined>
|
||||
/** Whether a graph of another release is held. */
|
||||
get holding(): boolean
|
||||
/** Pass a graph of the current release; hold any other one and announce each new release once. */
|
||||
admit(manifest: BootManifest): boolean
|
||||
/** Release the held graph; undefined when no change is pending. */
|
||||
accept(): BootManifest | undefined
|
||||
/** Record an applied graph as the current release and withdraw the pending change. */
|
||||
applied(manifest: BootManifest): void
|
||||
/** 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>
|
||||
/** Accept the pending release and apply its latest graph; resolves at once without a pending change. */
|
||||
acceptRelease(): Promise<void>
|
||||
/** Stop announcing the connected Host's release; updates stay paused until the Host returns or the page reloads. */
|
||||
dismissRelease(): void
|
||||
}
|
||||
```
|
||||
|
||||
调用顺序:
|
||||
|
||||
1. `ClientEntries` 以启动 manifest 的 `release` 创建 gate。
|
||||
2. `sync(graph)` 解析 graph 后交给 `admit`。当前 release 的 graph 照常调和。其他 release 的 graph 被暂存;它首次出现时用 `console.warn` 记录新旧身份并发布变化,不进入调和队列。暂存 release 的后续 graph 帧替换暂存的 graph,不再重复发布;`retry()` 继续调和上次应用的 graph。
|
||||
3. 暂存期间忽略 `reload(id, rev)`(rebuilt 帧),因为 Host 每次重建后都会紧跟一帧完整 graph。
|
||||
4. 收到当前 release 的 graph(含 Host 退回当前 release)时,或 `acceptRelease()` 取走暂存的 graph 时,`ClientEntries` 调用 `applied` 后照常调和。
|
||||
2. `sync(graph)` 解析 graph 后把它的 release 交给 `observe`。启动 release 的 graph 照常调和,原有的 revision 对比只替换变化了的模块。其他 release 的 graph 直接返回,不进入调和队列;它首次出现时用 `console.warn` 记录 Host 之前和当前的身份并发布变化,除非它就是最近一次撤回的 release。
|
||||
3. Host 提供其他 release 期间,`reload(id, rev)`(rebuilt 帧)立即返回。
|
||||
4. `retry()` 继续调和上次应用的 graph,它始终属于启动 release。
|
||||
5. UI 的 inject 面通过 `location.reload()` 刷新页面;`ClientEntries` 不负责导航。
|
||||
|
||||
### UI
|
||||
|
||||
- 归属:[ui-settings-general](../../../../packages/client/ui-settings-general/src/client/index.ts),它已经拥有连接状态药丸(`ConnectionIndicator`)和 Desktop 更新提示;其 `inject` 增加 `modules`。
|
||||
- 它通过 `ctx.slots.inject('shell.overlay', ...)` 注册 `shell.overlay` 条目 `host-release-change`,用 `Modal` 渲染。inject 面提供 `hooks: { releaseChange: ctx.modules.entries.releaseChange }`、`acceptRelease` 和 `reloadPage`。
|
||||
- 文案在中英 locale 字典里。标题为**服务版本变化**(Service version changed);正文为「连接重新建立后版本信息变化如下」(After the connection was re-established, versions changed as follows.)。下面是两行灰色标签的版本:
|
||||
- **服务**:`current`(灰)→ `next`(实色),1px 绿色实线箭头,表示服务已经切换。
|
||||
- **页面**:`page`(实色)→ `next`(灰),1px 灰色虚线箭头,表示刷新后才会切换。两条连接线固定 56px,不撑满对话框。
|
||||
- 它通过 `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;Host 没发布的 release 或字段显示**未标注**。读屏把两行当文字读,箭头只是装饰。
|
||||
- 按钮为**暂不刷新**和**刷新以加载新版本**(主按钮,带 `data-modal-autofocus`)。关闭按钮、点击遮罩和 Escape 通过共用的 `Modal` 关闭行为等同**暂不刷新**。
|
||||
|
||||
@@ -121,20 +122,22 @@ class ClientEntries {
|
||||
- **给 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 的集合(启动 release 加每个接受过的 release)**:接受 B 后,Host 退回启动 release A 时不再提示,但页面此时运行的是 B 的插件;只记一个当前 release 更简单,也更准确。
|
||||
- **记住每一个撤回过的 release**:页面在多个 Host release 之间来回切换的情况很少,出现其他 release 后再问一次只多一次点击。
|
||||
- **身份保持一个不透明字符串,由页面拆出版本号和 commit**:显示规则需要分开这两部分,从拼接字符串里拆解,格式一变就会出错。
|
||||
- **新建一个独立 UI 包**:要多做三处注册,而 ui-settings-general 已经拥有连接状态和更新提示。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 升级后的 Host 或换了 commit 的开发 Host,不会再不经询问就把插件代码热替换进 shell 属于另一个 release 的页面;何时刷新由用户决定。
|
||||
- 选择**暂不刷新**后会继续跨 release 热替换,风险与这次改动之前相同,由用户自行选择。
|
||||
- 组合里没有注册确认框时,只会暂停热替换并记录警告,用户看不到提示。
|
||||
- 升级后的 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 的行为,并覆盖 manifest 解析、Host 发布的 release、共享预设给 Node 半侧的 define,以及确认框的渲染与回调;端到端重启场景在真实 Host 上验证过。
|
||||
- 单元测试通过 `ClientEntries` 固定 gate 的行为(同一 release 的替换、其他 release 不应用任何内容、回到启动 release 只替换变化了的模块、撤回、缺失 release),并覆盖 manifest 解析、Host 发布的 release、共享预设给 Node 半侧的 define,以及确认框的渲染与回调。
|
||||
@@ -6,8 +6,8 @@
|
||||
en: e256a29cc5bc2726
|
||||
zh: e181d6b1a6e5c647
|
||||
/client-modules/the-wire:
|
||||
en: 5f889c13ff035ebd
|
||||
zh: ed35e2bcc1ed8924
|
||||
en: 96b95cdb015c1af6
|
||||
zh: 01e4144d91551c13
|
||||
/client-modules/the-scan:
|
||||
en: 91ce66fab388a8a2
|
||||
zh: 77428943582ae3f6
|
||||
|
||||
@@ -87,7 +87,7 @@ interface WebBootGraph {
|
||||
|
||||
Initial publication and HMR derive each row's `rev` from the entry's mtime, ctime, and size, without hashing executable bytes. The same artifacts retain their revisions across Host restarts. The initial descriptors partition rows into bootstrap and application scheduling phases, and either phase may contain several descriptors. Their URLs contain only the ordered package-resource list and a revision derived from those row revisions; phase names do not enter the route. Graph composition preserves row order while greedily splitting before the map-form URL exceeds 3 KiB, without concatenating scripts or reading maps. The graph revision hashes the entry and batch descriptors. `immediately` marks the stage-one registration barrier; rows within one combo share its script transport, while separate combos load independently.
|
||||
|
||||
`release` pairs this package's version with the source commit the client build fixes into the built Host artifact, and stays outside the graph revision. A page applies graphs only from the release it last applied, starting with its boot release; a graph from any other release waits for the page's decision ([Host release changes](../../packages/client/modules/README.md#host-release-changes)).
|
||||
`release` pairs this package's version with the source commit the client build fixes into the built Host artifact, and stays outside the graph revision. A page applies graphs and rebuilt frames only while the Host serves the page's boot release; another release waits for a page reload ([Host release changes](../../packages/client/modules/README.md#host-release-changes)).
|
||||
|
||||
## The scan
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ interface WebBootGraph {
|
||||
|
||||
首次发布与 HMR 都从入口的 mtime、ctime 和大小派生每个 row 的 `rev`,不对可执行字节求哈希。相同产物会跨 Host 重启保持 revision。初始 descriptor 把 row 划入 bootstrap 与 application 两个调度阶段,每个阶段都可以包含多条 descriptor。URL 只含有序 package 资源列表和从这些 row revision 派生的 revision,阶段名不会进入路由。图组合保持 row 顺序,并在 map 形式 URL 超过 3 KiB 前贪心切分,不拼接脚本,也不读取 map。图 revision 对 entry 与 batch descriptor 求哈希。`immediately` 标记第一阶段的 registration barrier;同一 combo 中的 row 共享脚本传输,不同 combo 则独立加载。
|
||||
|
||||
`release` 把本包版本与 client 构建固化进 Host 构建产物的源码 commit 配成一对,不计入图 revision。页面只应用其上次应用的 release 的图,初始为页面启动时的 release;来自其他 release 的图等待页面决定([Host release 变化](../../packages/client/modules/README.zh.md#host-release-changes))。
|
||||
`release` 把本包版本与 client 构建固化进 Host 构建产物的源码 commit 配成一对,不计入图 revision。只有 Host 提供页面启动时的 release,页面才应用图和 rebuilt 帧;其他 release 要等页面刷新([Host release 变化](../../packages/client/modules/README.zh.md#host-release-changes))。
|
||||
|
||||
## 扫描
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
en: 45740a56e931d094
|
||||
zh: fa44cf77c86019af
|
||||
/deepseek-ai-dsh-client-modules/use-this-package/host-release-changes:
|
||||
en: e83691968fb417d0
|
||||
zh: 060a859360cc064f
|
||||
en: eb5edf0f33bdb0c1
|
||||
zh: 625e0d17e1648642
|
||||
/deepseek-ai-dsh-client-modules/use-this-package/sharing-modules:
|
||||
en: 1778b5a63ecde5e8
|
||||
zh: 537b4f8c755af8ad
|
||||
@@ -51,8 +51,8 @@
|
||||
en: 253d9f265cd83ed0
|
||||
zh: 9d0f8df84d9d3ac9
|
||||
/deepseek-ai-dsh-client-modules/understand-the-implementation/entry-ownership:
|
||||
en: 208e528dc3e0ed92
|
||||
zh: 82b0ef93f282eacd
|
||||
en: df2994f9ff8872c9
|
||||
zh: cd776526ed2b51e6
|
||||
/deepseek-ai-dsh-client-modules/understand-the-implementation/source-map:
|
||||
en: fabe9db8c44e2d90
|
||||
zh: 89ca94a8161f57ed
|
||||
@@ -66,7 +66,7 @@
|
||||
en: ca75c51c89c2c9b0
|
||||
zh: de54f3467ef3b9be
|
||||
/deepseek-ai-dsh-client-modules/known-limitations-and-deferred-work:
|
||||
en: 764a83c20b8fe401
|
||||
en: 6dbb155bc65c9d65
|
||||
zh: 368b553a56e46dfc
|
||||
/deepseek-ai-dsh-client-modules/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
|
||||
@@ -43,7 +43,7 @@ An open Web page follows the Host's complete module graph through the HMR transp
|
||||
|
||||
### Host release changes
|
||||
|
||||
Every graph carries the Host release identity as `release`: this package's `version`, which releases share with the CLI, and the source `commit` that the client build fixes into the built Host artifact. Two releases are equal only when both fields are. A page applies graphs only from the release it last applied, which starts as the release it booted from. After the page reconnects to a Host with another release, it holds that release's latest graph, ignores its rebuilt frames, publishes `ctx.modules.entries.releaseChange` with the page's boot release, the last applied release, and the new one, and keeps its current plugins running. `acceptRelease()` makes the held release current and applies its graph; reloading the page boots the new release instead. A graph from the current release withdraws the pending change. The current release lives only in page memory.
|
||||
Every graph carries the Host release identity as `release`: this package's `version`, which releases share with the CLI, and the source `commit` that the client build fixes into the built Host artifact. Two releases are equal only when both fields are. A page applies graphs and rebuilt frames only while the connected Host serves the release the page booted from, so every module the page runs belongs to that release; when the Host returns to it, the usual revision comparison replaces exactly the changed modules. While the Host serves another release, the page applies nothing from it, keeps its current plugins running, and publishes `ctx.modules.entries.releaseChange` with the page's boot release, the Host's previous release, and its current release; only a page reload loads the other release. `dismissRelease()` withdraws the announcement and keeps the page quiet for that Host release until another one appears; it remembers only the last dismissed release, in page memory.
|
||||
|
||||
### Sharing modules
|
||||
|
||||
@@ -85,7 +85,7 @@ The host contributes structured index rows that inject, into `<head>`: the `wind
|
||||
|
||||
### Entry ownership
|
||||
|
||||
`ClientEntries` records the entries created during boot and serializes full-graph updates, retries and code reloads over the same Loader. A local generation prevents an older download from mounting after its desired entry or code changes; snapshots of the same targets share the pending load. A graph from another release stays outside the queue: retries keep reconciling the last applied graph, and rebuilt frames are ignored until the page accepts that release. New arrivals use single-resource URLs, never startup batches that could register existing factories twice. Factories retain their artifact revision before an entry exists; graph updates discard stale unowned factories, their styles and failed arrival targets before importing consumers. Cleanup retains declared and observed transitive module requests from every remaining Loader entry. Its observable status has no runtime library import because the modules bootstrap materializes before platform seeds are available.
|
||||
`ClientEntries` records the entries created during boot and serializes full-graph updates, retries and code reloads over the same Loader. A local generation prevents an older download from mounting after its desired entry or code changes; snapshots of the same targets share the pending load. A graph from another release than the boot release stays outside the queue: retries keep reconciling the last applied graph, and rebuilt frames are ignored while the Host serves that release. New arrivals use single-resource URLs, never startup batches that could register existing factories twice. Factories retain their artifact revision before an entry exists; graph updates discard stale unowned factories, their styles and failed arrival targets before importing consumers. Cleanup retains declared and observed transitive module requests from every remaining Loader entry. Its observable status has no runtime library import because the modules bootstrap materializes before platform seeds are available.
|
||||
|
||||
### Source map
|
||||
|
||||
@@ -133,7 +133,7 @@ These limits define what the module system does not do. They are current package
|
||||
|
||||
- **Metadata-based revisions** — revisions identify filesystem generations, not content equality. Metadata-only changes can reload a plugin; changes invisible in mtime, ctime, and size cannot be distinguished.
|
||||
- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (`loadCache`/`edges`/`invalidate`) already supports a general module graph, so the externalization granularity can change without an interface change.
|
||||
- **Host release detection rides the graph stream** — a page learns another Host release only from a graph frame. When a restarting Host answers `/plugins/events` with a non-200 status before registering the route, the browser closes that stream for good, and the page neither updates plugins nor detects the release change until it reloads.
|
||||
- **Host release detection rides the graph stream** — a page learns another Host release only from a graph frame. When a restarting Host answers `/plugins/events` with a non-200 status before registering the route, the browser closes that stream for good, and the page neither updates plugins nor detects a release change until it reloads.
|
||||
- **Source launch has no build step for the Host half** — a Host started from TypeScript source reads the commit that `pnpm run dev:web` places in its environment; a source launch without it publishes the version alone.
|
||||
- **Bootstrap and code replacement limits** — the page retains its modules bootstrap and static platform identities. Removing or replacing the bootstrap requires a page reload; live replacement requests report a page-local error while retaining its fiber and exports; replacing package code and all existing consumers is outside ordinary enable/disable synchronization.
|
||||
- **Lazy delivery retains requested bodies** — the Host holds each bundle and lazy response plan; a script or map body remains cached after its first `GET`, and HMR additionally retains one prior startup generation. Memory grows only for response bodies that clients request while preserving one-generation race tolerance.
|
||||
|
||||
@@ -44,7 +44,7 @@ application combo 脚本只携带每个插件的 `client.js` 入口,并在启
|
||||
<a id="host-release-changes"></a>
|
||||
### Host release 变化
|
||||
|
||||
每个图都以 `release` 携带 Host 的 release 身份:本包的 `version`(各包发布时与 CLI 共用同一版本号),以及 client 构建固化进 Host 构建产物的源码 `commit`。两个字段都相等才算同一个 release。页面只应用其上次应用的 release 的图,初始为页面启动时的 release。页面重连到另一个 release 的 Host 后,会暂存该 release 的最新图、忽略它的 rebuilt 帧,通过 `ctx.modules.entries.releaseChange` 发布页面启动时的 release、上次应用的 release 和新 release,并让当前插件继续运行。`acceptRelease()` 把暂存的 release 设为当前 release 并应用其图;刷新页面则直接启动新 release。收到当前 release 的图会撤回待确认的变化。当前 release 只保存在页面内存中。
|
||||
每个图都以 `release` 携带 Host 的 release 身份:本包的 `version`(各包发布时与 CLI 共用同一版本号),以及 client 构建固化进 Host 构建产物的源码 `commit`。两个字段都相等才算同一个 release。只有当前连接的 Host 提供的是页面启动时的 release,页面才应用图和 rebuilt 帧,因此页面运行的每个模块都属于这个 release;Host 回到该 release 时,照常按 revision 对比,只替换变化了的模块。Host 提供另一个 release 期间,页面不应用它的任何内容,当前插件继续运行,并通过 `ctx.modules.entries.releaseChange` 发布页面启动时的 release、Host 之前的 release 和当前的 release;只有刷新页面才会加载另一个 release。`dismissRelease()` 撤回提示,并在出现其他 release 之前不再为该 Host release 提示;它只在页面内存里记住最近一次撤回的 release。
|
||||
|
||||
### 共享模块
|
||||
|
||||
@@ -86,7 +86,7 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
|
||||
|
||||
### 条目所有权
|
||||
|
||||
`ClientEntries` 记录启动时创建的条目,并在同一个 Loader 上串行执行完整图更新、重试和代码重载。本地代际阻止旧下载在目标条目或代码变化后挂载;目标相同的快照共用进行中的加载。来自其他 release 的图不进入队列:重试继续调和上次应用的图,页面接受该 release 之前一律忽略 rebuilt 帧。新增模块使用单资源 URL,不会重新执行可能重复注册现有 factory 的启动 batch。Factory 在条目创建前就保留产物 revision;图更新会在导入消费者前丢弃未归属条目的陈旧 factory、其样式和失败的到达目标。清理会保留每个剩余 Loader 条目的已声明及已观察到的传递模块依赖。其可观察状态不导入运行时库,因为 modules bootstrap 在平台种子可用之前物化。
|
||||
`ClientEntries` 记录启动时创建的条目,并在同一个 Loader 上串行执行完整图更新、重试和代码重载。本地代际阻止旧下载在目标条目或代码变化后挂载;目标相同的快照共用进行中的加载。来自启动 release 以外 release 的图不进入队列:重试继续调和上次应用的图,Host 提供该 release 期间忽略 rebuilt 帧。新增模块使用单资源 URL,不会重新执行可能重复注册现有 factory 的启动 batch。Factory 在条目创建前就保留产物 revision;图更新会在导入消费者前丢弃未归属条目的陈旧 factory、其样式和失败的到达目标。清理会保留每个剩余 Loader 条目的已声明及已观察到的传递模块依赖。其可观察状态不导入运行时库,因为 modules bootstrap 在平台种子可用之前物化。
|
||||
|
||||
### 源码索引
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export class ClientEntries {
|
||||
// The modules bootstrap factory cannot request platform libraries before the shell supplies its seed.
|
||||
private snapshot: ClientEntryState = { syncing: false, failures: [] }
|
||||
private readonly listeners = new Set<() => void>()
|
||||
/** Holds graphs of another Host release until the page accepts that release. */
|
||||
/** Admits only Host graphs of the page's boot release. */
|
||||
private readonly release: ReleaseChangeGate
|
||||
private readonly managed = new Map<string, Entry>()
|
||||
private readonly revisions = new Map<string, string>()
|
||||
@@ -64,11 +64,16 @@ export class ClientEntries {
|
||||
this.release = new ReleaseChangeGate(modules.manifest.release)
|
||||
}
|
||||
|
||||
/** Pending Host release change; undefined while every received graph carries the release this page last applied. */
|
||||
/** Host release change to announce; undefined while the Host serves the page's boot release or a dismissed one. */
|
||||
get releaseChange(): ObservableSnapshot<ClientReleaseChange | undefined> {
|
||||
return this.release.change
|
||||
}
|
||||
|
||||
/** Stop announcing the connected Host's release; updates stay paused until the Host returns to the boot release or the page reloads. */
|
||||
dismissRelease(): void {
|
||||
this.release.dismiss()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the initial roster and retain its entry identities for subsequent reconciliation.
|
||||
* @param loader - Page Loader, already configured with the module system.
|
||||
@@ -95,22 +100,17 @@ export class ClientEntries {
|
||||
|
||||
/**
|
||||
* Validate and apply the latest full Host graph. Changed targets cancel obsolete mounts; identical targets share pending loads.
|
||||
* A graph of another release than the last applied one is held and announced through {@link releaseChange} instead.
|
||||
* A graph of another release than the page's boot release is not applied; {@link releaseChange} announces it.
|
||||
* @param graph - JSON-decoded graph received from the Host.
|
||||
* @returns after the queued reconciliation, or at once for a held graph; per-package failures remain available in {@link state}.
|
||||
* @returns after the queued reconciliation, or at once for another release; per-package failures remain available in {@link state}.
|
||||
*/
|
||||
sync(graph: unknown): Promise<void> {
|
||||
const manifest = parseBootManifest(graph)
|
||||
return this.release.admit(manifest) ? this.adopt(manifest) : Promise.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept the pending Host release and apply its latest graph; later graphs of that release apply without asking.
|
||||
* @returns after the queued reconciliation; resolves at once when no release change is pending.
|
||||
*/
|
||||
acceptRelease(): Promise<void> {
|
||||
const held = this.release.accept()
|
||||
return held === undefined ? Promise.resolve() : this.adopt(held)
|
||||
if (!this.release.observe(manifest.release)) return Promise.resolve()
|
||||
if (entryTargets(manifest) !== entryTargets(this.desired)) this.generation++
|
||||
this.desired = manifest
|
||||
const generation = this.generation
|
||||
return this.enqueue(() => this.reconcile(generation))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,13 +125,13 @@ export class ClientEntries {
|
||||
/**
|
||||
* Replace one entry's code in the same queue as graph updates; duplicate revisions are ignored.
|
||||
* Entries missing after a failed import are reconciled; bootstrap replacement fails before teardown.
|
||||
* While a release change is pending the frame is ignored: the Host's graph frame that follows every rebuild carries the revision.
|
||||
* Ignored while the connected Host serves another release than the page's boot release.
|
||||
* @param id - Package id from a rebuilt frame.
|
||||
* @param rev - Opaque revision selecting the rebuilt artifact.
|
||||
* @returns after queued work; replacement errors reject, while per-package reconciliation errors remain in {@link state}.
|
||||
*/
|
||||
reload(id: string, rev: string): Promise<void> {
|
||||
if (this.release.holding) return Promise.resolve()
|
||||
if (!this.release.matches) return Promise.resolve()
|
||||
this.desired = {
|
||||
...this.desired,
|
||||
modules: this.desired.modules.map(row => row.id === id ? { ...row, rev } : row),
|
||||
@@ -165,15 +165,6 @@ export class ClientEntries {
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconcile a graph the release gate let through. */
|
||||
private adopt(manifest: BootManifest): Promise<void> {
|
||||
this.release.applied(manifest)
|
||||
if (entryTargets(manifest) !== entryTargets(this.desired)) this.generation++
|
||||
this.desired = manifest
|
||||
const generation = this.generation
|
||||
return this.enqueue(() => this.reconcile(generation))
|
||||
}
|
||||
|
||||
private enqueue(task: () => Promise<void>, subject = 'graph'): Promise<void> {
|
||||
const run = this.queue.then(task)
|
||||
// Each operation reports its own failure; later operations must still run.
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/** Host release changes held back from a page whose code belongs to another release. */
|
||||
/** Which Host graphs a page may apply: only graphs of the release the page booted from. */
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { BootManifest, WebBootRelease } from './manifest.ts'
|
||||
import type { WebBootRelease } from './manifest.ts'
|
||||
|
||||
/** A Host graph whose release differs from the release this page last applied; its code waits for acceptance or a page reload. */
|
||||
/** The connected Host serves another release than the one this page booted from; plugin updates wait for a page reload. */
|
||||
export interface ClientReleaseChange {
|
||||
/** Release this page booted from; its shell and static modules stay at this release until a reload. */
|
||||
/** Release this page booted from; every module the page runs belongs to it. */
|
||||
readonly page: WebBootRelease | undefined
|
||||
/** Release of the latest graph this page applied: its boot release or an accepted one. */
|
||||
readonly current: WebBootRelease | undefined
|
||||
/** Release of the latest Host graph. */
|
||||
readonly next: WebBootRelease | undefined
|
||||
/** Release the Host served before its latest release change. */
|
||||
readonly previous: WebBootRelease | undefined
|
||||
/** Release of the connected Host. */
|
||||
readonly host: WebBootRelease | undefined
|
||||
}
|
||||
|
||||
/** Whether two releases are the same: both absent, or equal in every field. */
|
||||
@@ -23,12 +23,12 @@ function describeRelease(release: WebBootRelease | undefined): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the latest Host graph of another release than the one this page last applied. The owner applies
|
||||
* only graphs that {@link admit} passes or {@link accept} releases, and reports each one through
|
||||
* {@link applied}. The current release lives in page memory, so a reload starts from the new boot release.
|
||||
* Tracks the connected Host's release against the page's boot release. The page applies graphs and
|
||||
* rebuilt frames only while both match, so every module it runs belongs to its boot release; another
|
||||
* release waits for a page reload. A mismatch is announced unless the page dismissed that Host release.
|
||||
*/
|
||||
export class ReleaseChangeGate {
|
||||
/** Pending Host release change; undefined while every received graph carries the current release. */
|
||||
/** Prompt-worthy release change; undefined while the Host serves the boot release or a dismissed one. */
|
||||
readonly change: ObservableSnapshot<ClientReleaseChange | undefined> = {
|
||||
getSnapshot: () => this.snapshot,
|
||||
subscribe: (listener) => {
|
||||
@@ -38,53 +38,46 @@ export class ReleaseChangeGate {
|
||||
}
|
||||
private snapshot: ClientReleaseChange | undefined
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private current: WebBootRelease | undefined
|
||||
private held: BootManifest | undefined
|
||||
private host: WebBootRelease | undefined
|
||||
/** Host release the page last dismissed; a holder because an unpublished release is itself a value. */
|
||||
private dismissed: { readonly release: WebBootRelease | undefined } | undefined
|
||||
|
||||
/**
|
||||
* Start from the release the page booted from.
|
||||
* Start with the Host that served the boot graph.
|
||||
* @param page - boot graph's release identity.
|
||||
*/
|
||||
constructor(private readonly page: WebBootRelease | undefined) {
|
||||
this.current = page
|
||||
this.host = page
|
||||
}
|
||||
|
||||
/** Whether a graph of another release is held; its rebuilt frames carry nothing the next graph frame lacks. */
|
||||
get holding(): boolean {
|
||||
return this.held !== undefined
|
||||
/** Whether the connected Host serves the page's boot release; graphs and rebuilt frames apply only then. */
|
||||
get matches(): boolean {
|
||||
return sameRelease(this.host, this.page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass a graph of the current release; hold any other one and announce each new release once.
|
||||
* @param manifest - parsed Host graph.
|
||||
* @returns true when the owner applies the graph now.
|
||||
* Record the release of a received Host graph and update the announced change.
|
||||
* @param release - release identity of the received graph.
|
||||
* @returns true when the graph belongs to the page's boot release and may be applied.
|
||||
*/
|
||||
admit(manifest: BootManifest): boolean {
|
||||
if (sameRelease(manifest.release, this.current)) return true
|
||||
this.held = manifest
|
||||
if (this.snapshot === undefined || !sameRelease(this.snapshot.next, manifest.release)) {
|
||||
console.warn(`client-modules: Host release changed from ${describeRelease(this.current)} to ${describeRelease(manifest.release)}; plugin updates pause until this page accepts it or reloads`)
|
||||
this.publish({ page: this.page, current: this.current, next: manifest.release })
|
||||
observe(release: WebBootRelease | undefined): boolean {
|
||||
const previous = this.host
|
||||
this.host = release
|
||||
const quiet = this.matches || (this.dismissed !== undefined && sameRelease(release, this.dismissed.release))
|
||||
if (quiet) {
|
||||
if (this.snapshot !== undefined) this.publish(undefined)
|
||||
} else if (this.snapshot === undefined || !sameRelease(this.snapshot.host, release)) {
|
||||
console.warn(`client-modules: Host release changed from ${describeRelease(previous)} to ${describeRelease(release)}; plugin updates pause until this page reloads`)
|
||||
this.publish({ page: this.page, previous, host: release })
|
||||
}
|
||||
return false
|
||||
return this.matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the held graph for the owner to apply.
|
||||
* @returns the held graph, or undefined when no release change is pending.
|
||||
*/
|
||||
accept(): BootManifest | undefined {
|
||||
return this.held
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a graph the owner applied as the current release, withdrawing any pending change.
|
||||
* @param manifest - graph about to be reconciled.
|
||||
*/
|
||||
applied(manifest: BootManifest): void {
|
||||
this.held = undefined
|
||||
this.current = manifest.release
|
||||
if (this.snapshot !== undefined) this.publish(undefined)
|
||||
/** Stop announcing the connected Host's release; the page keeps its boot release and still applies no other release. */
|
||||
dismiss(): void {
|
||||
if (this.snapshot === undefined) return
|
||||
this.dismissed = { release: this.host }
|
||||
this.publish(undefined)
|
||||
}
|
||||
|
||||
private publish(snapshot: ClientReleaseChange | undefined): void {
|
||||
|
||||
@@ -520,70 +520,93 @@ describe('Host release changes', () => {
|
||||
const built = (release: WebBootRelease | undefined, ...entries: WebBootEntry[]): WebBootGraph => ({
|
||||
...graph(...entries), ...(release === undefined ? {} : { release }),
|
||||
})
|
||||
const b1: WebBootRelease = { version: '1.0.0', commit: 'aaaaaaa' }
|
||||
const b2: WebBootRelease = { version: '1.0.0', commit: 'bbbbbbb' }
|
||||
const b3: WebBootRelease = { version: '2.0.0' }
|
||||
const r1: WebBootRelease = { version: '1.0.0', commit: 'aaaaaaa' }
|
||||
const r2: WebBootRelease = { version: '1.0.0', commit: 'bbbbbbb' }
|
||||
const r3: WebBootRelease = { version: '2.0.0' }
|
||||
const warning = (from: string, to: string) =>
|
||||
`client-modules: Host release changed from ${from} to ${to}; plugin updates pause until this page reloads`
|
||||
|
||||
it('pauses code replacement for another release until the page accepts it', async () => {
|
||||
it('applies graphs and rebuilt frames of the boot release without announcing anything', async () => {
|
||||
const effects = { mounted: 0, disposed: 0, hits: 0 }
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const b = await bench(built(b1, row('pet')), { pet: visible('pet', effects) })
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = b.modules.entries.releaseChange.subscribe(listener)
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
|
||||
// A different commit of the same version is another release.
|
||||
await b.modules.entries.sync(built(b2, row('pet', 'r1')))
|
||||
// Rebuilt frames of the pending release are ignored; its next graph frame carries the revision.
|
||||
await b.modules.entries.reload('pet', 'r2')
|
||||
expect(effects).toEqual({ mounted: 1, disposed: 0, hits: 0 })
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: b1, current: b1, next: b2 })
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
expect(warn).toHaveBeenCalledWith(`client-modules: Host release changed from ${JSON.stringify(b1)} to ${JSON.stringify(b2)}; plugin updates pause until this page accepts it or reloads`)
|
||||
// Repeated frames for the pending release neither replace code nor announce again.
|
||||
await b.modules.entries.sync(built({ ...b2 }, row('pet', 'r2')))
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
await b.modules.entries.acceptRelease()
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
expect(effects).toEqual({ mounted: 2, disposed: 1, hits: 0 })
|
||||
expect(b.fetched.at(-1)).toBe(row('pet', 'r2').url)
|
||||
// The accepted release replaces code without asking again.
|
||||
await b.modules.entries.sync(built(b2, row('pet', 'r2')))
|
||||
await b.modules.entries.reload('pet', 'r3')
|
||||
expect(effects).toEqual({ mounted: 3, disposed: 2, hits: 0 })
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
// An unsubscribed listener hears no later change; the page keeps its boot release.
|
||||
unsubscribe()
|
||||
await b.modules.entries.sync(built(b3, row('pet', 'r3')))
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: b1, current: b2, next: b3 })
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('asks for every release other than the last applied one, including the boot release', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const b = await bench(built(b1, row('pet')), { pet: () => ({ apply() {} }) })
|
||||
await b.modules.entries.sync(built(b2, row('pet')))
|
||||
await b.modules.entries.acceptRelease()
|
||||
await b.modules.entries.sync(built(b1, row('pet')))
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: b1, current: b2, next: b1 })
|
||||
await b.modules.entries.sync(built(b3, row('pet')))
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: b1, current: b2, next: b3 })
|
||||
})
|
||||
|
||||
it('withdraws a pending prompt when the Host returns to the applied release', async () => {
|
||||
const effects = { mounted: 0, disposed: 0, hits: 0 }
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const b = await bench(built(b1, row('pet')), { pet: visible('pet', effects) })
|
||||
const b = await bench(built(r1, row('pet')), { pet: visible('pet', effects) })
|
||||
const listener = vi.fn()
|
||||
b.modules.entries.releaseChange.subscribe(listener)
|
||||
await b.modules.entries.sync(built(b2, row('pet', 'r1')))
|
||||
await b.modules.entries.sync(built(b1, row('pet', 'r1')))
|
||||
await b.modules.entries.sync(built({ ...r1 }, row('pet')))
|
||||
expect(effects).toEqual({ mounted: 1, disposed: 0, hits: 0 })
|
||||
await b.modules.entries.sync(built(r1, row('pet', 'r1')))
|
||||
await b.modules.entries.reload('pet', 'r2')
|
||||
expect(effects).toEqual({ mounted: 3, disposed: 2, hits: 0 })
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('applies nothing from another release and announces it once', async () => {
|
||||
const effects = { mounted: 0, disposed: 0, hits: 0 }
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const b = await bench(built(r1, row('pet')), { pet: visible('pet', effects) })
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = b.modules.entries.releaseChange.subscribe(listener)
|
||||
// A different commit of the same version is another release.
|
||||
await b.modules.entries.sync(built(r2, row('pet', 'r1'), row('cat')))
|
||||
await b.modules.entries.reload('pet', 'r2')
|
||||
expect(effects).toEqual({ mounted: 1, disposed: 0, hits: 0 })
|
||||
expect(b.modules.loadCache.has('cat')).toBe(false)
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: r1, previous: r1, host: r2 })
|
||||
expect(warn).toHaveBeenCalledWith(warning(JSON.stringify(r1), JSON.stringify(r2)))
|
||||
// Later frames of the same Host release neither apply nor announce again.
|
||||
await b.modules.entries.sync(built({ ...r2 }, row('pet', 'r3')))
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
// Another Host release replaces the announcement; an unsubscribed listener hears nothing more.
|
||||
await b.modules.entries.sync(built(r3, row('pet', 'r3')))
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: r1, previous: r2, host: r3 })
|
||||
expect(warn).toHaveBeenLastCalledWith(warning(JSON.stringify(r2), JSON.stringify(r3)))
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
expect(effects).toEqual({ mounted: 2, disposed: 1, hits: 0 })
|
||||
// Accepting without a pending change leaves the page unchanged.
|
||||
await b.modules.entries.acceptRelease()
|
||||
expect(effects.mounted).toBe(2)
|
||||
unsubscribe()
|
||||
await b.modules.entries.sync(built(r2, row('pet', 'r3')))
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
expect(effects.mounted).toBe(1)
|
||||
})
|
||||
|
||||
it('withdraws the announcement and replaces only changed modules when the Host returns to the boot release', async () => {
|
||||
const pet = { mounted: 0, disposed: 0, hits: 0 }
|
||||
const cat = { mounted: 0, disposed: 0, hits: 0 }
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const b = await bench(built(r1, row('pet'), row('cat')), { pet: visible('pet', pet), cat: visible('cat', cat) })
|
||||
await b.modules.entries.sync(built(r2, row('pet', 'r1'), row('cat', 'r1')))
|
||||
// The boot release's graph is compared with the graph the page last applied.
|
||||
await b.modules.entries.sync(built(r1, row('pet'), row('cat', 'r1')))
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
|
||||
expect(pet).toEqual({ mounted: 1, disposed: 0, hits: 0 })
|
||||
expect(cat).toEqual({ mounted: 2, disposed: 1, hits: 0 })
|
||||
await b.modules.entries.reload('pet', 'r2')
|
||||
expect(pet).toEqual({ mounted: 2, disposed: 1, hits: 0 })
|
||||
})
|
||||
|
||||
it('stops announcing a dismissed Host release but still applies nothing from it', async () => {
|
||||
const effects = { mounted: 0, disposed: 0, hits: 0 }
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const b = await bench(built(r1, row('pet')), { pet: visible('pet', effects) })
|
||||
// Dismissing without an announcement leaves nothing to remember.
|
||||
b.modules.entries.dismissRelease()
|
||||
await b.modules.entries.sync(built(r2, row('pet', 'r1')))
|
||||
b.modules.entries.dismissRelease()
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
|
||||
await b.modules.entries.sync(built(r2, row('pet', 'r2')))
|
||||
await b.modules.entries.reload('pet', 'r3')
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
|
||||
expect(effects).toEqual({ mounted: 1, disposed: 0, hits: 0 })
|
||||
// Only the last dismissed release stays quiet.
|
||||
await b.modules.entries.sync(built(r3, row('pet', 'r3')))
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: r1, previous: r2, host: r3 })
|
||||
await b.modules.entries.sync(built(r2, row('pet', 'r3')))
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
|
||||
await b.modules.entries.sync(built(r3, row('pet', 'r3')))
|
||||
b.modules.entries.dismissRelease()
|
||||
await b.modules.entries.sync(built(r2, row('pet', 'r3')))
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: r1, previous: r3, host: r2 })
|
||||
expect(warn).toHaveBeenCalledTimes(4)
|
||||
expect(effects.mounted).toBe(1)
|
||||
})
|
||||
|
||||
it('treats an absent release as one identity', async () => {
|
||||
@@ -591,23 +614,27 @@ describe('Host release changes', () => {
|
||||
const b = await bench(graph(), {})
|
||||
await b.modules.entries.sync(graph())
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
|
||||
await b.modules.entries.sync(built(b3))
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: undefined, current: undefined, next: b3 })
|
||||
expect(warn).toHaveBeenCalledWith(`client-modules: Host release changed from none to ${JSON.stringify(b3)}; plugin updates pause until this page accepts it or reloads`)
|
||||
await b.modules.entries.acceptRelease()
|
||||
await b.modules.entries.sync(built(r3))
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: undefined, previous: undefined, host: r3 })
|
||||
expect(warn).toHaveBeenCalledWith(warning('none', JSON.stringify(r3)))
|
||||
b.modules.entries.dismissRelease()
|
||||
await b.modules.entries.sync(graph())
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toEqual({ page: undefined, current: b3, next: undefined })
|
||||
expect(b.modules.entries.releaseChange.getSnapshot()).toBeUndefined()
|
||||
// A Host that publishes no release counts as another release for a page that booted with one.
|
||||
const versioned = await bench(built(r1), {})
|
||||
await versioned.modules.entries.sync(graph())
|
||||
expect(versioned.modules.entries.releaseChange.getSnapshot()).toEqual({ page: r1, previous: r1, host: undefined })
|
||||
})
|
||||
|
||||
it('notifies every subscriber when one of them throws', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const b = await bench(built(b1), {})
|
||||
const b = await bench(built(r1), {})
|
||||
const failure = new Error('subscriber')
|
||||
const listener = vi.fn()
|
||||
b.modules.entries.releaseChange.subscribe(() => { throw failure })
|
||||
b.modules.entries.releaseChange.subscribe(listener)
|
||||
await b.modules.entries.sync(built(b2))
|
||||
await b.modules.entries.sync(built(r2))
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
expect(error).toHaveBeenCalledWith('client-modules: release change subscriber failed', failure)
|
||||
})
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 2843d46e95311bce
|
||||
zh: ec11bdeb5d3ce818
|
||||
/deepseek-ai-dsh-client-ui-settings-general/use-this-package:
|
||||
en: 4e5046c43bfd1c07
|
||||
zh: 088df88202c6d6ae
|
||||
en: 2bc21e57fe17eab9
|
||||
zh: e441cd313f747b22
|
||||
/deepseek-ai-dsh-client-ui-settings-general/use-this-package/the-general-section:
|
||||
en: 8b8a7c6287bb303b
|
||||
zh: 19535112531aedd5
|
||||
@@ -36,8 +36,8 @@
|
||||
en: e37fb7df883ea356
|
||||
zh: c972524e56c6d6c2
|
||||
/deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation/host-release-changes:
|
||||
en: 66c7903c64be9f84
|
||||
zh: 7fd0dcda5f4f083f
|
||||
en: d464f726b026c39a
|
||||
zh: c949e5f4d65a639f
|
||||
/deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation/document-availability:
|
||||
en: ff1aec9eb1a87a79
|
||||
zh: eb91ab8bef59742d
|
||||
|
||||
@@ -31,7 +31,7 @@ Users reach the shell through the sidebar's bottom Settings control; feature plu
|
||||
|
||||
Settings uses the [frame’s shared overlay inset](../ui-layout/README.md#window-chrome-seat) and keeps at least 24px of viewport margin. Its mask leaves the Windows caption unpainted; the full-viewport layer still blocks background clicks.
|
||||
|
||||
When the page reconnects to a Host running another release, a **Service version changed** dialog draws two lines: **Service** from the last applied release (gray) to the new release, joined by a thin solid green arrow because the switch already happened, and **Page** from the release this page booted from to the new release (gray), joined by an equally thin dashed gray arrow because only a reload completes it. A line shows versions when they differ and commits otherwise. **Reload to load the new version** loads the new release; **Not now**, the close control, a mask click, and Escape keep the page and apply the new release's plugin updates, after which reconnecting to that release no longer asks. Plugin updates stay paused until one of these choices.
|
||||
When the page reconnects to a Host running another release, a **Service version changed** dialog draws two lines: **Service** from the Host's previous release (gray) to its current release, joined by a thin solid green arrow because the Host already switched, and **Page** from the release this page booted from to the Host's release (gray), joined by an equally thin dashed gray arrow because only a reload completes it. A line shows versions when its two versions differ and commits otherwise. The page applies no plugin update from that release. **Reload to load the new version** loads it; **Not now**, the close control, a mask click, and Escape keep the page at its own release and stop asking about that Host release. When the Host returns to the page's release, the dialog closes and live updates resume.
|
||||
|
||||
When the section navigation exceeds the panel's available height, the list scrolls independently of the settings content and keeps the Settings title fixed.
|
||||
|
||||
@@ -77,7 +77,7 @@ The shell is an explicit recovery consumer, so it injects Connection directly ra
|
||||
|
||||
### Host release changes
|
||||
|
||||
The `shell.overlay` entry `host-release-change` binds `ctx.modules.entries.releaseChange` in its hooks compartment and receives two callbacks: one calls `ctx.modules.entries.acceptRelease()`, the other reloads the page. Release identities are Host data and render verbatim; [Client Modules](../modules/README.md#host-release-changes) owns which release counts as current.
|
||||
The `shell.overlay` entry `host-release-change` binds `ctx.modules.entries.releaseChange` in its hooks compartment and receives two callbacks: one calls `ctx.modules.entries.dismissRelease()`, the other reloads the page. Release identities are Host data and render verbatim; [Client Modules](../modules/README.md#host-release-changes) owns which release counts as current.
|
||||
|
||||
### Document availability
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ kind: "package-reference"
|
||||
|
||||
设置面板使用[框架共享的浮层起点](../ui-layout/README.zh.md#window-chrome-seat),视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。
|
||||
|
||||
页面重连到运行另一个 release 的 Host 时,会弹出**服务版本变化**对话框,画出两条线:**服务**从上次应用的 release(灰色)指向新 release,用细的绿色实线箭头连接,表示切换已经完成;**页面**从页面启动时的 release 指向新 release(灰色),用同样细的灰色虚线箭头连接,表示要刷新后才完成。版本号不同时显示版本号,否则显示 commit。**刷新以加载新版本**加载新 release;**暂不刷新**、关闭按钮、点击遮罩和 Escape 都保留当前页面并应用新 release 的插件更新,之后重连到该 release 不再提示。做出选择之前,插件更新保持暂停。
|
||||
页面重连到运行另一个 release 的 Host 时,会弹出**服务版本变化**对话框,画出两条线:**服务**从 Host 之前的 release(灰色)指向它当前的 release,用细的绿色实线箭头连接,表示 Host 已经切换;**页面**从页面启动时的 release 指向 Host 的 release(灰色),用同样细的灰色虚线箭头连接,表示要刷新后才完成。每行两端版本号不同时显示版本号,否则显示 commit。页面不应用该 release 的任何插件更新。**刷新以加载新版本**加载它;**暂不刷新**、关闭按钮、点击遮罩和 Escape 都让页面保持自己的 release,并不再为该 Host release 提示。Host 回到页面的 release 时,对话框关闭,热更新恢复。
|
||||
|
||||
当分区导航超出面板可用高度时,列表独立于设置内容滚动,并保持「设置」标题固定。
|
||||
|
||||
@@ -77,7 +77,7 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注
|
||||
|
||||
### Host release 变化
|
||||
|
||||
`shell.overlay` 条目 `host-release-change` 在其 hooks compartment 中绑定 `ctx.modules.entries.releaseChange`,并接收两个回调:一个调用 `ctx.modules.entries.acceptRelease()`,另一个刷新页面。release 身份是 Host 数据,按原样显示;哪个 release 算当前由 [Client Modules](../modules/README.zh.md#host-release-changes) 决定。
|
||||
`shell.overlay` 条目 `host-release-change` 在其 hooks compartment 中绑定 `ctx.modules.entries.releaseChange`,并接收两个回调:一个调用 `ctx.modules.entries.dismissRelease()`,另一个刷新页面。release 身份是 Host 数据,按原样显示;哪个 release 算当前由 [Client Modules](../modules/README.zh.md#host-release-changes) 决定。
|
||||
|
||||
### 文档可用性
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@ import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@dee
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import css from './HostReleasePrompt.module.css'
|
||||
|
||||
/** Registrant-private face: the page's pending release change and its two decisions. */
|
||||
/** Registrant-private face: the page's release change and its two decisions. */
|
||||
export type HostReleasePromptInjected = {
|
||||
/** Keep this page and apply the new release's plugin updates; later graphs of that release apply without asking. */
|
||||
acceptRelease: () => void
|
||||
/** Keep this page at its boot release and stop asking about the connected Host's release. */
|
||||
dismissRelease: () => void
|
||||
/** Reload the page from the Host. */
|
||||
reloadPage: () => void
|
||||
hooks: {
|
||||
/** Pending Host release change owned by the page's module system. */
|
||||
/** Host release change owned by the page's module system. */
|
||||
releaseChange: HostObservable<ClientReleaseChange | undefined>
|
||||
}
|
||||
}
|
||||
@@ -53,28 +53,28 @@ function Transition({ label, from, to, done }: { label: string; from: string; to
|
||||
|
||||
/**
|
||||
* Ask whether to reload after the Host changed releases; closing the dialog keeps the page.
|
||||
* The service line shows the completed switch from the last applied release; the page line shows the
|
||||
* pending switch from the release this page booted from.
|
||||
* @param props - the pending change, both decisions, and localized copy.
|
||||
* @returns the dialog while a change is pending; otherwise nothing.
|
||||
* The service line shows the Host's completed switch from its previous release; the page line shows the
|
||||
* switch from the page's boot release that only a reload completes.
|
||||
* @param props - the release change, both decisions, and localized copy.
|
||||
* @returns the dialog while a change is announced; otherwise nothing.
|
||||
*/
|
||||
export function HostReleasePrompt({ useReleaseChange, acceptRelease, reloadPage, t }: HostReleasePromptProps) {
|
||||
export function HostReleasePrompt({ useReleaseChange, dismissRelease, reloadPage, t }: HostReleasePromptProps) {
|
||||
const change = useReleaseChange(value => value)
|
||||
if (change === undefined) return null
|
||||
const unknown = t('hostRelease.unknown')
|
||||
const [serviceFrom, serviceTo] = transitionText(change.current, change.next, unknown)
|
||||
const [pageFrom, pageTo] = transitionText(change.page, change.next, unknown)
|
||||
const [serviceFrom, serviceTo] = transitionText(change.previous, change.host, unknown)
|
||||
const [pageFrom, pageTo] = transitionText(change.page, change.host, unknown)
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={t('hostRelease.title')}
|
||||
description={t('hostRelease.description')}
|
||||
closeLabel={t('hostRelease.later')}
|
||||
onClose={acceptRelease}
|
||||
onClose={dismissRelease}
|
||||
className={clsx(css.dialog)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" onClick={acceptRelease}>{t('hostRelease.later')}</Button>
|
||||
<Button variant="outline" onClick={dismissRelease}>{t('hostRelease.later')}</Button>
|
||||
<Button variant="primary" data-modal-autofocus onClick={reloadPage}>{t('hostRelease.reload')}</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -240,9 +240,9 @@ export function apply(ctx: ClientContext): void {
|
||||
locale: NS,
|
||||
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
|
||||
}, GeneralSection))
|
||||
// The page's module system holds another Host release's plugin updates until this dialog's decision.
|
||||
// The page's module system applies no plugin update from another Host release; this dialog offers the reload.
|
||||
const releasePromptInjected = (): HostReleasePromptInjected => ({
|
||||
acceptRelease: () => { void ctx.modules.entries.acceptRelease().catch((error: unknown) => { ctx.logger.error(error) }) },
|
||||
dismissRelease: () => { ctx.modules.entries.dismissRelease() },
|
||||
reloadPage: () => { globalThis.location.reload() },
|
||||
hooks: { releaseChange: ctx.modules.entries.releaseChange },
|
||||
})
|
||||
|
||||
@@ -37,7 +37,7 @@ export const zh = {
|
||||
'connection.reconnect': '连接异常,点击立即重连',
|
||||
'connection.restart': '连接中断,正在重试,点击立即重连',
|
||||
'hostRelease.title': '服务版本变化',
|
||||
'hostRelease.description': '连接重新建立后版本信息变化如下',
|
||||
'hostRelease.description': '服务版本已变化,本页面已暂停热更新,刷新后加载新版本。',
|
||||
'hostRelease.service': '服务',
|
||||
'hostRelease.page': '页面',
|
||||
'hostRelease.unknown': '未标注',
|
||||
@@ -85,7 +85,7 @@ export const en = {
|
||||
'connection.reconnect': 'Disconnected, reconnect now',
|
||||
'connection.restart': 'Reconnecting, reconnect now',
|
||||
'hostRelease.title': 'Service version changed',
|
||||
'hostRelease.description': 'After the connection was re-established, versions changed as follows.',
|
||||
'hostRelease.description': 'The service version changed. This page paused live updates; reload to load the new version.',
|
||||
'hostRelease.service': 'Service',
|
||||
'hostRelease.page': 'Page',
|
||||
'hostRelease.unknown': 'Not labeled',
|
||||
|
||||
@@ -281,31 +281,25 @@ describe('ui-settings-general apply', () => {
|
||||
expect(c.ctx.slots.spec('settings.general.item')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('asks about an unaccepted Host release in the shell overlay and resumes plugin updates once kept', async ({ mock, start }) => {
|
||||
it('asks about another Host release in the shell overlay and stops asking once dismissed', async ({ mock, start }) => {
|
||||
const { c } = await client(mock, start)
|
||||
const overlay = () => c.ctx.slots.entries('shell.overlay').find(row => row.options.id === 'host-release-change')
|
||||
expect(overlay()?.component).toBe(HostReleasePrompt)
|
||||
expect(overlay()?.locale).toBe(NS)
|
||||
const face = overlay()!.inject!()
|
||||
const { acceptRelease, reloadPage, hooks } = face
|
||||
if (typeof acceptRelease !== 'function' || typeof reloadPage !== 'function' || typeof hooks !== 'object' || hooks === null) {
|
||||
const { dismissRelease, reloadPage, hooks } = face
|
||||
if (typeof dismissRelease !== 'function' || typeof reloadPage !== 'function' || typeof hooks !== 'object' || hooks === null) {
|
||||
throw new TypeError('host-release-change inject face is incomplete')
|
||||
}
|
||||
const injected = { acceptRelease, reloadPage, hooks } as HostReleasePromptInjected
|
||||
const injected = { dismissRelease, reloadPage, hooks } as HostReleasePromptInjected
|
||||
expect(injected.hooks.releaseChange).toBe(c.ctx.modules.entries.releaseChange)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const release = { version: '2.0.0' }
|
||||
await c.ctx.modules.entries.sync({ ...graphFromRoster(webApp.rows), release })
|
||||
expect(injected.hooks.releaseChange.getSnapshot()).toEqual({ page: undefined, current: undefined, next: release })
|
||||
expect(injected.hooks.releaseChange.getSnapshot()).toEqual({ page: undefined, previous: undefined, host: release })
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
injected.acceptRelease()
|
||||
await vi.waitFor(() => { expect(injected.hooks.releaseChange.getSnapshot()).toBeUndefined() })
|
||||
// A rejected acceptance is reported rather than left unhandled.
|
||||
const failed = new Error('acceptance failed')
|
||||
vi.spyOn(c.ctx.modules.entries, 'acceptRelease').mockRejectedValueOnce(failed)
|
||||
const logged = vi.spyOn(c.ctx.logger, 'error').mockImplementation(() => {})
|
||||
injected.acceptRelease()
|
||||
await vi.waitFor(() => { expect(logged).toHaveBeenCalledWith(failed) })
|
||||
injected.dismissRelease()
|
||||
expect(injected.hooks.releaseChange.getSnapshot()).toBeUndefined()
|
||||
const reload = vi.fn()
|
||||
vi.stubGlobal('location', { reload })
|
||||
onTestFinished(() => { vi.unstubAllGlobals() })
|
||||
|
||||
@@ -250,61 +250,61 @@ describe('current version', () => {
|
||||
})
|
||||
|
||||
describe('Host release change confirmation', () => {
|
||||
type Build = { version: string; commit?: string }
|
||||
type Change = { page: Build | undefined; current: Build | undefined; next: Build | undefined } | undefined
|
||||
type Release = { version: string; commit?: string }
|
||||
type Change = { page: Release | undefined; previous: Release | undefined; host: Release | undefined } | undefined
|
||||
function prompt(change: Change, dictionary: Record<string, string> = en) {
|
||||
const acceptRelease = vi.fn()
|
||||
const dismissRelease = vi.fn()
|
||||
const reloadPage = vi.fn()
|
||||
const props: HostReleasePromptProps = {
|
||||
...kit,
|
||||
t: key => dictionary[key] ?? key,
|
||||
useReleaseChange: select => select(change),
|
||||
acceptRelease,
|
||||
dismissRelease,
|
||||
reloadPage,
|
||||
}
|
||||
return { acceptRelease, reloadPage, view: render(<HostReleasePrompt {...props} />) }
|
||||
return { dismissRelease, reloadPage, view: render(<HostReleasePrompt {...props} />) }
|
||||
}
|
||||
|
||||
it('stays hidden while no build change is pending', () => {
|
||||
it('stays hidden while no release change is announced', () => {
|
||||
prompt(undefined)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the service and page transitions and either reloads or keeps the page', () => {
|
||||
const { acceptRelease, reloadPage } = prompt({
|
||||
page: { version: '0.2.1' }, current: { version: '0.2.2' }, next: { version: '0.2.3', commit: 'abc1234' },
|
||||
const { dismissRelease, reloadPage } = prompt({
|
||||
page: { version: '0.2.1' }, previous: { version: '0.2.2' }, host: { version: '0.2.3', commit: 'abc1234' },
|
||||
})
|
||||
const dialog = screen.getByRole('dialog', { name: 'Service version changed' })
|
||||
// Changed versions show only the versions.
|
||||
expect(dialog.textContent).toContain('Service0.2.20.2.3Page0.2.10.2.3')
|
||||
expect(dialog.textContent).toContain('After the connection was re-established, versions changed as follows.')
|
||||
expect(dialog.textContent).toContain(en['hostRelease.description'])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reload to load the new version' }))
|
||||
expect(reloadPage).toHaveBeenCalledOnce()
|
||||
expect(acceptRelease).not.toHaveBeenCalled()
|
||||
expect(dismissRelease).not.toHaveBeenCalled()
|
||||
const keep = screen.getAllByRole('button', { name: 'Not now' })
|
||||
// The footer action and the dialog's close control both keep the page.
|
||||
expect(keep).toHaveLength(2)
|
||||
for (const button of keep) fireEvent.click(button)
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(acceptRelease).toHaveBeenCalledTimes(3)
|
||||
expect(dismissRelease).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('shows commits for a transition within one version and labels what the Host did not publish', () => {
|
||||
prompt({
|
||||
page: { version: '0.2.0' },
|
||||
current: { version: '0.2.1', commit: 'aaaaaaa' },
|
||||
next: { version: '0.2.1', commit: 'bbbbbbb' },
|
||||
previous: { version: '0.2.1', commit: 'aaaaaaa' },
|
||||
host: { version: '0.2.1', commit: 'bbbbbbb' },
|
||||
})
|
||||
expect(screen.getByRole('dialog').textContent).toContain('Serviceaaaaaaabbbbbbb' + 'Page0.2.00.2.1')
|
||||
cleanup()
|
||||
prompt({ page: { version: '0.2.1' }, current: { version: '0.2.1' }, next: { version: '0.2.1', commit: 'bbbbbbb' } }, zh)
|
||||
prompt({ page: { version: '0.2.1' }, previous: { version: '0.2.1' }, host: { version: '0.2.1', commit: 'bbbbbbb' } }, zh)
|
||||
expect(screen.getByRole('dialog', { name: '服务版本变化' }).textContent)
|
||||
.toContain('服务未标注bbbbbbb页面未标注bbbbbbb')
|
||||
cleanup()
|
||||
prompt({ page: undefined, current: undefined, next: { version: '0.2.1' } })
|
||||
prompt({ page: undefined, previous: undefined, host: { version: '0.2.1' } })
|
||||
expect(screen.getByRole('dialog').textContent).toContain('ServiceNot labeled0.2.1PageNot labeled0.2.1')
|
||||
cleanup()
|
||||
prompt({ page: { version: '0.2.1' }, current: { version: '0.2.1' }, next: undefined })
|
||||
prompt({ page: { version: '0.2.1' }, previous: { version: '0.2.1' }, host: undefined })
|
||||
expect(screen.getByRole('dialog').textContent).toContain('Service0.2.1Not labeledPage0.2.1Not labeled')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user