feat(gui): step1 skeleton — dsc web serves built web UI over booted harness host
Five new modules: apps/dsc (bin: parseArgs + node:http static server +
signal shutdown), packages/host/apiproxy (programmatic harness core
composition, agents:[]), packages/client/web-runtime (React-free browser
runtime), packages/client/web-ui (React mount), apps/web (vite build
entry producing dist consumed by apps/dsc via package exports).
Root wiring: apps/* workspace glob, dsh-* paths for host/client groups,
demo:web script, apps/web/dist gitignore. No protocol/API routes yet —
contract lands in step2 (see missions/tasks/20260719-1902-apiproxy-api-design).
Includes the design + implementation archives (spec v2.1, deepseekchat
baseline and harness boot research, implementation run log).
Acceptance: 12/12 passed incl. real-key llm.stream smoke (51 chunks).
feat(gui): apiproxy — four-quadrant RPC contract + fetch carriers, live end to end
Contract layer (src/api/, 14 files): four named wire message types
(ClientRequest / ServerResponse / ServerRequest / ClientResponse) as a
discriminated union over strict bidirectional rpcId (initiator mints,
responder echoes; channel and message fully decoupled — HTTP is the
client->server pipe, SSE the reverse); narrow RpcRequest<P>/
RpcResponse<T> signature forms; RpcMethodMap with RequestPayload<K>/
ResponseValue<K> derivation; typed RpcError details map; approval/
question responses modeled as ClientResponse via a single /api/respond
endpoint (RpcReceipt carrier ack); zod schemas anchored per Wire<T>
against exactOptionalPropertyTypes.
impl/api-proxy.ts: describe/list/create, both SSE streams (frame queue
pump, subscribed baseline, lifecycle frames, signal cleanup); history
pages on message boundaries (tail-back scan, partial included in the
tail page); prompt dispatches queue->agent.send / steer->agent.steer
with rpcId carried through MessageSource; cancel for attached sessions;
cold-session resume deduped via a per-id promise map; host-level
provider/model defaults injected at create/resume.
fetch/: mechanical UNARY_ROUTES table, two-level parse with
path==method check, SSE frames completed to ServerRequest full form;
client mints -> narrows -> envelopes outbound, verifies rpcId echo
inbound, streams SSE frames, four-quadrant onEnvelope tap (debug panel
choke point). Real-browser fixes: URL base resolves to location.origin
(hardcoded internal base broke real pages), browser-safe export paths.
Design archives: contract design.md v2.0 with decision log,
core-coverage audit, comparative studies, step2 impl run log. Probed
end to end over real HTTP: prompt -> live model stream -> history
returns the finished reply.
feat(gui): RpcLog debug panel — fixture-driven milestone, playwright-verified 10/10
web-runtime: rpcLog + ui slices (zustand), four-quadrant RpcLogEntry
(client-request / server-response / server-request / client-response),
onEnvelope tap -> microtask-batched pump with 500-entry ring buffer,
ConnectionController (private state, backoff reconnect), fixture API
with fake envelopes (?fixture switch), bootWebRuntime; contract types
via temporary local copies (api-types.ts, swapped for real imports when
W3 client lands).
web-ui: components/panels/RpcLog five-piece set (badge with unread
count, floating panel, direction glyphs per quadrant, same-rpcId
pair highlighting in two families, JSON payload expand, follow/pause,
clear), App shell, utils/formatRelative, light-theme CSS variables with
dark placeholders.
dsc bin: mime lookup fixed to use the actually-served file (naked
'/?query' no longer falls through to octet-stream download); shutdown
closes SSE keep-alive connections so SIGTERM actually exits.
Acceptance: scripts/verify-rpclog-panel.mjs (chromium headless) ALL
PASS 10/10 over design.md §D 1-6.
pkg: add web scripts for building
feat(gui): session milestone — list + conversation over Session OOP, styled RpcLog v2.1
web-runtime: Session/SessionManager object layer (resident instances,
mux frame routing, lineage flattening), foldSurface adapter with padding
sentinels for paged windows, chunk accumulator for streaming partials,
batched change notification (useSyncExternalStore contract), connection
sinks + reconnect fix (the 300ms self-abort reconnect storm that made
the session list flap is gone), fixture rewritten as a scripted host
(60-turn history, typewriter replay, resident pending approval, child
session); temporary contract copies deleted in favor of real apiproxy
imports.
web-ui: sessions screen (list with lineage indent + selection as
container-local state), conversation view (turn grouping, reasoning
fold, tool cards, steering, pending interaction cards, upward paging
with scroll anchoring), input bar with queue/steer/stop; RpcLog panel
restyled per docs/web-styling.md (tokenized palette, quadrant badge
glyphs now vertical ↑↓⇟⇞, pair highlighting, floating shadow).
docs/web-styling.md: living style guide (tokens, visual baseline,
coding rules, evolution log).
Acceptance: verify-session.mjs 31/31, verify-session-real.mjs 5/5
(real model streaming), verify-rpclog-panel.mjs 10/10.
feat(gui): hostruntime split + repo-wide package prefix rename
Package split (design: 20260720-0101-hostruntime-split-design):
dsh-host-runtime carries bootHost + createApiProxy + startHost()
(RunningHost {api, handler, defaults, ctx, dispose} — the seam Electron
and any future shell reuses; ctx is the official front-door mount
point); dsh-host-webserver carries the node:http static+API bridge
(fixed: abort now keys on res 'close' + writableEnded — req 'close'
fires on body end since Node 16 and was killing every SSE stream
instantly, the reconnect-storm root cause); apps/dsc is now a thin
assembly with web/-p subcommands. dsc -p runs the full isomorphic
carrier chain in process (second real protocol consumer; probed
end-to-end against the live model).
Naming rule (user decree): packages under host/ and client/ carry the
directory prefix in their npm name — dsh-host-apiproxy,
dsh-client-web-runtime, dsh-client-web-ui renamed repo-wide in one
frozen batch; explicit tsconfig paths entries added where the wildcard
no longer matches.
Acceptance: verify-session 31/31, verify-rpclog-panel 10/10,
verify-session-real 7/7 (incl. new 12s connection-stability sentinels),
tsc green, dsc web + dsc -p smoke both pass.
refactor(gui): AbstractApiClient class hierarchy — OO client with inheritable seams
AbstractApiClient (apiproxy) carries every protocol invariant: rpcId
minting, four-quadrant envelope wrap/unwrap, zod parsing, SSE frame
parsing, the payload-direct IApiClient surface (callers no longer mint
rpcIds — the carrier does), and the instance-level envelope observation
pump (batched via microtask; moved off module-level globals in
rpc-log.ts, which is now a pure subscriber mapping envelopes into store
entries — the debug panel observes the connection, it is not part of
it).
Platform subclasses own two abstract seams (doFetch, onEnvelope) plus
three protocol-level virtuals for transportless overrides:
InProcessApiClient (apiproxy; dsc -p uses new InProcessApiClient(
host.handler)), WebApiClient (web-runtime), FixtureApiClient (fixture
now subclasses instead of wrapping). Naming per decree: AbstractApiClient
/ IApiClient; ApiProxy stays the impl-side narrow-form contract.
headless.ts call sites drop rpcRequest wrappers (payload-direct);
split-design archive updated with the naming-rule ledger.
tsc green; verify-session 31/31, verify-rpclog-panel 10/10,
verify-session-real 7/7 (12s connection sentinel count=4); dsc -p smoke
CALLER-OK.
feat(gui): InputBar final form — bug batch, deepseekchat layout, single primary button, running locks input
Squashes the whole InputBar iteration batch: IME/caret/auto-grow/focus/dedup
bug fixes, layout aligned to the deepseekchat baseline, single primary button
with hover flyout, finalized button semantics with the Codex-style icon
circle, and running-state locking where stop is the only mid-turn action.
The same batch carried the Chinese-to-English code comment sweep
(density pruned), folded in here.
docs(gui): purge work-log references from code comments
76 design-doc references cleared across the GUI packages: section
pointers inlined as self-contained constraint statements, pure pointer
comments dropped, milestone codenames and ruling tags out, and the 14
contract file headers switched to the formal RFC (the only sanctioned
external reference). web-styling.md now cites the styling RFC instead
of the disposable research archive. grep for work-log reference
variants is clean across the GUI packages.
docs(gui): file-header comments self-contained — drop RFC filename references
RFC renames/reorgs must not require a source sweep (the 2026-07-20
two-way merge proved it). 11 headers lose only the '(RFC …)' tail and
stay self-contained; api-proxy.ts keeps its minimal-first note.
fix(gui): session streaming — freeze interrupted partials, sweep stale running calls, send force-scrolls
Aborted turns never emit the finalizing assistant/message, so the
accumulated partial and its running tool cards kept rendering below
later messages — the "new message lands above the stopped reply"
illusion. turn/end side effects now freeze content-bearing partials
into interrupted terminal nodes (fractional seq keeps flow order; the
live freeze and history replay converge through applyEventSideEffects,
so a refresh reconstructs identical frozen nodes) and turn running tool
cards into interrupted terminal cards; only content-free partials are
swept outright. ConversationView gains the send-force-scroll rule (own
words must be visible) alongside the pre-update atBottom follow flag.
Regressions pinned as E2-4a–c (real host) and §E1-11h (fixture).
feat(gui): webserver hardening verify script
feat(gui): dark-mode toggle pinned to the sidebar bottom
Interim home before the Settings page exists (the button re-homes with
zero logic change — mechanics live in utils/theme.ts): html[data-theme]
flip + dsc.theme localStorage, stored choice wins over the OS
prefers-color-scheme default, applied in mount() before first paint so
a dark reload never flashes light. Moon/sun inline SVG icon button at
the sidebar's pinned bottom row. Pure front-end local concern: no RPC,
no Session/store involvement. Dark sweep of list/conversation/input
card/RPC panel found no unreadable pairs — no token changes needed.
docs(gui): GUI RFCs and web styling handbook
Layering+RPC protocol and web client architecture RFCs (post-reorg,
developer-facing polish folded in) plus the styling engineering
handbook. Mission work logs live in the commit above; PRs can be cut
from this commit to include formal docs only.
fix(gui): client object-layer hardening — audit timing/reference/resilience batches (S3-S5,C1-C3,C5-C8)
fix(gui): carrier error channel + webserver backpressure (audit A1-A5,A7-A10,R2,R5)
feat(gui): session persistence surface — cold list, project cwd, legacy no-cwd retirement
refactor: rename dsc CLI to dsh — apps/cli, bin name, package scope
Includes the root tsconfig project-references fix for host/* and
client/web-runtime (originally a separate build fix commit).
test(gui): three-tier suite — protocol/object/browser lanes, tier-a fill to per-file 100%
test(gui): jsdom lane for web-ui + web-runtime coverage gate entry
docs(gui): GUI testing system RFC (zh)
feat(gui): tool-card views — contract slot, host-computed delivery, three-level card fallback
fix(gui): lint clean across GUI packages — wrap long doc comments, drop dead type args, sync-return methods without awaits
docs(gui): doc-sync mechanical fixes — JSDoc on apiproxy/host exports, RFC sketch fences ignore-check, md-wrap paragraphs, drop missions links, web-ui plain-ts entry
chore(gui): module-graph regen + knip clean — drop dead re-exports, internalize createFixtureApi, scan web-ui tsx and verify mjs scripts
build(gui): wire client/host packages into the lib build shape — tsc references + tsdown (web-ui css-external), lib manifests, cordis peer, apiproxy typed subpaths, vite src aliases
test(gui): host-side per-file 100% coverage — apiproxy schema/carrier suites, webserver http-bridge suite, host-runtime composition suite; client/* coverage excluded pending the browser-side testing work item
docs(gui): package READMEs for the five GUI packages — model-experience audit entries, limitations sections
docs(gui): bilingual RFC pairs + client JSDoc completion — translate the three GUI RFCs to English with i18n records and manifest ratchet, Consequences sections both sides, full client/* export JSDoc, regen doc graphs and RFC index
fix(scripts): doc-typecheck built-declarations mode maps /src/* subpath wildcards (apiproxy browser-safe channels)
docs(gui): apply dsh rename across pr-gates docs — READMEs, layering RFC en, web-ui entry comment, i18n re-record
fix(gui): post-rebase lint reconciliation — wrap main-tree long doc comments, read-through narrowing guards, abortError Error normalization, handleUnary generic justification
fix(gui): post-rebase doc/test reconciliation — align host specs with evolved carrier contracts (sentinel rpcId, stream/error surfacing, url-path transport messages, defaults.cwd), Agent Note titles and relocated links, KV Cache effect sections, JSDoc on evolved exports
fix(gui): second-rebase reconciliation to 509db0cb3 — restore api panel exports the baseline suites consume, knip workspace entries for jsdom lane and apps/web smokes, hoist result narrowing, align testing.md to the narrowed web-ui exclusion
fix(test): vitest-scoped tsconfig maps bare imports for tsx specs — with GUI manifests now pointing at lib, an unmapped importer loaded a second copy of the web-runtime singletons
fix(gui): typecheck + lint clean over the tool-card batch — brand callIds and object-form turn/end reason in the view spec, narrow fixture arg stringification, wrap long v8-ignore comments
docs(gui): export JSDoc for tool-card surfaces + testing-note pairing header
docs: rfc for web testing
feat: add tools to host-runtime
fix(gui): dispatch agent/error via agentEvents in host-runtime spec — mounted invariants plugin rejects raw ctx.emit without the scope carrier
fix(gui): restore GUI knip workspaces + scripts/mjs entries and regenerate lockfile after master rebase
fix(gui): post-rebase gate repairs — drop context-node envelope (master unwrapped injected content envelopes), regen event matrix, condense testing.md web-ui exclusion within budget
fix(session): browser-safe deep-equal in surface — node:util import broke the vite bundle
ci(gates): frontend vite build joins pre-push — node: imports in the client closure pass tsc but break the browser bundle
test(tui): drop the checkout-dependent process.cwd() harness default — a long worktree path pushes the footer token counters past the 88-column fake terminal
test(gui): jsdom behavior E2E — conversation main path over fixture runtime, reconnect banner lifecycle
test(gui): jsdom RPC panel behavior — ledger rows, expand, pairing, pause/clear, follow-pause, payload truncation
test(gui): jsdom tier-2 — InputBar guards, reasoning fold, JSON blocks, message variants, theme, create-then-select; act-harden banner case
test(gui): jsdom tier-3 — ConversationView states/paging/force-bottom, ToolCallCard arms, PendingCard, list rows
test(gui): jsdom tails — view-card variants, LogRow directions, registry hygiene, badge overflow, hook ops, mount glue
test(gui): jsdom tails round 2 — call-ref blocks, resume follow, view precedence, failed create, empty-diff arm
test(gui): jsdom final arms — anchor compensation, follow-off, interval ticks, view halves, node-over-running precedence
test(gui): web-ui joins the per-file 100% coverage gate
Annotation-only src changes plus the config swap. The web-ui exclusion is
replaced by a single index.tsx entry (stale byte-identical duplicate of
mount.tsx, nothing imports it; same entry-glue treatment as bin.ts) and the
coverage include gains .tsx.
v8-ignore sites (each with its reason inline):
- ConversationView 3x ref-null guards; InputBar disabled-click guard
- ToolCallCard both-null arms + windowless-custom argsRaw arm
- LogRow css-module key fallbacks (start/stop block); RpcLogBody 3x ref-null guards
- web-runtime drift from the tool-card batch: fixture presenter catch/str
typo-guards, dense-array guards (fold-adapter reset, session rebuild,
fixture backscan), live view-present arm (fixture replays are text-only;
view vocabulary is covered by the history samples)
test(gui): close the PR #443 host-side coverage gaps — apiproxy client abort arms, api-proxy cold/view paths, webserver drain
- apiproxy fetch/client.ts: 3 new cases (pre-aborted signal short-circuits
before transport + string reason mapping, non-Error/string reason falls to
the default AbortError message, signal-less doFetch passthrough)
- runtime/api-proxy.ts: one v8-ignore (summarizeCold cwd arm — list()
filters cwd-less legacy metas) + api-proxy-cold.spec.ts (cold list merge:
mtime source, locate-undefined and vanished-log fallbacks, lineage;
no-persistence/no-factory resume → internal) + 2 view cases (history views
with meta passthrough and orphan/bad-args/presenterless soft-falls,
session/disposed open-call cleanup on the mux stream)
- webserver/index.ts: /api/big fixture drives both drain-wait legs (full
8MiB readback after drain, mid-chunk disconnect wakes via 'close')
feat: app shell
fix: rebase conflicts
fix: coverage
fix(gui): lint clean after rebase — wrap long v8-ignore comments, unconditional v1 detail-block claim
chore(gui): remove browser/probe verify scripts from scripts/
The six GUI acceptance/probe scripts (carrier-errors, rpclog-panel,
session, session-real, webserver-backpressure, webserver-hardening)
leave the repo's scripts/ tree; the three code comments that pointed at
them now describe the coverage lane without naming a script path.
fix(webserver): guard the request callback — one malformed request must not kill the process
The async handle() had no top-level catch, so any throw inside it (a bad
%-escape reaching decodeURIComponent, a client dropping mid-body, a
response stream erroring) became an unhandled rejection and took the whole
process down (audit R1 must-fix). The guard answers 400 when headers are
not out yet, destroys the socket when they are, and reports the failure to
onError (the package never prints). Spec covers all three legs: %-escape
barrage → 400 + server stays alive, non-Error throw wrapped for onError,
mid-stream explosion → socket teardown.
feat: client AGENTS.md
fix: client/AGENTS.md
fix: rebase
feat(gui): T0 cut 1 — 12 client package skeletons with contract stubs, dshClient declarations, tsdown client preset, theme token sheets
feat(gui): T0 cut 2 — pure git mv migration per v3 §11 (connection six, runtime sessions/kernel, ui-conversation chat, ui-primitives markdown family, web shell + e2e)
feat(gui): T0 cuts 3+4 — import rewiring to new package names, .legacy demotion of owner-rewrite files, legacy web-runtime/web-ui/apps-web retired to attic
feat(gui): connection 对账刀——index.ts 精确导出清单替换 export *,intents.legacy 溶解删除
feat(client/ui-slots): SlotCore real implementation — kind semantics, sync version + microtask-batched notify, onMutate bridge
feat(gui): web shell vite alias — retarget to new client packages, shell static surface only
feat(gui): host 侧刀属地半——HostWebPluginRegistry(entries 扫描+internal/plugin 去抖重扫+dshClient 校验+exports./client 解析)、GET /plugins/<id>/client.js 分发端点、GET / 与 SPA fallback 注入 __DSH_BOOT__(webPlugins 可选注入,不传行为不变)
feat(web-react): add use-sync-external-store dep + local shim typings
feat(web-react): bindSnapshotSelector via uSES with-selector shim
feat(gui): ui-layout concession-chain solver — pure computeColumns with contract geometry
feat(gui): ui-layout LayoutService — four persisted stores, clamped actions, list-driven prune
feat(gui): ui-layout AppFrame styles — grid columns, collapse-safe borders, edge drag handles
test(gui): 存量 spec 平移——connection 三件+runtime 六件自 attic 捞回改包名路径全绿;api-helpers 按归属拆分(wire 半留 connection、classifier 半随 conversation.ts 入 runtime);boot-intents/preinit/rpc-log 随 intents/rpc-log 退役不迁(记 v3 §3.2 溶解项)
feat(client/ui-primitives): StateDot/Button/Pill/Input/Menu atoms, ConnectionBanner de-legacied to pure props, JsonBlock CSS on --dsw tokens
feat(web-react): createSnapshotStore engine (rafFlush batch, persist opt-in, dev freeze) + spec
feat(gui): ui-layout AppFrame — grid tracks, pointer-capture drag handles with rAF throttle, frame ResizeObserver
feat(web-react): useInvoke (external pending store, stable invoke, concurrency count) + spec
test(web-react): bind spec — equality bail, custom eq, zero resubscribe, StrictMode, method sources
feat(gui): ui-layout index rewiring — real exports, client apply provides ctx.layout and defines three slots
feat(web-react): SessionProvider (renderBody deps) + RootBindingProvider + binding contexts + spec
feat(gui): web shell AppRoot boot-page styles — self-contained with neutral token fallbacks
feat(gui): web shell AppRoot — boot gate over loader status, fail-loud plugin failure list
fix(gui): AppRoot gates on explicit settled signal — status-derived readiness races the incrementally filled table
feat(client/ui-theme): ThemeService real implementation — registry with built-in light/dark, apply toggles body[data-ds-dark-theme], third-party token overrides as body inline vars
feat(web-react): scopedSlots outlet (kind matrix, inject WeakMap caches, per-entry error boundary) + spec
feat(gui): web shell module-table seed — pure-library entities for the loader require surface
feat(client/i18n): I18nService real implementation — ns×locale registry, stable bind(ns) reference, zh fallback chain, zh/en skeleton dictionaries
feat(gui): web shell assembly closure — layout exports via module table, SessionProvider + scopedSlots + RootBindingProvider
feat: client/ui-conversation
feat: code
codedoc
build(gui): root bundle green — web shell excluded from the lib workspace (vite app), ui-primitives lib externalizes css side-effect imports (web-ui precedent)
gates(gui): verify-cordis-config follows aggregate tsconfig references (root is a shell over host/client programs); module graph regenerated for the twelve client packages
chore(gui): retire legacy migration sources — every owner rewrite landed (t0-checklist §7 ledger honored); orphan css of retired components removed
gates(gui): knip green groundwork — e2e/tsx entries for the new packages, loader-runtime deps ignored where loading is by specifier string, fake plugin ids un-bare-named, dead test export dropped
chore(client): manifest shape batch A — ui-slots/web-react/ui-primitives invariant companions, files whitelist, cordis+invariants peer/dev, tsconfig refs
chore(client): manifest shape batch B — connection/runtime/ui-conversation/ui-trajectory files whitelist, cordis peer+dev, explicit invariant lib entries (clientBundle signature)
chore(client): manifest shape batch C — i18n/ui-layout/ui-sidebar/ui-theme invariant companions, files whitelist, invariants peer/dev, tsconfig refs
chore(client): manifest shape batch D — web shell gains node-half lib entry + invariant companion + uniform files whitelist
chore(client): drop verified-unused deps — dsh-tools from runtime/ui-conversation (types ride /presentation), ui-primitives+clsx from ui-layout
gates(gui): doc-gate fixes — theme JSDoc prose, three client type-link exemptions, agent-note paths follow the migration, config catalog regenerated
gates(gui): type-equiv manifest follows the types.ts extraction, approval JSDoc keeps its link form, persistence catalog regenerated
docs(gui): per-constant JSDoc on the contract geometry exports (export-jsdoc gate)
test(gates): loader-composition budget covers cold tsx resolution after the program split (was flaking at the default 5s)
docs(gui): README substantiation batch 1 — ui-slots/ui-primitives/web-react/connection: Model Experience short form, real deferred-work ledgers, description accuracy pass
fix(client): theme/i18n dual-entry split — service classes + cordis merges move to src/client (host catalog scanner no longer misclassifies client services), node halves keep types + empty apply; catalogs regenerated
docs(gui): README substantiation batch 2 — runtime/ui-layout/ui-sidebar/ui-conversation: Model Experience short form, package-owned deferred-work ledgers (unload stub, watch approximation, /client value-import rule, global details state, two-state dots, stats duration gap, single-bundle caches)
docs(gui): README substantiation batch 3 — ui-trajectory/ui-theme/i18n/web: Model Experience short form, deferred-work ledgers (placeholder charter, no theme toggle owner, empty locale dictionaries, one-shot rendering); both README gates green
test(scripts): purity spec adopts clientBundle two-arg signature (explicit libEntry, no default)
gates(gui): knip green — declaration-merge dep ignored, fake plugin id assembled at runtime, invariants dep de-duplicated to peer+dev, stale apps/web section dropped
feat(gui): 门禁波次 host 三包 invariant 形状——apiproxy explained-empty 伴生(wire 契约层零事件面)、webserver 真关系伴生(manifest 行必解析出 clientPath,防 __DSH_BOOT__ 广告 404 bundle;apps/cli 发布 webPlugins 键供审计)、runtime 补 files 白名单;三包 exports/files/peer+dev/tsconfig refs 齐 fw-react 形状;constraints+invariants 双 gate 零违规
build(client): ui-layout/ui-sidebar tsdown configs adopt the explicit two-arg clientBundle signature (orphaned follow-up of the manifest shape batch)
refactor(gui): shell boot becomes a library face — bootWebShell(el) exported for the apps/web entry; main.ts retired
refactor(gui): exports 纪律刀1——ui-theme/i18n node index 收敛为只空 apply(Translate/LocaleDict/ThemeTokens 类型下沉 src/client/),ui-conversation 的 I18nService import 改 /client 子路径
build(typecheck): converge to root host aggregate + tsconfig.client.json — delete tsconfig.host.json, verify-cordis-config seeds both aggregates
feat(gui): apps/web restored as the vite application — thin main over bootWebShell; dsh-client-web becomes a plain lib (index exports shell surface, vite files and e2e moved out)
chore(gates): knip.json rewritten on the master base — same semantics, minimal diff (formatting churn dropped)
docs(gui): 时效清扫②——testing.md 删 web-ui 覆盖豁免残句;web-styling.md 加 token 换代头注(--dsw-* 现行、工程约束条款仍有效并注明收编处)
docs(gui): 时效清扫③——四对 GUI Agent Note 加路径更新头注(web-runtime/web-ui/dsh-frontend→现行 12 包结构;设计结论存续声明;双语对同步)
docs(gui): 时效清扫③b——四对 note 头注的 i18n 配对哈希重录
build(typecheck): minimal-diff tsconfig shape — drop root files entry (purity spec + preset move to client program), compress comments, drop redundant util/home root ref
feat(gui): apps/web restoration follow-through — dsh-frontend package name, cli dist resolve, root build:web filter, tsdown exemption dropped, vitest web lane + knip + client aggregate retargeted, e2e paths rebased
refactor(gui): exports 纪律刀2——connection wire 六件 git mv 进 src/client/(wire 即该 dshClient 插件的 client 半),node index=只空 apply,/client 半边整面导出(v3 §3.2 清单原样),包内 tests 改 src/client 直取
refactor(gui): exports 纪律刀3——runtime 实现整体下沉 src/client/(sessions/slots/loader;契约类型与 cordis merge 随迁 client/index),node index=只空 apply;./loader exports 指 client/loader;全消费面(web 壳/ui-sidebar/ui-trajectory/tests)bare→/client 机械跟改;vitest.e2e 换 tsconfig.vitest paths(root tsconfig 排除 client 会把 /client import 掉到 exports 的浏览器 dist bundle)
refactor(gui): exports 纪律刀3 补遗——ui-layout 三处 bare runtime import 改 /client(刀3 消费面机械跟改漏提交件;跨属地机械一行×3 报备 ui-shell)
test(gui): drop the getSessionManager singleton case — the init/get pair is a dead legacy-boot surface with zero live consumers (SessionsService constructs and holds the manager under the plugin architecture); source removal tracked with rt-core
refactor(gui): 删 manager.ts 尾部 initSessionManager/getSessionManager 单例对——旧 boot 直连遗物,插件化下 SessionsService 构造持有 manager,全仓零活消费者(convo-b 测试清扫对表,其测试用例已先行退役 7e2c51898);头注释同步去单例措辞
code
refactor
This commit is contained in:
17
packages/client/web-react/README.md
Normal file
17
packages/client/web-react/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# @deepseek-ai/dsh-client-web-react
|
||||
|
||||
ctx↔React glue: createSnapshotStore (zustand vanilla + immer + subscribeWithSelector + rafFlush + opt-in persist), bindSnapshotSelector, SessionProvider (dependency-inverted), scopedSlots outlet, RootBindingProvider, useInvoke. Contract: api-contracts v3 §2.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the ctx↔React glue runs entirely in the browser; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The persist middleware corrupts primitive-state stores** — it object-spreads state on save, so a `SnapshotStore<string>` round-trips as a character map; consumers with primitive state hand-roll persistence instead (ui-conversation drafts is the precedent).
|
||||
- **`UseSession` is deliberately wide (`object` snapshot)** — the dependency direction (runtime → web-react, never the reverse) keeps the real `ConversationSnapshot` type out of reach; session-slot consumers narrow once at their boundary.
|
||||
- **renderSlot is the single P-I form** — no Suspense, no per-entry lazy loading; the progressive-rendering surface returns with its own project.
|
||||
50
packages/client/web-react/package.json
Normal file
50
packages/client/web-react/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-web-react",
|
||||
"description": "ctx-to-React glue: createSnapshotStore (zustand engine), bindSnapshotSelector, SessionProvider, scopedSlots outlet, useInvoke",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./store": {
|
||||
"types": "./lib/types/store/index.d.ts",
|
||||
"default": "./lib/store/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"immer": "^10.1.1",
|
||||
"react": "^18.2.0",
|
||||
"use-sync-external-store": "1.2.0",
|
||||
"zustand": "~4.4.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/store/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
]
|
||||
}
|
||||
22
packages/client/web-react/src/bind.ts
Normal file
22
packages/client/web-react/src/bind.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* uSES bridge: turns any {@link ObservableSnapshot} into a typed selector
|
||||
* hook. Client-side-rendered only, so no server snapshot is wired.
|
||||
*/
|
||||
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector.js'
|
||||
import type { ObservableSnapshot, SnapshotSelectorHook } from './store/index.ts'
|
||||
|
||||
/**
|
||||
* Bind an observable snapshot source to a typed uSES selector hook.
|
||||
* subscribe/getSnapshot are captured once per source into stable closures
|
||||
* (also re-binds `this` for method-based sources), so components never
|
||||
* resubscribe across renders. Equality defaults to Object.is.
|
||||
* @param w - snapshot source (Session object or snapshot store).
|
||||
* @returns the selector hook.
|
||||
*/
|
||||
export function bindSnapshotSelector<T>(w: ObservableSnapshot<T>): SnapshotSelectorHook<T> {
|
||||
const subscribe = (fn: () => void) => w.subscribe(fn)
|
||||
const getSnapshot = () => w.getSnapshot()
|
||||
return function useSelector<S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean): S {
|
||||
return useSyncExternalStoreWithSelector(subscribe, getSnapshot, undefined, sel, eq)
|
||||
}
|
||||
}
|
||||
5
packages/client/web-react/src/env.d.ts
vendored
Normal file
5
packages/client/web-react/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Bundler-replaced NODE_ENV: vite/tsdown substitute the literal, so browsers
|
||||
* never evaluate a bare `process`. tsconfig carries no node types on purpose.
|
||||
*/
|
||||
declare const process: { env: { NODE_ENV?: string } }
|
||||
41
packages/client/web-react/src/index.ts
Normal file
41
packages/client/web-react/src/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* ctx-to-React glue: uSES bridge, SessionProvider (dependency-inverted),
|
||||
* scopedSlots outlet factory, useInvoke. Contract: api-contracts v3 section 2.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SnapshotSelectorHook } from './store/index.ts'
|
||||
|
||||
export type {
|
||||
ObservableSnapshot, SnapshotSelectorHook, SnapshotStore,
|
||||
} from './store/index.ts'
|
||||
export { createSnapshotStore, shallowEqual } from './store/index.ts'
|
||||
export { bindSnapshotSelector } from './bind.ts'
|
||||
|
||||
/**
|
||||
* Selector hook over a session's conversation snapshot. Wide (`object`) by
|
||||
* default inside this dependency-inverted package; runtime narrows it once at
|
||||
* its export surface (`UseSession<ConversationSnapshot>`) — the snapshot type
|
||||
* never flows back into web-react.
|
||||
*/
|
||||
export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap>
|
||||
|
||||
/** Session assembly handle narrowed from ui-slots' structural form. */
|
||||
export interface SessionBinding<Snap extends object = object> {
|
||||
readonly sessionId: string
|
||||
readonly session: { useSelector: UseSession<Snap> }
|
||||
readonly ctx: unknown
|
||||
}
|
||||
|
||||
/** SessionProvider dependency surface (inverted: web-react never imports runtime). */
|
||||
export interface SessionProviderDeps {
|
||||
useCurrent: () => string | undefined
|
||||
resolveBinding: (id: string) => SessionBinding | undefined
|
||||
/** Assembler-owned body: the shell closes over its own scopedSlots to render the session slots. */
|
||||
renderBody: (id: string) => ReactNode
|
||||
}
|
||||
|
||||
export { createSessionProvider, RootBindingProvider, SlotAssemblyError, useRootBinding, useSessionBinding } from './session-provider.tsx'
|
||||
|
||||
export { scopedSlots } from './scoped-slots.tsx'
|
||||
|
||||
export { useInvoke } from './use-invoke.ts'
|
||||
32
packages/client/web-react/src/invariant.ts
Normal file
32
packages/client/web-react/src/invariant.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-web-react`.
|
||||
* @module @deepseek-ai/dsh-client-web-react/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-web-react'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-web-react-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: pure ctx-to-React glue — it emits no cordis events
|
||||
* and owns no cross-plugin mutable relation; store batching, selector
|
||||
* equality short-circuits, and inject-cache identity are asserted directly
|
||||
* by this package's behavior specs.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
191
packages/client/web-react/src/scoped-slots.tsx
Normal file
191
packages/client/web-react/src/scoped-slots.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* ScopedSlots factory: the sole render surface over the slot registry.
|
||||
* renderSlot subscribes through uSES (SlotCore.subscribe/getVersion), renders
|
||||
* per slot kind, wraps every entry in an error boundary, and merges props from
|
||||
* three sources: standard injection (session slots get useSession), the
|
||||
* registrant's cached inject factory, then owner props (owner wins).
|
||||
*
|
||||
* Typing model (slot type-chain design §4): the key stays generic (`K`) from
|
||||
* renderSlot down to the outlet, so `entries<K>()` returns typed entries and
|
||||
* the per-entry render path is monomorphic — no existential casts in loops.
|
||||
*/
|
||||
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
|
||||
import type {
|
||||
RenderOpts, RootBinding, ScopedSlots, SessionBinding as SlotSessionBinding,
|
||||
SlotCore, SlotEntry, SlotMap,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotAssemblyError, useRootBinding, useSessionBinding } from './session-provider.tsx'
|
||||
|
||||
type AnyKey = keyof SlotMap & string
|
||||
type EntryOf<K extends AnyKey> = SlotEntry<SlotMap[K]>
|
||||
type InjectedProps = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Inject results cache: root slots per entry, session slots per (entry x binding).
|
||||
* WeakMap keys are the entry objects (stable across entries() snapshots per
|
||||
* the SlotCore contract); values are the registrant's injected share. Storage
|
||||
* erases the per-entry `I` — the single budgeted cast per cache restores it.
|
||||
*/
|
||||
const rootInjectCache = new WeakMap<object, InjectedProps>()
|
||||
const sessionInjectCache = new WeakMap<object, WeakMap<object, InjectedProps>>()
|
||||
|
||||
function cachedRootInject<K extends AnyKey>(entry: EntryOf<K>, binding: RootBinding): InjectedProps {
|
||||
const inject = entry.options?.inject
|
||||
if (!inject) return {}
|
||||
let props = rootInjectCache.get(entry)
|
||||
if (!props) {
|
||||
// Root-scope factories accept RootBinding; the conditional-type parameter
|
||||
// only fails to dispatch because K is generic here — the outlet's
|
||||
// spec.scope branch guarantees the scope side (budgeted cast, one per cache).
|
||||
props = (inject as (b: RootBinding) => InjectedProps)(binding)
|
||||
rootInjectCache.set(entry, props)
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
function cachedSessionInject<K extends AnyKey>(entry: EntryOf<K>, binding: SlotSessionBinding): InjectedProps {
|
||||
const inject = entry.options?.inject
|
||||
if (!inject) return {}
|
||||
let perBinding = sessionInjectCache.get(entry)
|
||||
if (!perBinding) {
|
||||
perBinding = new WeakMap()
|
||||
sessionInjectCache.set(entry, perBinding)
|
||||
}
|
||||
let props = perBinding.get(binding)
|
||||
if (!props) {
|
||||
// Same scope-dispatch note as the root cache: the session branch of the
|
||||
// outlet guarantees this factory's binding side (budgeted cast).
|
||||
props = (inject as (b: SlotSessionBinding) => InjectedProps)(binding)
|
||||
perBinding.set(binding, props)
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-entry isolation: one registrant crashing (component render or inject
|
||||
* factory) must not take down siblings. Assembly errors (missing providers)
|
||||
* rethrow — a miswired shell must fail loud, not degrade into fallbacks.
|
||||
*/
|
||||
class SlotErrorBoundary extends Component<
|
||||
{ slotKey: string; children: ReactNode }, { failed: boolean }
|
||||
> {
|
||||
override state = { failed: false }
|
||||
static getDerivedStateFromError(error: unknown): { failed: boolean } {
|
||||
if (error instanceof SlotAssemblyError) throw error
|
||||
return { failed: true }
|
||||
}
|
||||
override componentDidCatch(error: unknown): void {
|
||||
console.error(`slot entry crashed in '${this.props.slotKey}':`, error)
|
||||
}
|
||||
override render(): ReactNode {
|
||||
if (this.state.failed) return <div data-slot-error={this.props.slotKey} />
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
interface OutletProps<K extends AnyKey> {
|
||||
core: SlotCore
|
||||
slotKey: K
|
||||
ownerProps: object
|
||||
opts?: RenderOpts | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* One rendered entry: standard injection + cached inject + owner props.
|
||||
* Inject factories run inside these component bodies ON PURPOSE — the outlet
|
||||
* wraps every Entry element in the per-entry error boundary, so a throwing
|
||||
* factory blacks out only its own entry. The three-source merge composes the
|
||||
* entry's full props contract; TS cannot prove the composition against
|
||||
* `SlotMap[K]['props']` (the shares are erased at the registry boundary), so
|
||||
* each Entry renders through a props-widened view of the component — the
|
||||
* design-budgeted composition point, one per scope branch.
|
||||
*/
|
||||
function SessionEntry<K extends AnyKey>({ entry, ownerProps }: {
|
||||
entry: EntryOf<K>; ownerProps: object
|
||||
}) {
|
||||
const binding = useSessionBinding()
|
||||
const Comp = entry.component as FC<InjectedProps>
|
||||
const injected = cachedSessionInject(entry, binding)
|
||||
return <Comp useSession={binding.session.useSelector} {...injected} {...ownerProps} />
|
||||
}
|
||||
|
||||
function RootEntry<K extends AnyKey>({ entry, ownerProps }: {
|
||||
entry: EntryOf<K>; ownerProps: object
|
||||
}) {
|
||||
const hasInject = entry.options?.inject !== undefined
|
||||
const Comp = entry.component as FC<InjectedProps>
|
||||
// Only inject-bearing entries need the root binding channel; plain entries
|
||||
// must render fine in shells that never mounted RootBindingProvider.
|
||||
if (!hasInject) return <Comp {...ownerProps} />
|
||||
return <RootInjectEntry entry={entry} ownerProps={ownerProps} />
|
||||
}
|
||||
|
||||
function RootInjectEntry<K extends AnyKey>({ entry, ownerProps }: {
|
||||
entry: EntryOf<K>; ownerProps: object
|
||||
}) {
|
||||
const binding = useRootBinding()
|
||||
const Comp = entry.component as FC<InjectedProps>
|
||||
const injected = cachedRootInject(entry, binding)
|
||||
return <Comp {...injected} {...ownerProps} />
|
||||
}
|
||||
|
||||
function SlotOutlet<K extends AnyKey>({ core, slotKey, ownerProps, opts }: OutletProps<K>) {
|
||||
// Version tick drives entries() re-read; SlotCore batches per microtask.
|
||||
useSyncExternalStore(
|
||||
(fn) => core.subscribe(slotKey, fn),
|
||||
() => core.getVersion(slotKey),
|
||||
)
|
||||
const spec = core.spec(slotKey)
|
||||
if (!spec) throw new Error(`renderSlot('${slotKey}') before define`)
|
||||
const entries = core.entries(slotKey)
|
||||
const Entry: FC<{ entry: EntryOf<K>; ownerProps: object }> =
|
||||
spec.scope === 'session' ? SessionEntry : RootEntry
|
||||
|
||||
// The boundary must wrap the Entry ELEMENT, not live inside it: inject
|
||||
// factories and binding lookups run in the Entry body and must land in the
|
||||
// per-entry fallback rather than escaping to the tree above.
|
||||
const guarded = (entry: EntryOf<K>, key?: string | number) => (
|
||||
<SlotErrorBoundary slotKey={slotKey} key={key}>
|
||||
<Entry entry={entry} ownerProps={ownerProps} />
|
||||
</SlotErrorBoundary>
|
||||
)
|
||||
|
||||
if (spec.kind === 'single') {
|
||||
const entry = entries[0]
|
||||
if (!entry) return <>{opts?.fallback ?? null}</>
|
||||
return guarded(entry)
|
||||
}
|
||||
if (spec.kind === 'keyed') {
|
||||
const entry = entries.find((e) => e.options && 'key' in e.options && e.options.key === opts?.entryKey)
|
||||
if (!entry) return <>{opts?.fallback ?? null}</>
|
||||
return guarded(entry)
|
||||
}
|
||||
// list: registration order refined by explicit order, optional id filter.
|
||||
const withListOptions = entries.map((entry) => ({
|
||||
entry,
|
||||
id: entry.options && 'id' in entry.options ? entry.options.id : undefined,
|
||||
order: entry.options && 'order' in entry.options ? entry.options.order ?? 0 : 0,
|
||||
}))
|
||||
let list = [...withListOptions].sort((a, b) => a.order - b.order)
|
||||
if (opts?.only !== undefined) list = list.filter((item) => item.id === opts.only)
|
||||
if (list.length === 0) return <>{opts?.fallback ?? null}</>
|
||||
return <>{list.map((item, i) => guarded(item.entry, item.id ?? i))}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a whitelist-narrowed ScopedSlots render surface over a SlotCore.
|
||||
* The type parameter narrows compile-time access; the runtime whitelist
|
||||
* backstops plain-JS callers.
|
||||
* @param core - the slot registry core.
|
||||
* @param keys - whitelisted slot keys the caller may render.
|
||||
* @returns the ScopedSlots facade.
|
||||
*/
|
||||
export function scopedSlots<K extends AnyKey>(core: SlotCore, ...keys: K[]): ScopedSlots<K> {
|
||||
const allowed = new Set<string>(keys)
|
||||
return {
|
||||
renderSlot(key, props, opts) {
|
||||
if (!allowed.has(key)) throw new Error(`slot '${key}' is not in this ScopedSlots whitelist`)
|
||||
return <SlotOutlet core={core} slotKey={key} ownerProps={props} opts={opts} />
|
||||
},
|
||||
}
|
||||
}
|
||||
74
packages/client/web-react/src/session-provider.tsx
Normal file
74
packages/client/web-react/src/session-provider.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* SessionProvider (dependency-inverted; never imports runtime) plus the two
|
||||
* binding contexts the slot outlet reads: per-session {@link BindingContext}
|
||||
* written here, and the root-binding channel written by the shell through
|
||||
* {@link RootBindingProvider}.
|
||||
*/
|
||||
import { createContext, useContext, type FC, type ReactNode } from 'react'
|
||||
import type { RootBinding } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionBinding, SessionProviderDeps } from './index.ts'
|
||||
|
||||
/** Session binding for the subtree under SessionProvider (module-private write). */
|
||||
const BindingContext = createContext<SessionBinding | null>(null)
|
||||
|
||||
/**
|
||||
* A missing-provider assembly error: the shell wired the tree wrong. The slot
|
||||
* error boundary rethrows this class so misassembly stays fail-loud while
|
||||
* registrant errors (inject factories, entry components) are contained
|
||||
* per entry.
|
||||
*/
|
||||
export class SlotAssemblyError extends Error {}
|
||||
|
||||
/**
|
||||
* Read the enclosing session binding; throws outside a SessionProvider
|
||||
* subtree (session slots must not render without a session).
|
||||
* @returns the enclosing binding.
|
||||
*/
|
||||
export function useSessionBinding(): SessionBinding {
|
||||
const binding = useContext(BindingContext)
|
||||
if (!binding) throw new SlotAssemblyError('session slot rendered outside SessionProvider')
|
||||
return binding
|
||||
}
|
||||
|
||||
const RootBindingContext = createContext<RootBinding | null>(null)
|
||||
|
||||
/**
|
||||
* Root-binding supply channel: the shell mounts this once at the top so root
|
||||
* slot inject factories receive their assembly handle.
|
||||
*/
|
||||
export const RootBindingProvider: FC<{ value: RootBinding; children?: ReactNode }> =
|
||||
({ value, children }) => (
|
||||
<RootBindingContext.Provider value={value}>{children}</RootBindingContext.Provider>
|
||||
)
|
||||
|
||||
/**
|
||||
* Read the root binding; throws when the shell forgot to mount
|
||||
* {@link RootBindingProvider} (root inject factories need ctx).
|
||||
* @returns the root binding.
|
||||
*/
|
||||
export function useRootBinding(): RootBinding {
|
||||
const binding = useContext(RootBindingContext)
|
||||
if (!binding) throw new SlotAssemblyError('root slot inject requires RootBindingProvider above')
|
||||
return binding
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the single SessionProvider component: subscribes to the current
|
||||
* session id, resolves its binding (stable reference), remounts the body
|
||||
* under key={id}, and delegates body rendering to the assembler's renderBody
|
||||
* (slot ownership stays with layout; the provider knows no slot names).
|
||||
* @param deps - inverted dependencies.
|
||||
* @returns the provider component.
|
||||
*/
|
||||
export function createSessionProvider(deps: SessionProviderDeps): FC<{ renderEmpty?: () => ReactNode }> {
|
||||
return function SessionProvider({ renderEmpty }) {
|
||||
const id = deps.useCurrent()
|
||||
const binding = id === undefined ? undefined : deps.resolveBinding(id)
|
||||
if (id === undefined || !binding) return <>{renderEmpty?.() ?? null}</>
|
||||
return (
|
||||
<BindingContext.Provider value={binding} key={id}>
|
||||
{deps.renderBody(id)}
|
||||
</BindingContext.Provider>
|
||||
)
|
||||
}
|
||||
}
|
||||
150
packages/client/web-react/src/store/index.ts
Normal file
150
packages/client/web-react/src/store/index.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Snapshot store engine (zustand vanilla + immer + subscribeWithSelector +
|
||||
* rafFlush middleware + opt-in persist + dev freeze). The only data contract
|
||||
* consumed by React is {@link ObservableSnapshot}.
|
||||
*/
|
||||
import { createStore, type StoreApi } from 'zustand/vanilla'
|
||||
import { subscribeWithSelector } from 'zustand/middleware'
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import { produce } from 'immer'
|
||||
import { bindSnapshotSelector } from '../bind.ts'
|
||||
|
||||
/** Minimal observable snapshot source: Session objects and snapshot stores both satisfy it. */
|
||||
export interface ObservableSnapshot<T> { getSnapshot(): T; subscribe(fn: () => void): () => void }
|
||||
|
||||
/** Writable snapshot store with an attached typed selector hook. */
|
||||
export interface SnapshotStore<T> extends ObservableSnapshot<T> {
|
||||
/**
|
||||
* Mutate the state through an immer draft.
|
||||
* @param mutator - draft mutator.
|
||||
*/
|
||||
update(mutator: (draft: T) => void): void
|
||||
/**
|
||||
* Replace the state wholesale.
|
||||
* @param next - next state.
|
||||
*/
|
||||
set(next: T): void
|
||||
readonly useSelector: SnapshotSelectorHook<T>
|
||||
}
|
||||
|
||||
/** Typed selector hook: equality defaults to Object.is; pass shallowEqual for object slices. */
|
||||
export type SnapshotSelectorHook<T> = <S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S
|
||||
|
||||
/**
|
||||
* Shallow equality for selector slices (re-export of zustand/shallow semantics).
|
||||
* @param a - left value.
|
||||
* @param b - right value.
|
||||
* @returns whether the values are shallowly equal.
|
||||
*/
|
||||
export function shallowEqual(a: unknown, b: unknown): boolean {
|
||||
return shallow(a, b)
|
||||
}
|
||||
|
||||
/** Batches subscriber notification into one flush per animation frame. */
|
||||
function rafBatch(notify: () => void): () => void {
|
||||
// Fall back to microtask batching where rAF is absent (node unit tests);
|
||||
// both preserve the N-changes=1-notification contract within a tick.
|
||||
const schedule: (fn: () => void) => void =
|
||||
typeof requestAnimationFrame === 'function'
|
||||
? (fn) => { requestAnimationFrame(() => { fn() }) }
|
||||
: (fn) => { queueMicrotask(fn) }
|
||||
let scheduled = false
|
||||
return () => {
|
||||
if (scheduled) return
|
||||
scheduled = true
|
||||
schedule(() => {
|
||||
scheduled = false
|
||||
notify()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a snapshot store.
|
||||
*
|
||||
* Flush default is 'sync' (controlled inputs need same-tick echo); frame-driven
|
||||
* stores opt into 'raf', where a frame's worth of updates coalesces into one
|
||||
* notification. Known raf-mode tradeoff: a component mounting mid-frame reads
|
||||
* fresh state while existing subscribers hear it next flush — transient
|
||||
* frame-level skew, same nature as the object layer's microtask batching.
|
||||
*
|
||||
* @param init - initial state.
|
||||
* @param opts - flush mode and opt-in persistence (localStorage, keyed by name).
|
||||
* @returns the store.
|
||||
*/
|
||||
export function createSnapshotStore<T>(
|
||||
init: T, opts?: { flush?: 'raf' | 'sync'; persist?: { name: string } }): SnapshotStore<T> {
|
||||
// Immer enters through produce() in update() below (identical semantics to
|
||||
// the immer middleware without its setState-signature mutator generics).
|
||||
const withSelector = subscribeWithSelector(() => init)
|
||||
const api: StoreApi<T> = createStore<T>()(withSelector)
|
||||
if (opts?.persist) attachPersistence(api, opts.persist.name)
|
||||
|
||||
let subscribe = (fn: () => void) => api.subscribe(fn)
|
||||
if (opts?.flush === 'raf') {
|
||||
const listeners = new Set<() => void>()
|
||||
const flush = rafBatch(() => { for (const fn of [...listeners]) fn() })
|
||||
api.subscribe(flush)
|
||||
subscribe = (fn: () => void) => {
|
||||
listeners.add(fn)
|
||||
return () => { listeners.delete(fn) }
|
||||
}
|
||||
}
|
||||
|
||||
const store: SnapshotStore<T> = {
|
||||
getSnapshot: () => api.getState(),
|
||||
subscribe: fn => subscribe(fn),
|
||||
update: (mutator) => {
|
||||
// Immer's produce (not setState's partial-merge path) so scalar and
|
||||
// array roots replace correctly; produce also freezes in dev.
|
||||
api.setState(produce(api.getState(), (draft) => { mutator(draft as T) }), true)
|
||||
},
|
||||
set: (next) => {
|
||||
api.setState(devFreeze(next), true)
|
||||
},
|
||||
useSelector: undefined as unknown as SnapshotSelectorHook<T>,
|
||||
}
|
||||
;(store as { useSelector: SnapshotSelectorHook<T> }).useSelector = bindSnapshotSelector(store)
|
||||
return store
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-value JSON persistence to localStorage. Hand-rolled instead of the
|
||||
* zustand persist middleware: its write path spreads state into an object
|
||||
* (`partialize({ ...get() })`), exploding primitive state (a persisted string
|
||||
* draft becomes {0:'h',1:'e',...}) — not fixable via merge/deserialize options
|
||||
* because the corruption happens before serialization. Storage failures
|
||||
* (quota, private mode) only disable persistence, never break the store.
|
||||
*/
|
||||
function attachPersistence<T>(api: StoreApi<T>, name: string): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(name)
|
||||
if (raw !== null) {
|
||||
api.setState(devFreeze(JSON.parse(raw) as T), true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`snapshot store '${name}' rehydration failed:`, error)
|
||||
}
|
||||
api.subscribe((state) => {
|
||||
try {
|
||||
localStorage.setItem(name, JSON.stringify(state))
|
||||
} catch (error) {
|
||||
console.error(`snapshot store '${name}' persistence failed:`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Deep-freeze wholesale-set state outside production: set() bypasses immer's freeze. */
|
||||
function devFreeze<T>(value: T): T {
|
||||
if (process.env.NODE_ENV === 'production') return value
|
||||
deepFreeze(value)
|
||||
return value
|
||||
}
|
||||
|
||||
function deepFreeze(value: unknown): void {
|
||||
if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return
|
||||
Object.freeze(value)
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
deepFreeze((value as Record<PropertyKey, unknown>)[key])
|
||||
}
|
||||
}
|
||||
62
packages/client/web-react/src/use-invoke.ts
Normal file
62
packages/client/web-react/src/use-invoke.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* useInvoke: wrap an async action into a stable trigger plus pending flag.
|
||||
* Pending is tracked in a per-hook external store read through uSES instead
|
||||
* of setState, keeping the render body side-effect free and the invoke
|
||||
* reference stable across renders (idempotent-hook rules).
|
||||
*/
|
||||
import { useRef, useSyncExternalStore } from 'react'
|
||||
|
||||
interface InvokeCell {
|
||||
inflight: number
|
||||
listeners: Set<() => void>
|
||||
fn: () => Promise<unknown>
|
||||
invoke: () => void
|
||||
subscribe: (fn: () => void) => () => void
|
||||
getPending: () => boolean
|
||||
}
|
||||
|
||||
function createCell(fn: () => Promise<unknown>): InvokeCell {
|
||||
const cell: InvokeCell = {
|
||||
inflight: 0,
|
||||
listeners: new Set(),
|
||||
fn,
|
||||
invoke: () => {
|
||||
bump(cell, 1)
|
||||
cell.fn().catch((error: unknown) => {
|
||||
// Domain errors surface through the event echo (session log); the
|
||||
// framework only guarantees pending resets and leaves a trace.
|
||||
console.error('useInvoke action failed:', error)
|
||||
}).finally(() => { bump(cell, -1) })
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
cell.listeners.add(listener)
|
||||
return () => { cell.listeners.delete(listener) }
|
||||
},
|
||||
getPending: () => cell.inflight > 0,
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
function bump(cell: InvokeCell, delta: number): void {
|
||||
const wasPending = cell.inflight > 0
|
||||
cell.inflight += delta
|
||||
if (wasPending !== cell.inflight > 0) {
|
||||
for (const listener of [...cell.listeners]) listener()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an async action into a stable invoke callback plus pending flag.
|
||||
* Concurrent invocations are counted: pending stays true until the last
|
||||
* in-flight call settles. The latest `fn` is always the one invoked.
|
||||
* @param fn - async action.
|
||||
* @returns invoke trigger and pending state.
|
||||
*/
|
||||
export function useInvoke(fn: () => Promise<unknown>): [invoke: () => void, pending: boolean] {
|
||||
const ref = useRef<InvokeCell | null>(null)
|
||||
ref.current ??= createCell(fn)
|
||||
const cell = ref.current
|
||||
cell.fn = fn
|
||||
const pending = useSyncExternalStore(cell.subscribe, cell.getPending)
|
||||
return [cell.invoke, pending]
|
||||
}
|
||||
14
packages/client/web-react/src/use-sync-external-store.d.ts
vendored
Normal file
14
packages/client/web-react/src/use-sync-external-store.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Local typings for use-sync-external-store 1.2.0: the package ships no types
|
||||
* and the DefinitelyTyped package is unavailable offline. Mirrors the shim's
|
||||
* with-selector build (the only entry this package consumes).
|
||||
*/
|
||||
declare module 'use-sync-external-store/shim/with-selector.js' {
|
||||
export function useSyncExternalStoreWithSelector<Snapshot, Selection>(
|
||||
subscribe: (onStoreChange: () => void) => () => void,
|
||||
getSnapshot: () => Snapshot,
|
||||
getServerSnapshot: undefined | null | (() => Snapshot),
|
||||
selector: (snapshot: Snapshot) => Selection,
|
||||
isEqual?: (a: Selection, b: Selection) => boolean,
|
||||
): Selection
|
||||
}
|
||||
125
packages/client/web-react/tests/bind.spec.tsx
Normal file
125
packages/client/web-react/tests/bind.spec.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
// @vitest-environment jsdom
|
||||
import { StrictMode } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector, shallowEqual } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react/store'
|
||||
|
||||
interface Snap { a: number; b: number }
|
||||
|
||||
/** Hand-rolled observable source so subscription counting is exact. */
|
||||
function makeSource(initial: Snap) {
|
||||
let state = initial
|
||||
const listeners = new Set<() => void>()
|
||||
let subscribeCalls = 0
|
||||
const source: ObservableSnapshot<Snap> = {
|
||||
getSnapshot: () => state,
|
||||
subscribe: (fn) => {
|
||||
subscribeCalls += 1
|
||||
listeners.add(fn)
|
||||
return () => { listeners.delete(fn) }
|
||||
},
|
||||
}
|
||||
return {
|
||||
source,
|
||||
set: (next: Snap) => {
|
||||
state = next
|
||||
for (const fn of [...listeners]) fn()
|
||||
},
|
||||
stats: { get subscribeCalls() { return subscribeCalls }, get active() { return listeners.size } },
|
||||
}
|
||||
}
|
||||
|
||||
function Harness<S>({ useSelector, sel, eq, probe }: {
|
||||
useSelector: SnapshotSelectorHook<Snap>
|
||||
sel: (s: Snap) => S
|
||||
eq?: (a: S, b: S) => boolean
|
||||
probe: { renders: number; value?: S }
|
||||
}) {
|
||||
probe.renders += 1
|
||||
probe.value = useSelector(sel, eq)
|
||||
return null
|
||||
}
|
||||
|
||||
describe('bindSnapshotSelector', () => {
|
||||
it('re-renders on selected change and bails out when the slice is equal', () => {
|
||||
const { source, set } = makeSource({ a: 1, b: 10 })
|
||||
const useSelector = bindSnapshotSelector(source)
|
||||
const probe = { renders: 0, value: undefined as number | undefined }
|
||||
render(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
|
||||
expect(probe.value).toBe(1)
|
||||
const before = probe.renders
|
||||
act(() => { set({ a: 1, b: 11 }) }) // unrelated field: Object.is bail
|
||||
expect(probe.renders).toBe(before)
|
||||
act(() => { set({ a: 2, b: 11 }) })
|
||||
expect(probe.renders).toBe(before + 1)
|
||||
expect(probe.value).toBe(2)
|
||||
})
|
||||
|
||||
it('supports custom equality for object slices', () => {
|
||||
const { source, set } = makeSource({ a: 1, b: 10 })
|
||||
const useSelector = bindSnapshotSelector(source)
|
||||
const probe = { renders: 0, value: undefined as { a: number } | undefined }
|
||||
render(<Harness useSelector={useSelector} sel={(s) => ({ a: s.a })} eq={shallowEqual} probe={probe} />)
|
||||
const before = probe.renders
|
||||
act(() => { set({ a: 1, b: 99 }) }) // fresh object, shallow-equal slice
|
||||
expect(probe.renders).toBe(before)
|
||||
act(() => { set({ a: 5, b: 99 }) })
|
||||
expect(probe.renders).toBe(before + 1)
|
||||
expect(probe.value).toEqual({ a: 5 })
|
||||
})
|
||||
|
||||
it('does not resubscribe across re-renders of the same component', () => {
|
||||
const { source, set, stats } = makeSource({ a: 1, b: 10 })
|
||||
const useSelector = bindSnapshotSelector(source)
|
||||
const probe = { renders: 0, value: undefined as number | undefined }
|
||||
const { rerender } = render(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
|
||||
const after = stats.subscribeCalls
|
||||
rerender(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
|
||||
act(() => { set({ a: 2, b: 10 }) })
|
||||
rerender(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
|
||||
expect(stats.subscribeCalls).toBe(after)
|
||||
})
|
||||
|
||||
it('is StrictMode-safe and cleans up subscriptions on unmount', () => {
|
||||
const { source, stats } = makeSource({ a: 1, b: 10 })
|
||||
const useSelector = bindSnapshotSelector(source)
|
||||
const probe = { renders: 0, value: undefined as number | undefined }
|
||||
const view = render(
|
||||
<StrictMode>
|
||||
<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />
|
||||
</StrictMode>,
|
||||
)
|
||||
expect(probe.value).toBe(1)
|
||||
view.unmount()
|
||||
expect(stats.active).toBe(0)
|
||||
})
|
||||
|
||||
it('binds method-style sources without losing this', () => {
|
||||
class MethodSource implements ObservableSnapshot<Snap> {
|
||||
private state: Snap = { a: 7, b: 0 }
|
||||
private listeners = new Set<() => void>()
|
||||
getSnapshot(): Snap { return this.state }
|
||||
subscribe(fn: () => void): () => void {
|
||||
this.listeners.add(fn)
|
||||
return () => { this.listeners.delete(fn) }
|
||||
}
|
||||
}
|
||||
const useSelector = bindSnapshotSelector(new MethodSource())
|
||||
const probe = { renders: 0, value: undefined as number | undefined }
|
||||
render(<Harness useSelector={useSelector} sel={(s) => s.a} probe={probe} />)
|
||||
expect(probe.value).toBe(7)
|
||||
})
|
||||
|
||||
it('memoizes the selector result against getSnapshot spam', () => {
|
||||
const { source } = makeSource({ a: 1, b: 10 })
|
||||
const sel = vi.fn((s: Snap) => s.a)
|
||||
const useSelector = bindSnapshotSelector(source)
|
||||
const probe = { renders: 0, value: undefined as number | undefined }
|
||||
const { rerender } = render(<Harness useSelector={useSelector} sel={sel} probe={probe} />)
|
||||
const calls = sel.mock.calls.length
|
||||
rerender(<Harness useSelector={useSelector} sel={sel} probe={probe} />)
|
||||
// Same snapshot + same selector reference: no recompute beyond bookkeeping.
|
||||
expect(sel.mock.calls.length).toBeLessThanOrEqual(calls + 1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Integration against the real ui-slots SlotCore (T1): the outlet's uSES
|
||||
* pairing rides the real subscribe/getVersion/entries surfaces, and the
|
||||
* whitelist narrows at compile time (expect-error negative samples).
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { scopedSlots } from '@deepseek-ai/dsh-client-web-react'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
'spec.single': { kind: 'single'; scope: 'root'; props: { label?: string } }
|
||||
'spec.list': { kind: 'list'; scope: 'root'; props: object }
|
||||
'spec.off-limits': { kind: 'single'; scope: 'root'; props: object }
|
||||
}
|
||||
}
|
||||
|
||||
describe('scopedSlots over the real SlotCore', () => {
|
||||
it('renders registrations live: define, register, dispose back to fallback', async () => {
|
||||
const core = new SlotCore()
|
||||
core.define('spec.single', { kind: 'single', scope: 'root' })
|
||||
const slots = scopedSlots(core, 'spec.single')
|
||||
const view = render(<>{slots.renderSlot('spec.single', {}, { fallback: <i>none</i> })}</>)
|
||||
expect(view.container.textContent).toBe('none')
|
||||
let dispose = () => {}
|
||||
// The real core batches subscriber notification per microtask: async act.
|
||||
await act(async () => { dispose = core.register('spec.single', ({ label }) => <b>{label ?? 'on'}</b>) })
|
||||
expect(view.container.textContent).toBe('on')
|
||||
await act(async () => { dispose(); dispose() }) // disposer is idempotent in the real core
|
||||
expect(view.container.textContent).toBe('none')
|
||||
})
|
||||
|
||||
it('passes owner props through and orders list entries', () => {
|
||||
const core = new SlotCore()
|
||||
core.define('spec.single', { kind: 'single', scope: 'root' })
|
||||
core.define('spec.list', { kind: 'list', scope: 'root' })
|
||||
core.register('spec.single', ({ label }) => <b>{label}</b>)
|
||||
core.register('spec.list', () => <span>2</span>, { id: 'two', order: 2 })
|
||||
core.register('spec.list', () => <span>1</span>, { id: 'one', order: 1 })
|
||||
const slots = scopedSlots(core, 'spec.single', 'spec.list')
|
||||
const view = render(
|
||||
<>
|
||||
{slots.renderSlot('spec.single', { label: 'owner' })}
|
||||
{slots.renderSlot('spec.list', {})}
|
||||
</>,
|
||||
)
|
||||
expect(view.container.textContent).toBe('owner12')
|
||||
})
|
||||
|
||||
it('fails loud when rendering a key that was never defined', () => {
|
||||
const core = new SlotCore()
|
||||
const slots = scopedSlots(core, 'spec.single')
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
expect(() => render(<>{slots.renderSlot('spec.single', {})}</>)).toThrow(/before define/)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('narrows the whitelist at compile time and backstops at runtime', () => {
|
||||
const core = new SlotCore()
|
||||
core.define('spec.single', { kind: 'single', scope: 'root' })
|
||||
core.define('spec.off-limits', { kind: 'single', scope: 'root' })
|
||||
const slots = scopedSlots(core, 'spec.single')
|
||||
// @ts-expect-error spec.off-limits is outside this ScopedSlots whitelist
|
||||
expect(() => slots.renderSlot('spec.off-limits', {})).toThrow(/whitelist/)
|
||||
// @ts-expect-error unknown keys are rejected even before whitelist narrowing
|
||||
expect(() => slots.renderSlot('spec.nonexistent', {})).toThrow(/whitelist/)
|
||||
})
|
||||
})
|
||||
274
packages/client/web-react/tests/scoped-slots.spec.tsx
Normal file
274
packages/client/web-react/tests/scoped-slots.spec.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import type {
|
||||
FC } from 'react'
|
||||
import type {
|
||||
InjectFactory, RootBinding, SlotCore, SlotEntry, SlotEntryDef, SlotSpec,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
createSessionProvider, createSnapshotStore, RootBindingProvider, scopedSlots,
|
||||
type SessionBinding, type SessionProviderDeps,
|
||||
} from '@deepseek-ai/dsh-client-web-react'
|
||||
|
||||
/**
|
||||
* Behavioral SlotCore fake (the real ui-slots is still a T0 stub): registration
|
||||
* mutates entries, bumps the key version, and notifies subscribers synchronously
|
||||
* (batching semantics belong to fw-slots' core, not this package's outlet).
|
||||
*/
|
||||
function makeFakeCore() {
|
||||
const specs = new Map<string, SlotSpec<SlotEntryDef>>()
|
||||
const entries = new Map<string, SlotEntry<SlotEntryDef>[]>()
|
||||
const versions = new Map<string, number>()
|
||||
const subs = new Map<string, Set<() => void>>()
|
||||
const bump = (key: string) => {
|
||||
versions.set(key, (versions.get(key) ?? 0) + 1)
|
||||
for (const fn of [...(subs.get(key) ?? [])]) fn()
|
||||
}
|
||||
const core = {
|
||||
define: (key: string, spec: SlotSpec<SlotEntryDef>) => {
|
||||
specs.set(key, spec)
|
||||
bump(key)
|
||||
return () => { specs.delete(key); bump(key) }
|
||||
},
|
||||
// Options widened beyond SlotOptions<SlotEntryDef>: fake keys ('fake.list')
|
||||
// are not in SlotMap, so calls resolve against this signature and need the
|
||||
// list/keyed fields the conditional type would otherwise narrow away.
|
||||
register: (
|
||||
key: string, component: FC<object>,
|
||||
options: { key?: string; id?: string; order?: number; label?: string; inject?: InjectFactory<SlotEntryDef> } = {},
|
||||
) => {
|
||||
const list = entries.get(key) ?? []
|
||||
const entry: SlotEntry<SlotEntryDef> = { component, options }
|
||||
entries.set(key, [...list, entry])
|
||||
bump(key)
|
||||
return () => {
|
||||
entries.set(key, (entries.get(key) ?? []).filter((e) => e !== entry))
|
||||
bump(key)
|
||||
}
|
||||
},
|
||||
entries: (key: string) => entries.get(key) ?? [],
|
||||
spec: (key: string) => specs.get(key),
|
||||
subscribe: (key: string, fn: () => void) => {
|
||||
const set = subs.get(key) ?? new Set()
|
||||
set.add(fn)
|
||||
subs.set(key, set)
|
||||
return () => { set.delete(fn) }
|
||||
},
|
||||
getVersion: (key: string) => versions.get(key) ?? 0,
|
||||
onMutate: () => () => {},
|
||||
}
|
||||
return core as unknown as SlotCore & typeof core
|
||||
}
|
||||
|
||||
const useSelectorStub = (() => { throw new Error('unused in these specs') }) as never
|
||||
|
||||
const makeBinding = (sessionId: string): SessionBinding => ({
|
||||
sessionId, session: { useSelector: useSelectorStub }, ctx: { tag: sessionId },
|
||||
})
|
||||
|
||||
/** Mount ui under a SessionProvider bound to one switchable session. */
|
||||
function sessionHarness(body: (id: string) => React.ReactNode, bindings: Record<string, SessionBinding>) {
|
||||
const current = createSnapshotStore<{ id: string | undefined }>({ id: undefined })
|
||||
const deps: SessionProviderDeps = {
|
||||
useCurrent: () => current.useSelector((s) => s.id),
|
||||
resolveBinding: (id) => bindings[id],
|
||||
renderBody: body,
|
||||
}
|
||||
const Provider = createSessionProvider(deps)
|
||||
return { current, Provider }
|
||||
}
|
||||
|
||||
describe('scopedSlots basics', () => {
|
||||
it('throws on renderSlot before define and on non-whitelisted keys', () => {
|
||||
const core = makeFakeCore()
|
||||
const slots = scopedSlots(core, 'fake.root' as never)
|
||||
expect(() => slots.renderSlot('fake.session' as never, {})).toThrow(/whitelist/)
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
expect(() => render(<>{slots.renderSlot('fake.root' as never, {})}</>)).toThrow(/before define/)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('renders single-kind root slots, falls back when empty, live-updates on register/dispose', () => {
|
||||
const core = makeFakeCore()
|
||||
core.define('fake.root', { kind: 'single', scope: 'root' })
|
||||
const slots = scopedSlots(core, 'fake.root' as never)
|
||||
const view = render(<>{slots.renderSlot('fake.root' as never, {}, { fallback: <i>none</i> })}</>)
|
||||
expect(view.container.textContent).toBe('none')
|
||||
let dispose = () => {}
|
||||
act(() => { dispose = core.register('fake.root', () => <b>SB</b>) })
|
||||
expect(view.container.textContent).toBe('SB')
|
||||
act(() => { dispose() })
|
||||
expect(view.container.textContent).toBe('none')
|
||||
})
|
||||
|
||||
it('renders list slots in order, honors only-filter, keyed slots dispatch by entryKey', () => {
|
||||
const core = makeFakeCore()
|
||||
core.define('fake.list', { kind: 'list', scope: 'root' })
|
||||
core.define('fake.keyed', { kind: 'keyed', scope: 'root' })
|
||||
core.register('fake.list', () => <span>b</span>, { id: 'b', order: 2 })
|
||||
core.register('fake.list', () => <span>a</span>, { id: 'a', order: 1 })
|
||||
core.register('fake.keyed', () => <span>goal</span>, { key: 'goal' })
|
||||
const slots = scopedSlots(core, 'fake.list' as never, 'fake.keyed' as never)
|
||||
const list = render(<>{slots.renderSlot('fake.list' as never, {})}</>)
|
||||
expect(list.container.textContent).toBe('ab')
|
||||
const only = render(<>{slots.renderSlot('fake.list' as never, {}, { only: 'b' })}</>)
|
||||
expect(only.container.textContent).toBe('b')
|
||||
const hit = render(<>{slots.renderSlot('fake.keyed' as never, {}, { entryKey: 'goal' })}</>)
|
||||
expect(hit.container.textContent).toBe('goal')
|
||||
const miss = render(
|
||||
<>{slots.renderSlot('fake.keyed' as never, {}, { entryKey: 'nope', fallback: <i>fb</i> })}</>)
|
||||
expect(miss.container.textContent).toBe('fb')
|
||||
})
|
||||
|
||||
it('contains a throwing root inject factory to its own entry (P1 whiteout regression)', () => {
|
||||
const core = makeFakeCore()
|
||||
core.define('fake.list', { kind: 'list', scope: 'root' })
|
||||
core.register('fake.list', () => <span>never</span>, {
|
||||
id: 'bad', order: 1,
|
||||
inject: (() => { throw new Error('inject boom') }) as unknown as InjectFactory<SlotEntryDef>,
|
||||
})
|
||||
core.register('fake.list', () => <span>alive</span>, { id: 'ok', order: 2 })
|
||||
const slots = scopedSlots(core, 'fake.list' as never)
|
||||
const root: RootBinding = { ctx: {} }
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const view = render(
|
||||
<RootBindingProvider value={root}>
|
||||
<main>{slots.renderSlot('fake.list' as never, {})}</main>
|
||||
</RootBindingProvider>,
|
||||
)
|
||||
spy.mockRestore()
|
||||
// The failing entry blacks out alone; the sibling and the tree above survive.
|
||||
expect(view.container.querySelector('main')).not.toBeNull()
|
||||
expect(view.container.textContent).toBe('alive')
|
||||
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('contains a throwing session inject factory to its own entry', () => {
|
||||
const core = makeFakeCore()
|
||||
core.define('fake.session', { kind: 'single', scope: 'session' })
|
||||
core.register('fake.session', () => <span>never</span>, {
|
||||
inject: (() => { throw new Error('session inject boom') }) as unknown as InjectFactory<SlotEntryDef>,
|
||||
})
|
||||
const slots = scopedSlots(core, 'fake.session' as never)
|
||||
const bindings = { s1: makeBinding('s1') }
|
||||
const { current, Provider } = sessionHarness(
|
||||
(id) => <main data-shell={id}>{slots.renderSlot('fake.session' as never, {})}</main>, bindings)
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const view = render(<Provider />)
|
||||
act(() => { current.update((d) => { d.id = 's1' }) })
|
||||
spy.mockRestore()
|
||||
expect(view.container.querySelector('[data-shell]')).not.toBeNull()
|
||||
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('isolates a crashing entry without collapsing siblings', () => {
|
||||
const core = makeFakeCore()
|
||||
core.define('fake.list', { kind: 'list', scope: 'root' })
|
||||
core.register('fake.list', () => { throw new Error('entry boom') }, { id: 'bad', order: 1 })
|
||||
core.register('fake.list', () => <span>alive</span>, { id: 'ok', order: 2 })
|
||||
const slots = scopedSlots(core, 'fake.list' as never)
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const view = render(<>{slots.renderSlot('fake.list' as never, {})}</>)
|
||||
spy.mockRestore()
|
||||
expect(view.container.textContent).toBe('alive')
|
||||
expect(view.container.querySelector('[data-slot-error]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('inject caching and props merge', () => {
|
||||
it('root inject runs once per entry and receives the root binding ctx', () => {
|
||||
const core = makeFakeCore()
|
||||
core.define('fake.root', { kind: 'single', scope: 'root' })
|
||||
const inject = vi.fn((b: RootBinding) => ({ tag: (b.ctx as { tag: string }).tag }))
|
||||
core.register('fake.root', ({ tag }: { tag?: string }) => <b>{tag}</b>,
|
||||
{ inject: inject as unknown as InjectFactory<SlotEntryDef> })
|
||||
const slots = scopedSlots(core, 'fake.root' as never)
|
||||
const root: RootBinding = { ctx: { tag: 'ROOT' } }
|
||||
const view = render(
|
||||
<RootBindingProvider value={root}>
|
||||
{slots.renderSlot('fake.root' as never, {})}
|
||||
</RootBindingProvider>,
|
||||
)
|
||||
expect(view.container.textContent).toBe('ROOT')
|
||||
view.rerender(
|
||||
<RootBindingProvider value={root}>
|
||||
{slots.renderSlot('fake.root' as never, {})}
|
||||
</RootBindingProvider>,
|
||||
)
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('root slots with inject throw without RootBindingProvider; plain entries do not need it', () => {
|
||||
const core = makeFakeCore()
|
||||
core.define('fake.root', { kind: 'single', scope: 'root' })
|
||||
core.register('fake.root', () => <b>plain</b>)
|
||||
const slots = scopedSlots(core, 'fake.root' as never)
|
||||
const view = render(<>{slots.renderSlot('fake.root' as never, {})}</>)
|
||||
expect(view.container.textContent).toBe('plain')
|
||||
|
||||
const core2 = makeFakeCore()
|
||||
core2.define('fake.root', { kind: 'single', scope: 'root' })
|
||||
core2.register('fake.root', () => <b>x</b>,
|
||||
{ inject: (() => ({})) as unknown as InjectFactory<SlotEntryDef> })
|
||||
const slots2 = scopedSlots(core2, 'fake.root' as never)
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
expect(() => render(<>{slots2.renderSlot('fake.root' as never, {})}</>))
|
||||
.toThrow(/RootBindingProvider/)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('session inject caches per (entry x binding): session switch re-invokes, switch-back reuses', () => {
|
||||
const core = makeFakeCore()
|
||||
core.define('fake.session', { kind: 'single', scope: 'session' })
|
||||
const inject = vi.fn((b: { sessionId: string }) => ({ sid: b.sessionId }))
|
||||
core.register('fake.session', ({ sid }: { sid?: string }) => <b>{sid}</b>,
|
||||
{ inject: inject as unknown as InjectFactory<SlotEntryDef> })
|
||||
const slots = scopedSlots(core, 'fake.session' as never)
|
||||
const bindings = { s1: makeBinding('s1'), s2: makeBinding('s2') }
|
||||
const { current, Provider } = sessionHarness(
|
||||
() => slots.renderSlot('fake.session' as never, {}), bindings)
|
||||
const view = render(<Provider />)
|
||||
act(() => { current.update((d) => { d.id = 's1' }) })
|
||||
expect(view.container.textContent).toBe('s1')
|
||||
expect(inject).toHaveBeenCalledTimes(1)
|
||||
act(() => { current.update((d) => { d.id = 's2' }) })
|
||||
expect(view.container.textContent).toBe('s2')
|
||||
expect(inject).toHaveBeenCalledTimes(2)
|
||||
act(() => { current.update((d) => { d.id = 's1' }) }) // back: cache hit
|
||||
expect(view.container.textContent).toBe('s1')
|
||||
expect(inject).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('session slots receive standard useSession injection and owner props win the merge', () => {
|
||||
const core = makeFakeCore()
|
||||
core.define('fake.session', { kind: 'single', scope: 'session' })
|
||||
const seen: Record<string, unknown>[] = []
|
||||
core.register('fake.session', (props: object) => {
|
||||
seen.push(props as Record<string, unknown>)
|
||||
return null
|
||||
}, { inject: (() => ({ fromInject: 'inject', shared: 'inject' })) as unknown as InjectFactory<SlotEntryDef> })
|
||||
const slots = scopedSlots(core, 'fake.session' as never)
|
||||
const bindings = { s1: makeBinding('s1') }
|
||||
const { current, Provider } = sessionHarness(
|
||||
() => slots.renderSlot('fake.session' as never, { owner: 'owner', shared: 'owner' } as never), bindings)
|
||||
render(<Provider />)
|
||||
act(() => { current.update((d) => { d.id = 's1' }) })
|
||||
const props = seen.at(-1)!
|
||||
expect(props.useSession).toBe(bindings.s1.session.useSelector)
|
||||
expect(props.fromInject).toBe('inject')
|
||||
expect(props.owner).toBe('owner')
|
||||
expect(props.shared).toBe('owner') // three-source merge: owner overrides inject
|
||||
})
|
||||
|
||||
it('session slots outside a SessionProvider fail loud', () => {
|
||||
const core = makeFakeCore()
|
||||
core.define('fake.session', { kind: 'single', scope: 'session' })
|
||||
core.register('fake.session', () => <b>x</b>)
|
||||
const slots = scopedSlots(core, 'fake.session' as never)
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
expect(() => render(<>{slots.renderSlot('fake.session' as never, {})}</>))
|
||||
.toThrow(/outside SessionProvider/)
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
106
packages/client/web-react/tests/session-provider.spec.tsx
Normal file
106
packages/client/web-react/tests/session-provider.spec.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
// @vitest-environment jsdom
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import type { RootBinding } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
createSessionProvider, createSnapshotStore, RootBindingProvider,
|
||||
useRootBinding, useSessionBinding,
|
||||
type SessionBinding, type SessionProviderDeps,
|
||||
} from '@deepseek-ai/dsh-client-web-react'
|
||||
|
||||
const makeBinding = (sessionId: string): SessionBinding => ({
|
||||
sessionId,
|
||||
session: { useSelector: (() => { throw new Error('unused') }) as never },
|
||||
ctx: { tag: sessionId },
|
||||
})
|
||||
|
||||
function setup(bindings: Record<string, SessionBinding>) {
|
||||
const current = createSnapshotStore<{ id: string | undefined }>({ id: undefined })
|
||||
const resolveBinding = vi.fn((id: string) => bindings[id])
|
||||
const seen: { id: string; binding: SessionBinding; mountCount: number }[] = []
|
||||
let mounts = 0
|
||||
|
||||
function Body({ id }: { id: string }) {
|
||||
const binding = useSessionBinding()
|
||||
const mountRef = useRef(0)
|
||||
useEffect(() => { mounts += 1; mountRef.current = mounts }, [])
|
||||
seen.push({ id, binding, mountCount: mountRef.current })
|
||||
return <div data-testid="body">{id}</div>
|
||||
}
|
||||
|
||||
const deps: SessionProviderDeps = {
|
||||
useCurrent: () => current.useSelector((s) => s.id),
|
||||
resolveBinding,
|
||||
renderBody: (id) => <Body id={id} />,
|
||||
}
|
||||
const SessionProvider = createSessionProvider(deps)
|
||||
return { current, resolveBinding, SessionProvider, seen, mountCount: () => mounts }
|
||||
}
|
||||
|
||||
describe('createSessionProvider', () => {
|
||||
it('renders empty without a current session and switches to the body on select', () => {
|
||||
const { current, SessionProvider } = setup({ s1: makeBinding('s1') })
|
||||
const view = render(<SessionProvider renderEmpty={() => <span>empty</span>} />)
|
||||
expect(view.container.textContent).toBe('empty')
|
||||
act(() => { current.update((d) => { d.id = 's1' }) })
|
||||
expect(view.container.textContent).toBe('s1')
|
||||
})
|
||||
|
||||
it('renders null empty state when renderEmpty is omitted', () => {
|
||||
const { SessionProvider } = setup({})
|
||||
const view = render(<SessionProvider />)
|
||||
expect(view.container.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('falls back to empty when the binding does not resolve', () => {
|
||||
const { current, SessionProvider } = setup({})
|
||||
const view = render(<SessionProvider renderEmpty={() => <span>empty</span>} />)
|
||||
act(() => { current.update((d) => { d.id = 'ghost' }) })
|
||||
expect(view.container.textContent).toBe('empty')
|
||||
})
|
||||
|
||||
it('passes the resolved binding through context and remounts on session switch', () => {
|
||||
const bindings = { s1: makeBinding('s1'), s2: makeBinding('s2') }
|
||||
const { current, SessionProvider, seen, mountCount } = setup(bindings)
|
||||
render(<SessionProvider />)
|
||||
act(() => { current.update((d) => { d.id = 's1' }) })
|
||||
expect(seen.at(-1)!.binding).toBe(bindings.s1)
|
||||
const mountsAfterS1 = mountCount()
|
||||
act(() => { current.update((d) => { d.id = 's2' }) })
|
||||
expect(seen.at(-1)!.binding).toBe(bindings.s2)
|
||||
// key={id} semantics: switching sessions remounts the body subtree.
|
||||
expect(mountCount()).toBe(mountsAfterS1 + 1)
|
||||
})
|
||||
|
||||
it('does not remount the body when unrelated renders happen on the same session', () => {
|
||||
const bindings = { s1: makeBinding('s1') }
|
||||
const { current, SessionProvider, mountCount } = setup(bindings)
|
||||
const view = render(<SessionProvider />)
|
||||
act(() => { current.update((d) => { d.id = 's1' }) })
|
||||
const mounts = mountCount()
|
||||
view.rerender(<SessionProvider />)
|
||||
expect(mountCount()).toBe(mounts)
|
||||
})
|
||||
})
|
||||
|
||||
describe('binding contexts', () => {
|
||||
it('useSessionBinding throws outside a SessionProvider subtree', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
function Naked() { useSessionBinding(); return null }
|
||||
expect(() => render(<Naked />)).toThrow(/outside SessionProvider/)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('RootBindingProvider supplies the root binding; absence throws', () => {
|
||||
const root: RootBinding = { ctx: { tag: 'root' } }
|
||||
let got: RootBinding | undefined
|
||||
function Probe() { got = useRootBinding(); return null }
|
||||
render(<RootBindingProvider value={root}><Probe /></RootBindingProvider>)
|
||||
expect(got).toBe(root)
|
||||
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
expect(() => render(<Probe />)).toThrow(/RootBindingProvider/)
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
135
packages/client/web-react/tests/store.spec.ts
Normal file
135
packages/client/web-react/tests/store.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSnapshotStore, shallowEqual } from '@deepseek-ai/dsh-client-web-react/store'
|
||||
|
||||
interface State {
|
||||
a: { n: number }
|
||||
b: { list: string[] }
|
||||
}
|
||||
|
||||
const init = (): State => ({ a: { n: 1 }, b: { list: ['x'] } })
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('createSnapshotStore', () => {
|
||||
it('applies update through a draft and preserves untouched branch references', () => {
|
||||
const store = createSnapshotStore(init())
|
||||
const before = store.getSnapshot()
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
const after = store.getSnapshot()
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.a.n).toBe(2)
|
||||
expect(after.b).toBe(before.b)
|
||||
})
|
||||
|
||||
it('notifies synchronously per update by default', () => {
|
||||
const store = createSnapshotStore(init())
|
||||
const seen: number[] = []
|
||||
store.subscribe(() => { seen.push(store.getSnapshot().a.n) })
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
store.update((d) => { d.a.n = 3 })
|
||||
expect(seen).toEqual([2, 3])
|
||||
})
|
||||
|
||||
it('coalesces a frame of updates into one notification in raf mode', () => {
|
||||
const frame: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
frame.push(cb)
|
||||
return frame.length
|
||||
})
|
||||
const store = createSnapshotStore(init(), { flush: 'raf' })
|
||||
const spy = vi.fn()
|
||||
store.subscribe(spy)
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
store.update((d) => { d.a.n = 3 })
|
||||
store.update((d) => { d.b.list.push('y') })
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
expect(frame).toHaveLength(1)
|
||||
frame.shift()!(0)
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
expect(store.getSnapshot().a.n).toBe(3)
|
||||
// Next frame batches independently.
|
||||
store.update((d) => { d.a.n = 4 })
|
||||
expect(frame).toHaveLength(1)
|
||||
frame.shift()!(0)
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('falls back to microtask batching in raf mode without requestAnimationFrame', async () => {
|
||||
const store = createSnapshotStore(init(), { flush: 'raf' })
|
||||
const spy = vi.fn()
|
||||
store.subscribe(spy)
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
store.update((d) => { d.a.n = 3 })
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
await Promise.resolve()
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('unsubscribes raf-mode listeners', () => {
|
||||
const frame: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
frame.push(cb)
|
||||
return frame.length
|
||||
})
|
||||
const store = createSnapshotStore(init(), { flush: 'raf' })
|
||||
const spy = vi.fn()
|
||||
const off = store.subscribe(spy)
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
off()
|
||||
frame.shift()!(0)
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replaces state wholesale via set and freezes it outside production', () => {
|
||||
const store = createSnapshotStore(init())
|
||||
const next = init()
|
||||
store.set(next)
|
||||
expect(store.getSnapshot()).toBe(next)
|
||||
expect(() => { (store.getSnapshot().a).n = 9 }).toThrow()
|
||||
})
|
||||
|
||||
it('freezes update produce output outside production (immer dev freeze)', () => {
|
||||
const store = createSnapshotStore(init())
|
||||
store.update((d) => { d.a.n = 2 })
|
||||
expect(() => { (store.getSnapshot().a).n = 9 }).toThrow()
|
||||
})
|
||||
|
||||
it('rehydrates primitive state whole, not spread into index keys', () => {
|
||||
const backing = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => backing.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { backing.set(k, v) },
|
||||
removeItem: (k: string) => { backing.delete(k) },
|
||||
})
|
||||
const store = createSnapshotStore<string>('', { persist: { name: 'spec-draft' } })
|
||||
store.set('hello')
|
||||
const revived = createSnapshotStore<string>('', { persist: { name: 'spec-draft' } })
|
||||
expect(revived.getSnapshot()).toBe('hello')
|
||||
})
|
||||
|
||||
it('persists to localStorage under the given name and rehydrates', () => {
|
||||
const backing = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => backing.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { backing.set(k, v) },
|
||||
removeItem: (k: string) => { backing.delete(k) },
|
||||
})
|
||||
const store = createSnapshotStore(init(), { persist: { name: 'spec-store' } })
|
||||
store.update((d) => { d.a.n = 42 })
|
||||
expect(backing.has('spec-store')).toBe(true)
|
||||
const revived = createSnapshotStore(init(), { persist: { name: 'spec-store' } })
|
||||
expect(revived.getSnapshot().a.n).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shallowEqual', () => {
|
||||
it('matches one-level-equal objects and rejects deeper drift', () => {
|
||||
const leaf = { deep: 1 }
|
||||
expect(shallowEqual({ x: 1, y: leaf }, { x: 1, y: leaf })).toBe(true)
|
||||
expect(shallowEqual({ x: 1, y: { deep: 1 } }, { x: 1, y: { deep: 1 } })).toBe(false)
|
||||
expect(shallowEqual([1, 2], [1, 2])).toBe(true)
|
||||
expect(shallowEqual([1, 2], [2, 1])).toBe(false)
|
||||
})
|
||||
})
|
||||
84
packages/client/web-react/tests/use-invoke.spec.tsx
Normal file
84
packages/client/web-react/tests/use-invoke.spec.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { useInvoke } from '@deepseek-ai/dsh-client-web-react'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void
|
||||
let reject!: (e: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
interface Probe {
|
||||
invoke: () => void
|
||||
pending: boolean
|
||||
renders: number
|
||||
}
|
||||
|
||||
function Harness({ fn, probe }: { fn: () => Promise<unknown>; probe: Probe }) {
|
||||
const [invoke, pending] = useInvoke(fn)
|
||||
probe.invoke = invoke
|
||||
probe.pending = pending
|
||||
probe.renders += 1
|
||||
return null
|
||||
}
|
||||
|
||||
const newProbe = (): Probe => ({ invoke: () => {}, pending: false, renders: 0 })
|
||||
|
||||
describe('useInvoke', () => {
|
||||
it('tracks pending across the action lifecycle', async () => {
|
||||
const d = deferred<void>()
|
||||
const probe = newProbe()
|
||||
render(<Harness fn={() => d.promise} probe={probe} />)
|
||||
expect(probe.pending).toBe(false)
|
||||
act(() => { probe.invoke() })
|
||||
expect(probe.pending).toBe(true)
|
||||
await act(async () => { d.resolve(); await d.promise })
|
||||
expect(probe.pending).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps pending true until the last concurrent call settles', async () => {
|
||||
const d1 = deferred<void>()
|
||||
const d2 = deferred<void>()
|
||||
const queue = [d1, d2]
|
||||
const probe = newProbe()
|
||||
render(<Harness fn={() => queue.shift()!.promise} probe={probe} />)
|
||||
act(() => { probe.invoke() })
|
||||
act(() => { probe.invoke() })
|
||||
expect(probe.pending).toBe(true)
|
||||
await act(async () => { d1.resolve(); await d1.promise })
|
||||
expect(probe.pending).toBe(true)
|
||||
await act(async () => { d2.resolve(); await d2.promise })
|
||||
expect(probe.pending).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the invoke reference stable while fn changes, and calls the latest fn', async () => {
|
||||
const first = vi.fn(() => Promise.resolve())
|
||||
const second = vi.fn(() => Promise.resolve())
|
||||
const probe = newProbe()
|
||||
const { rerender } = render(<Harness fn={first} probe={probe} />)
|
||||
const invokeBefore = probe.invoke
|
||||
rerender(<Harness fn={second} probe={probe} />)
|
||||
expect(probe.invoke).toBe(invokeBefore)
|
||||
await act(async () => { probe.invoke() })
|
||||
expect(first).not.toHaveBeenCalled()
|
||||
expect(second).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('resets pending and logs when the action rejects', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const d = deferred<void>()
|
||||
const probe = newProbe()
|
||||
render(<Harness fn={() => d.promise} probe={probe} />)
|
||||
act(() => { probe.invoke() })
|
||||
expect(probe.pending).toBe(true)
|
||||
await act(async () => {
|
||||
d.reject(new Error('boom'))
|
||||
await d.promise.catch(() => {})
|
||||
})
|
||||
expect(probe.pending).toBe(false)
|
||||
expect(consoleError).toHaveBeenCalledWith('useInvoke action failed:', expect.any(Error))
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
25
packages/client/web-react/tsconfig.json
Normal file
25
packages/client/web-react/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"jsx": "react-jsx",
|
||||
"lib": [
|
||||
"ES2024",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
42
packages/client/web-react/tsdown.config.ts
Normal file
42
packages/client/web-react/tsdown.config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/**
|
||||
* Root shape plus the store subpath, built as SEPARATE single-entry bundles:
|
||||
* a multi-entry build emits a hash-named shared chunk that the exact `files`
|
||||
* whitelist cannot publish (same shape as code-runtime-worker/user-approval).
|
||||
* Each entry inlines the shared store code instead; the node lib is the
|
||||
* repo-uniform shape (publint/NodeNext), not an identity-sensitive runtime —
|
||||
* browser consumers resolve this package through the loader module table.
|
||||
*/
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: { index: 'lib/types/index.js' },
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'neutral',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
entry: { invariant: 'lib/types/invariant.js' },
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'neutral',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
entry: { 'store/index': 'lib/types/store/index.js' },
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'neutral',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
])
|
||||
Reference in New Issue
Block a user