From fb0fb4803381072525f2f9edd06b3e6ca3de6f4e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 14 Sep 2026 21:47:38 +0800 Subject: [PATCH 1/5] feat(web): apply client plugin changes without reloading --- ...7-23-client-plugin-loading-model.i18n.yaml | 4 +- .../2026-07-23-client-plugin-loading-model.md | 12 +- ...26-07-23-client-plugin-loading-model.zh.md | 12 +- apps/web/tests/client-plugin-live.e2e.ts | 131 ++++++ .../client-plugin-live/enabled.expected.md | 1 + .../plugins/fixture-live-client/client.js | 31 ++ .../fixture-live-client/cordis.patch.yml | 3 + .../plugins/fixture-live-client/index.js | 2 + .../plugins/fixture-live-client/package.json | 13 + apps/web/tsconfig.json | 1 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/client-modules.i18n.yaml | 4 +- docs/subsystems/client-modules.md | 2 +- docs/subsystems/client-modules.zh.md | 2 +- packages/client/hmr/README.i18n.yaml | 4 +- packages/client/hmr/README.md | 16 +- packages/client/hmr/README.zh.md | 16 +- packages/client/hmr/package.json | 2 +- packages/client/hmr/src/client/index.ts | 166 +------- packages/client/hmr/src/events.ts | 10 +- packages/client/hmr/src/index.ts | 44 +- .../client/hmr/tests/node-half.client.spec.ts | 80 ++++ .../client/hmr/tests/transport.client.spec.ts | 42 ++ packages/client/hmr/tsconfig.json | 4 +- packages/client/modules/README.i18n.yaml | 4 +- packages/client/modules/README.md | 10 +- packages/client/modules/README.zh.md | 10 +- packages/client/modules/package.json | 3 +- packages/client/modules/src/client/entries.ts | 230 ++++++++++ .../modules/src/client/entry-lifecycle.ts | 28 ++ packages/client/modules/src/client/index.ts | 2 + .../client/modules/src/client/manifest.ts | 3 + packages/client/modules/src/client/system.ts | 46 +- .../modules/tests/entries.client.spec.ts | 393 ++++++++++++++++++ .../modules/tests/loader.client.spec.ts | 26 ++ packages/client/modules/tsconfig.json | 11 +- .../README.i18n.yaml | 4 +- .../ui-settings-plugin-inventory/README.md | 2 + .../ui-settings-plugin-inventory/README.zh.md | 2 + .../ui-settings-plugin-inventory/package.json | 7 +- .../src/client/PluginInventorySettingsTab.tsx | 19 +- .../src/client/index.ts | 9 +- .../src/client/locales.ts | 6 + .../tests/browser-plugin.client.spec.tsx | 14 +- .../tests/components.client.spec.tsx | 22 + .../tsconfig.json | 6 + packages/client/web/README.i18n.yaml | 4 +- packages/client/web/README.md | 2 + packages/client/web/README.zh.md | 2 + packages/client/web/src/boot-client.ts | 10 +- .../client-runtime/README.i18n.yaml | 4 +- .../test-support/client-runtime/README.md | 2 +- .../test-support/client-runtime/README.zh.md | 2 +- .../src/assembly/test-client.ts | 4 +- pnpm-lock.yaml | 9 + tsconfig.host.json | 1 + vitest.config.ts | 2 - 59 files changed, 1273 insertions(+), 236 deletions(-) create mode 100644 apps/web/tests/client-plugin-live.e2e.ts create mode 100644 apps/web/tests/expected/client-plugin-live/enabled.expected.md create mode 100644 apps/web/tests/fixtures/plugins/fixture-live-client/client.js create mode 100644 apps/web/tests/fixtures/plugins/fixture-live-client/cordis.patch.yml create mode 100644 apps/web/tests/fixtures/plugins/fixture-live-client/index.js create mode 100644 apps/web/tests/fixtures/plugins/fixture-live-client/package.json create mode 100644 packages/client/hmr/tests/transport.client.spec.ts create mode 100644 packages/client/modules/src/client/entries.ts create mode 100644 packages/client/modules/src/client/entry-lifecycle.ts create mode 100644 packages/client/modules/tests/entries.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index 07c15ff846..e64e8bfe2c 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.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-07-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: d0f9b20f0adabda6cc7132e5411bcadd60c9e108 -2026-07-23-client-plugin-loading-model.zh.md: 27d5026ed03509d6408a16389246cb1321a37959 +2026-07-23-client-plugin-loading-model.md: 924459599a63070fc4a3cc89d32ea0e18b936dc2 +2026-07-23-client-plugin-loading-model.zh.md: ab8a19ee92601e6e5038ed764802084cd8082b6f diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index d0f9b20f0a..924459599a 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -68,25 +68,31 @@ Why is the roster yml rows and not a scan? Because which plugins compose into a 4. `settled` = every entry created + `loader.await()` quiescent + an all-ACTIVE sweep. The sweep lists each import-failed, FAILED, or PENDING fiber with its missing services. It exists because cordis inject waits have no timeout — the sweep is the fail-loud floor. 5. The framework-free loading page projects real fiber states via `internal/status`. After the sweep, the kernel calls `ctx.uiRenderer.mount(container)` and replaces the page with the real UI in one pass. +### Live graph reconciliation + +The modules controller owns exactly the Loader entries created from the boot manifest. Full Host snapshots update its module descriptors and reconcile those entries; other Loader contributors retain ownership. New modules arrive through single-resource URLs because replaying a startup batch could register an existing factory twice. Removal uses Loader deletion, then drains the captured fiber before removing unused modules and styles. Declared and observed transitive requests keep shared modules alive. + +The Host SSE adapter waits for Loader imports, activation and captured removed-fiber cleanup, then sends the complete current graph, including on reconnect. Graph reconciliation and rebuilt code share one page-owned queue. Local generations prevent stale downloads from mounting; opaque revisions are compared only for equality. The settings inventory reports page-local errors and can retry the same graph without changing Host enablement. This preserves unrelated page state and avoids both an application restart and a second plugin executor. Electron's independent installation flow remains outside this mechanism. + ### Hot reload: one driver plugin, self-watched bundles Hot reload is a composition decision: the web bundle mounts the `client-hmr` row (a normal plugin package) unconditionally; its node half brings the bundle watch and the SSE channel, and the chain stays idle until a rebuild watcher rewrites client bundles. A composition that must not expose it disables the row. How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the bundle stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids both an initial all-row re-hash and `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a bundle mtime or size delta, or a dirty row, `rebuilt(id)` is the single re-hash entry point; it reads the current source map as part of that new artifact snapshot, while a map-only write does not remount unchanged executable code. When the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; one bundle stat per row and interval is sufficient, the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn bundle read self-heals: its stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev. -On the browser side, the driver reloads one plugin per frame, serialized: +On the browser side, the transport delegates code replacement to the same modules controller that owns graph reconciliation: 1. `invalidate` — drop the stale factory and record, and bind the rebuilt frame's revision to that row's one-resource combo URL. A live factory would make the next step a no-op. 2. `prefetch` — load that one-resource external script and register the fresh factory while the old fiber still serves. The initial multi-resource script never executes again. 3. `registry.delete` — before touching the fiber. A bare fiber dispose trips the vendored Loader's self-dispose branch, which would disable the entry permanently. 4. Drain the old fiber's disposers. 5. Remove owned `