Prechádzať zdrojové kódy

fix(web): preserve boot page through React handoff

imccyu 1 mesiac pred
rodič
commit
0fda2a26b7

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.md
-2026-08-17-dynamic-client-render-and-attachment-ownership.md: e9adf25464af767b2ee7d9d0bd11a5130a219977
-2026-08-17-dynamic-client-render-and-attachment-ownership.zh.md: 581030e9a136025aafb0562a4a6b8241f299d0cb
+2026-08-17-dynamic-client-render-and-attachment-ownership.md: 7dc465a878b4e087c1219011c330c9235e69791a
+2026-08-17-dynamic-client-render-and-attachment-ownership.zh.md: 2ccebfe705175b70425e0fcb7aa18e7213780b92

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.md

@@ -14,17 +14,17 @@ The loading and failure page has the opposite requirement: it must remain usable
 
 `@deepseek-ai/dsh-client-web` is a framework-free boot kernel. It draws its loading and failure page with DOM operations and local CSS fallbacks, constructs the client module system and Cordis Loader, creates the statically adopted modules bootstrap entry plus every host-graph entry, and waits until every fiber is ACTIVE. It then resolves `ctx.uiRenderer` and hands the existing container to `mount()`.
 
-`@deepseek-ai/dsh-client-ui-renderer` is an `immediately` dynamic client plugin. It owns the React slot outlets, SessionProvider, and observable-to-uSES binding. After `slots`, `sessions`, and `layout` activate, it installs the slot renderer, provides `ctx.uiRenderer`, creates the React root on `mount()`, projects the selected session title, and performs the sole context-level `renderSlot('root')` call. Its service, renderer installation, and React root all dispose with their owners.
+`@deepseek-ai/dsh-client-ui-renderer` is an `immediately` dynamic client plugin. It owns the React slot outlets, SessionProvider, and observable-to-uSES binding. After `slots`, `sessions`, and `layout` activate, it installs the slot renderer and provides `ctx.uiRenderer`. `mount()` hydrates the kernel-authored boot DOM, then replaces it with the assembled application in a layout effect before the browser can paint an intermediate frame. The hydrated spinner node retains its animation phase. The assembled tree projects the selected session title and performs the sole context-level `renderSlot('root')` call. The service, renderer installation, and React root all dispose with their owners.
 
 `ui-conversation` declares `conversation.input.attachments` and `conversation.message.images` and supplies attachment data, callbacks, authorized image loading, and its locale seat. `ui-attachment` waits on those declarations through `ctx.slots.inject()` and registers the draft rail/drop target and historical image gallery/lightbox. The React implementations remain internal package values; cross-plugin composition uses slots. This package integration supersedes the direct-import ruling in the [attachment display note](../feature/2026-08-11-web-attachment-display-alignment.md) without changing that note's visual and interaction decisions.
 
-ui-theme imports its five global stylesheets from its client entry. The shared client-bundle preset compiles ordinary CSS as well as CSS Modules and injects plugin-owned style tags at bundle materialization, so unloading or reloading ui-theme removes or replaces its global CSS with the same lifecycle as its service. The web kernel retains only mount defaults and the self-contained boot-page palette.
+ui-theme imports its five global stylesheets from its client entry. The shared client-bundle preset compiles ordinary CSS as well as CSS Modules and injects plugin-owned style tags at bundle materialization, so unloading or reloading ui-theme removes or replaces its global CSS with the same lifecycle as its service. The web kernel retains only mount defaults and a self-contained boot-page palette whose fonts and colors match the corresponding theme tokens.
 
 React, React DOM, Cordis, ui-slots, and ui-primitives remain static platform modules with one browser identity. The dynamic ui-renderer bundle consumes those shared modules and owns the rendering effects.
 
 ## Verification
 
-Component tests pin the boot page, document title, application tree, attachment entries, and disposal. The assembled built-bundle boot exercises the real module table and dynamic entries, while the client-bundle CSS tests prove global styles compile into watched plugin-owned injectors. The browser replay lane covers the complete handoff from the framework-free page to the rendered application.
+Component tests pin the boot page, hydration without boot-DOM mutation, document title, application tree, attachment entries, and disposal. The assembled built-bundle boot exercises the real module table and dynamic entries, while the client-bundle CSS tests prove global styles compile into watched plugin-owned injectors. The browser replay lane covers the complete handoff from the framework-free page to the rendered application.
 
 ## Alternatives considered
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.zh.md

@@ -14,17 +14,17 @@ Status: implemented
 
 `@deepseek-ai/dsh-client-web` 是不依赖框架的启动内核。它通过 DOM 操作与本地 CSS 回退绘制加载和失败页面,构造客户端模块系统与 Cordis Loader,创建静态接纳的 modules 启动 entry 和宿主图中的每个 entry,并等待所有 fiber 进入 ACTIVE。随后它解析 `ctx.uiRenderer`,把现有容器交给 `mount()`。
 
-`@deepseek-ai/dsh-client-ui-renderer` 是带 `immediately` 标记的动态客户端插件。它持有 React slot outlet、SessionProvider 与 observable 到 uSES 的绑定。`slots`、`sessions` 与 `layout` 激活后,它安装 slot 渲染器、提供 `ctx.uiRenderer`、在 `mount()` 时创建 React 根、投影当前会话标题,并执行唯一一次上下文级 `renderSlot('root')` 调用。其服务、渲染器安装和 React 根都随各自持有方 dispose。
+`@deepseek-ai/dsh-client-ui-renderer` 是带 `immediately` 标记的动态客户端插件。它持有 React slot outlet、SessionProvider 与 observable 到 uSES 的绑定。`slots`、`sessions` 与 `layout` 激活后,它安装 slot 渲染器并提供 `ctx.uiRenderer`。`mount()` hydrate 内核生成的启动 DOM,再通过 layout effect 在浏览器绘制中间帧前将其替换为组装完成的应用。hydrate 后的 spinner 节点会保持动画相位。组装后的树投影当前会话标题,并执行唯一一次上下文级 `renderSlot('root')` 调用。服务、渲染器安装和 React 根都随各自持有方 dispose。
 
 `ui-conversation` 声明 `conversation.input.attachments` 与 `conversation.message.images`,并提供附件数据、回调、经会话授权的图片加载及其 locale seat。`ui-attachment` 通过 `ctx.slots.inject()` 等待这些声明,再注册草稿附件栏/拖放目标和历史图片画廊/灯箱。React 实现仍是包内值;跨插件组合通过 slot 完成。这项包集成决策取代[附件展示 Note](../feature/2026-08-11-web-attachment-display-alignment.md)中的直接导入规则,但不改变该 Note 的视觉与交互决策。
 
-ui-theme 从客户端 entry 导入自己的五份全局样式表。共享客户端 bundle 预设会编译普通 CSS 与 CSS Modules,并在 bundle 物化时注入插件持有的 style 标签,因此卸载或重载 ui-theme 时,其全局 CSS 会随服务的同一生命周期删除或替换。Web 内核只保留挂载默认值与自给自足的启动页配色。
+ui-theme 从客户端 entry 导入自己的五份全局样式表。共享客户端 bundle 预设会编译普通 CSS 与 CSS Modules,并在 bundle 物化时注入插件持有的 style 标签,因此卸载或重载 ui-theme 时,其全局 CSS 会随服务的同一生命周期删除或替换。Web 内核只保留挂载默认值,以及字体和颜色与对应主题 token 一致的自给自足启动页配色。
 
 React、React DOM、Cordis、ui-slots 与 ui-primitives 仍是保持单一浏览器身份的静态平台模块。动态 ui-renderer bundle 消费这些共享模块并持有渲染副作用。
 
 ## 验证
 
-组件测试固定启动页、文档标题、应用树、附件 entry 与 dispose 行为。组装后的构建 bundle 启动测试会运行真实模块表与动态 entry,客户端 bundle CSS 测试则证明全局样式会编译成受监视、由插件持有的注入器。浏览器回放测试覆盖从不依赖框架的页面到渲染应用的完整交接。
+组件测试固定启动页、hydrate 不改变启动 DOM、文档标题、应用树、附件 entry 与 dispose 行为。组装后的构建 bundle 启动测试会运行真实模块表与动态 entry,客户端 bundle CSS 测试则证明全局样式会编译成受监视、由插件持有的注入器。浏览器回放测试覆盖从不依赖框架的页面到渲染应用的完整交接。
 
 ## 备选方案
 

+ 2 - 2
packages/client/ui-renderer/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-renderer/README.md
-README.md: 481c3aad4898ed2ecf7b5514b5c21a175caedef0
-README.zh.md: ca9f527f682664999afb87cca7ab6976ffb42012
+README.md: f63f1eff99d8d3357280f81648899bb68f5e2ef9
+README.zh.md: f3fddebeee14f69877fac03d34baf3b51b91ddc9

+ 1 - 1
packages/client/ui-renderer/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-The browser Cordis plugin that owns the React rendering layer. [`dsh-client-web`](../web/README.md) renders a framework-free boot page and loads the complete client plugin roster; after every entry activates, it calls `ctx.uiRenderer.mount(container)`. This package provides that service, installs the slot renderer, creates the React root, and returns its unmount disposer.
+The browser Cordis plugin that owns the React rendering layer. [`dsh-client-web`](../web/README.md) renders a framework-free boot page and loads the complete client plugin roster; after every entry activates, it calls `ctx.uiRenderer.mount(container)`. This package provides that service, installs the slot renderer, hydrates the existing boot DOM, switches to the assembled application before the next paint, and returns the React root's unmount disposer.
 
 The client entry also owns the React implementation of slot outlets, session providers, and observable-to-uSES binding. Business plugins pass bare observable sources through typed slot `hooks`; the renderer binds them at the outlet. The plugin activates after `slots`, `sessions`, and `layout`, projects the selected session title, and performs the sole context-level `renderSlot('root')` call. React, React DOM, Cordis, ui-slots, and ui-primitives retain one browser identity through the web shell's static module table; this package arrives as a dynamic client bundle.
 

+ 1 - 1
packages/client/ui-renderer/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-负责 React 渲染层的浏览器 Cordis 插件。[`dsh-client-web`](../web/README.md) 渲染不依赖框架的启动页并加载完整的客户端插件名册;所有 entry 激活后,它调用 `ctx.uiRenderer.mount(container)`。本包提供该服务、安装 slot 渲染器、创建 React 根,并返回卸载 disposer。
+负责 React 渲染层的浏览器 Cordis 插件。[`dsh-client-web`](../web/README.md) 渲染不依赖框架的启动页并加载完整的客户端插件名册;所有 entry 激活后,它调用 `ctx.uiRenderer.mount(container)`。本包提供该服务、安装 slot 渲染器、hydrate 现有启动 DOM、在下一次绘制前切换到组装完成的应用,并返回 React 根的卸载 disposer。
 
 client entry 还持有 slot outlet、会话 provider 以及 observable 到 uSES 绑定的 React 实现。业务插件通过带类型的 slot `hooks` 传递裸 observable source;渲染器在 outlet 处完成绑定。插件在 `slots`、`sessions` 和 `layout` 就绪后激活,投影当前会话标题,并执行全程序唯一一次上下文级 `renderSlot('root')` 调用。React、React DOM、Cordis、ui-slots 和 ui-primitives 通过 web 外壳的静态模块表保持同一浏览器身份;本包则以动态客户端 bundle 到达。
 

+ 35 - 3
packages/client/ui-renderer/src/client/index.ts

@@ -3,7 +3,9 @@
  * dependencies activate and exposes the mount operation used by the web boot
  * kernel after the complete client roster settles.
  */
-import { createRoot } from 'react-dom/client'
+import { createElement, useLayoutEffect, useState, type ReactNode } from 'react'
+import { flushSync } from 'react-dom'
+import { createRoot, hydrateRoot, type Root } from 'react-dom/client'
 import type { Context } from '@deepseek-ai/cordis'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import { createSlotRenderer } from './scoped-slots.tsx'
@@ -42,6 +44,37 @@ declare module '@deepseek-ai/cordis' {
 /** Services required before application assembly. */
 export const inject = ['slots', 'sessions']
 
+interface BootSnapshot {
+  className: string
+  html: string
+}
+
+/** Hydrate the kernel-owned loading DOM before replacing it with the application. */
+function BootHandoff(props: { app: () => ReactNode; boot: BootSnapshot }): ReactNode {
+  const [ready, setReady] = useState(false)
+  useLayoutEffect(() => { setReady(true) }, [])
+  if (ready) return props.app()
+  return createElement('div', {
+    className: props.boot.className,
+    'data-dsh-boot': '',
+    dangerouslySetInnerHTML: { __html: props.boot.html },
+  })
+}
+
+/** Mount React while preserving the framework-free boot DOM through hydration. */
+function mountApp(container: HTMLElement, app: () => ReactNode): Root {
+  const boot = container.querySelector<HTMLElement>(':scope > [data-dsh-boot]')
+  if (boot !== null) {
+    return hydrateRoot(container, createElement(BootHandoff, {
+      app,
+      boot: { className: boot.className, html: boot.innerHTML },
+    }))
+  }
+  const root = createRoot(container)
+  flushSync(() => { root.render(app()) })
+  return root
+}
+
 /**
  * Install the slot renderer and provide the application mount face.
  * @param ctx - Plugin context.
@@ -50,8 +83,7 @@ export function apply(ctx: Context): void {
   ctx.slots.install(createSlotRenderer())
   ctx.reflect.provide('uiRenderer', {
     mount: (container: HTMLElement): (() => void) => {
-      const root = createRoot(container)
-      root.render(buildRenderApp({ ctx })())
+      const root = mountApp(container, buildRenderApp({ ctx }))
       return () => { root.unmount() }
     },
   })

+ 22 - 1
packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx

@@ -1,5 +1,5 @@
 // @vitest-environment jsdom
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup } from '@testing-library/react'
 import { Context } from '@deepseek-ai/cordis'
 import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
@@ -12,6 +12,7 @@ const mounted: (() => void)[] = []
 
 afterEach(() => {
   act(() => { for (const unmount of mounted.splice(0)) unmount() })
+  vi.restoreAllMocks()
   cleanup()
   document.body.innerHTML = ''
 })
@@ -50,6 +51,26 @@ describe('UI renderer plugin', () => {
     expect(el.querySelector('[data-testid="root-probe"]')).toBeTruthy()
   })
 
+  it('hydrates the boot page before switching to the assembled application', async () => {
+    const { ctx, slots } = await bench()
+    slots.register({ name: 'root' }, () => <div data-testid="root-probe" />)
+    const el = container()
+    el.innerHTML = '<div class="boot" data-dsh-boot=""><div><div class="spinner"></div><div>Loading plugins…</div></div></div>'
+    const boot = el.firstElementChild
+    const observer = new MutationObserver(() => {})
+    observer.observe(el, { childList: true, subtree: true })
+    const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+    act(() => { mounted.push(ctx.get('uiRenderer')!.mount(el)) })
+
+    const records = observer.takeRecords()
+    observer.disconnect()
+    expect(error).not.toHaveBeenCalled()
+    error.mockRestore()
+    expect(el.querySelector('[data-testid="root-probe"]')).toBeTruthy()
+    expect(records.some(record => record.target === boot)).toBe(false)
+  })
+
   it('returns an unmount disposer', async () => {
     const { ctx, slots } = await bench()
     slots.register({ name: 'root' }, () => <div data-testid="root-probe" />)

+ 2 - 2
packages/client/web/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/web/README.md
-README.md: 357d414c6c4c293de98ca88617d4ade8569ed98d
-README.zh.md: 5304d8d858ccb8893a2174372353a72b3e033cb1
+README.md: 82d4c38d4b66818e406038e8fbaf7dbdc07a07a0
+README.zh.md: 047249e8607a6b38ff74f09515dc27eb3e7fe31d

+ 2 - 2
packages/client/web/README.md

@@ -2,9 +2,9 @@
 
 English | [中文](README.zh.md)
 
-Web boot kernel: `new AppWebEntry(el, seams?).run()` mounts the client through two stages. The module stage builds `@deepseek-ai/dsh-client-modules` over the host-provided `window.__DSH_BOOT__` graph and prefetches the `immediately` tier. The plugin stage mounts the vendored Cordis Loader, injects that module system through the Loader's `internal` interface, creates every graph entry, and waits for every fiber to become ACTIVE. It then calls the dynamic UI renderer's `ctx.uiRenderer.mount(el)` operation, replacing the boot page with the complete UI. The host graph owns the roster and prefetch marks; this package adds only the statically adopted modules bootstrap entry.
+Web boot kernel: `new AppWebEntry(el, seams?).run()` mounts the client through two stages. The module stage builds `@deepseek-ai/dsh-client-modules` over the host-provided `window.__DSH_BOOT__` graph and prefetches the `immediately` tier. The plugin stage mounts the vendored Cordis Loader, injects that module system through the Loader's `internal` interface, creates every graph entry, and waits for every fiber to become ACTIVE. It then hands the marked boot DOM to the dynamic UI renderer's `ctx.uiRenderer.mount(el)` operation; the renderer hydrates that DOM before switching to the complete UI. The host graph owns the roster and prefetch marks; this package adds only the statically adopted modules bootstrap entry.
 
-The boot page uses plain DOM and local CSS, so client-bundle and plugin-activation failures remain visible. React mounting, slot rendering, application assembly, and browser-title projection live in [`ui-renderer`](../ui-renderer/README.md). The modules package is the only plugin package registered through `registerStatic`, because the module system cannot load itself.
+The boot page uses plain DOM and local CSS, so client-bundle and plugin-activation failures remain visible. Its fallback fonts and colors match the theme tokens that arrive during loading, and hydration keeps the spinner DOM and animation phase until the application commit. React mounting, slot rendering, application assembly, and browser-title projection live in [`ui-renderer`](../ui-renderer/README.md). The modules package is the only plugin package registered through `registerStatic`, because the module system cannot load itself.
 
 `PLATFORM_MODULES` (src/platform.ts) is the single source of truth for shared modules: seed-table keys, tsdown client externals, and the Vite alias set are its projections.
 

+ 2 - 2
packages/client/web/README.zh.md

@@ -2,9 +2,9 @@
 
 [English](README.md) | 中文
 
-Web 启动内核:`new AppWebEntry(el, seams?).run()` 分两个阶段挂载客户端。模块阶段基于宿主提供的 `window.__DSH_BOOT__` 图构建 `@deepseek-ai/dsh-client-modules`,并预取 `immediately` 层级。插件阶段挂载仓库内置的 Cordis Loader,通过 Loader 的 `internal` 接口注入该模块系统,创建全部图 entry,并等待每个 fiber 进入 ACTIVE。随后它调用动态 UI 渲染器的 `ctx.uiRenderer.mount(el)` 操作,以完整 UI 替换启动页。名册与预取标记归宿主图所有;本包只额外加入静态接纳的 modules 启动 entry。
+Web 启动内核:`new AppWebEntry(el, seams?).run()` 分两个阶段挂载客户端。模块阶段基于宿主提供的 `window.__DSH_BOOT__` 图构建 `@deepseek-ai/dsh-client-modules`,并预取 `immediately` 层级。插件阶段挂载仓库内置的 Cordis Loader,通过 Loader 的 `internal` 接口注入该模块系统,创建全部图 entry,并等待每个 fiber 进入 ACTIVE。随后它把带标记的启动 DOM 交给动态 UI 渲染器的 `ctx.uiRenderer.mount(el)` 操作;渲染器先 hydrate 该 DOM,再切换到完整 UI。名册与预取标记归宿主图所有;本包只额外加入静态接纳的 modules 启动 entry。
 
-启动页只使用原生 DOM 与本地 CSS,因此客户端 bundle 或插件激活失败时仍能显示。React 挂载、slot 渲染、应用组装和浏览器标题投影位于 [`ui-renderer`](../ui-renderer/README.md)。modules 包是唯一通过 `registerStatic` 注册的插件包,因为模块系统无法加载自身。
+启动页只使用原生 DOM 与本地 CSS,因此客户端 bundle 或插件激活失败时仍能显示。其回退字体和颜色与加载期间到达的主题 token 一致,hydrate 会保留 spinner DOM 及其动画相位,直到应用提交。React 挂载、slot 渲染、应用组装和浏览器标题投影位于 [`ui-renderer`](../ui-renderer/README.md)。modules 包是唯一通过 `registerStatic` 注册的插件包,因为模块系统无法加载自身。
 
 `PLATFORM_MODULES`(src/platform.ts)是共享模块接口的唯一真源:种子表 key、tsdown 客户端 external 和 vite alias 集都是它的投影。
 

+ 15 - 3
packages/client/web/src/base.css

@@ -9,13 +9,25 @@ body,
 }
 
 body {
-  font-family: var(--dsw-font-family);
+  font-family: var(
+    --dsw-font-family,
+    -apple-system,
+    BlinkMacSystemFont,
+    'Segoe UI',
+    'PingFang SC',
+    'Hiragino Sans GB',
+    'Microsoft YaHei',
+    'Helvetica Neue',
+    Helvetica,
+    Arial,
+    sans-serif
+  );
   /* Grayscale antialiasing over subpixel rendering: WebKit/Blink and the
      Firefox macOS equivalent; other engines ignore both lines. */
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
-  color: var(--dsw-alias-label-primary);
-  background: var(--dsw-alias-bg-base);
+  color: var(--dsw-alias-label-primary, #0f1115);
+  background: var(--dsw-alias-bg-base, #fff);
 }
 
 /* Form controls don't inherit the body font (UA sheets pin their families —

+ 14 - 3
packages/client/web/src/boot-page.module.css

@@ -1,12 +1,12 @@
 /* The framework-free boot page cannot depend on theme delivery succeeding. */
 
 .boot {
-  --dsh-boot-bg: #f9fafb;
+  --dsh-boot-bg: #fff;
   --dsh-boot-label-primary: #0f1115;
   --dsh-boot-label-secondary: #61666b;
   --dsh-boot-label-tertiary: #81858c;
   --dsh-boot-border: rgb(0 0 0 / 10%);
-  --dsh-boot-brand: #3964fe;
+  --dsh-boot-brand: #0f1115;
 
   height: 100%;
   display: grid;
@@ -72,7 +72,18 @@
 }
 
 .failedItem {
-  font-family: var(--ds-font-family-code, ui-monospace, 'SF Mono', Menlo, Consolas, 'Courier New');
+  font-family: var(
+    --ds-font-family-code,
+    'SF Mono',
+    'JetBrains Mono',
+    'Fira Code',
+    Consolas,
+    'Liberation Mono',
+    Menlo,
+    Courier,
+    'PingFang SC',
+    'Microsoft YaHei'
+  );
   font-size: 12px;
   line-height: 18px;
   color: var(--dsw-alias-label-secondary, var(--dsh-boot-label-secondary));

+ 1 - 0
packages/client/web/src/boot-page.ts

@@ -27,6 +27,7 @@ export class BootPage {
    */
   constructor(container: HTMLElement) {
     this.root = div(css.boot)
+    this.root.dataset.dshBoot = ''
     this.card = div(css.card)
     this.card.append(div(css.wordmark, 'HARNESS'))
     this.root.append(this.card)

+ 0 - 1
packages/client/web/src/boot.ts

@@ -82,7 +82,6 @@ export class AppWebEntry {
   private async mountApp(ctx: Context): Promise<void> {
     const mounted = ctx.inject(['uiRenderer'], (scope) => {
       scope.effect(() => scope.uiRenderer.mount(this.container), 'web boot: application mount')
-      this.page.dispose()
     })
     await mounted
   }

+ 1 - 0
packages/client/web/tests/boot-page.client.spec.ts

@@ -13,6 +13,7 @@ function mount() {
 describe('BootPage', () => {
   it('draws the loading skeleton before any plugin state arrives', () => {
     const { el } = mount()
+    expect(el.firstElementChild?.getAttribute('data-dsh-boot')).toBe('')
     expect(el.textContent).toContain('HARNESS')
     expect(el.textContent).toContain('Loading plugins…')
   })