docs(web): the composer bar is one session-maybe slot, not a swapped pair

fix(web): keep one composer bar DOM across the no-workspace transition

The composer.bar slot moves from session to session-maybe scope: with no
current session the entry still mounts, the machine faces (keyboard, stop,
command) arrive undefined, and the bar renders its normal DOM inert via the
disabled owner prop. DisabledInputBar and its parallel tree are gone, so the
textarea node survives the cold-start workspace pick instead of flashing
through a remount. A blank session whose workspace was deleted takes the
same inert path through owner props.

test(web): add the composer DOM-continuity acceptance probe

Drives a real dsh web server with headless chromium through the cold-start
-> pick-workspace -> type flow and asserts the composer textarea is the
same DOM node throughout (a marker property must survive). Rerun
prerequisites are in the header comment.

docs(web): session-maybe identity is adoption, not hold-forever

fix(web): session-maybe entries adopt the first session, then remount like strict entries

A session-maybe entry used to keep one React instance across every
transition, so component-local state leaked between sessions once the
composer bar moved to that scope (PermissionSelect's optimistic pick, the
IME composition guard). Identity is now adoption: an incarnation born
session-less holds through the arrival of the first session (the blank
shell's DOM survives the workspace pick), and afterwards behaves exactly
like a strict session entry — a switch or a drop to no-session remounts,
clearing local state by construction. The child key is an incarnation
counter kept in the stable outlet wrapper via render-phase setState.

chore: knip knows the root acceptance probe's playwright dependency

scripts/hero-composer-dom-continuity.mjs resolves playwright through
apps/web's devDependency tree (createRequire), which knip cannot follow;
ignore it at the root workspace.
This commit is contained in:
imccyu
2026-07-29 21:06:27 +08:00
committed by imccyu
parent ecd07abdc0
commit 6ea1238714
20 changed files with 346 additions and 115 deletions
+57 -3
View File
@@ -2,7 +2,7 @@
* React renderer for declarative slots. Per-entry bindings enforce child
* authorization, and entry boundaries contain registrant failures.
*/
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import { Component, useState, useSyncExternalStore, type FC, type ReactNode } from 'react'
import {
SlotOwnershipError, StaleAuthorizationError,
type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts,
@@ -366,15 +366,69 @@ function SessionEntry({ entry, ownerProps, info }: {
return <Comp {...kit} {...injected} {...ownerProps} />
}
function SessionMaybeEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
function SessionMaybeEntryBody({ entry, ownerProps, info }: {
entry: StoredEntry
ownerProps: object
info: SessionMaybeProvideInfo
}) {
const host = useHost()
const info = useSessionMaybeProvideInfo()
const Comp = entry.component as FC<InjectedProps>
const { kit, actions } = standardKit(host, entry, 'session-maybe', info)
const injected = cachedSessionMaybeInject(entry, info, actions)
return <Comp {...kit} {...injected} {...ownerProps} />
}
/**
* Session-maybe identity: adoption — the ONLY behavior (there is no
* hold-identity-forever mode). An incarnation born session-less ADOPTS the
* first session that arrives: identity holds across that one transition
* (undefined → first id), so a blank shell's DOM survives the moment a
* session appears. From then on the entry behaves exactly like a strict
* session entry: switching to a DIFFERENT session remounts (component-local
* state must not leak between sessions), and dropping back to no-session
* remounts into a fresh blank incarnation, which will adopt again.
* Component-local per-session state therefore clears by construction; state
* that must SURVIVE a switch belongs in session-bound sources (machine,
* store, hooks) — the existing layering rule, now load-bearing.
*/
function SessionMaybeEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
const info = useSessionMaybeProvideInfo()
// The child key is an incarnation counter, NOT the session id: adoption
// must keep the key constant across undefined → first id. Bookkeeping
// lives in this stable (unkeyed) wrapper via the render-phase setState
// form (React's sanctioned derived-state pattern: setState during render
// of the same component re-renders once before children mount, and the
// guard conditions make it convergent — StrictMode-safe).
const [state, setState] = useState<MaybeIncarnation>(FIRST_INCARNATION)
let { adopted, epoch } = state
if (info.sessionId !== undefined && adopted === undefined) {
// Adoption: same epoch — no remount.
adopted = info.sessionId
setState({ adopted, epoch })
} else if (adopted !== undefined && info.sessionId !== undefined && info.sessionId !== adopted) {
// Post-adoption session switch: next incarnation, born already adopted.
adopted = info.sessionId
epoch += 1
setState({ adopted, epoch })
} else if (adopted !== undefined && info.sessionId === undefined) {
// Back to no-session: next incarnation, born blank (adopts anew later).
adopted = undefined
epoch += 1
setState({ adopted, epoch })
}
return <SessionMaybeEntryBody key={epoch} entry={entry} ownerProps={ownerProps} info={info} />
}
/** Adoption bookkeeping of one session-maybe outlet (see SessionMaybeEntry). */
interface MaybeIncarnation {
/** Session this incarnation adopted; undefined while born blank and unadopted. */
readonly adopted: string | undefined
/** Incarnation counter — the child key; bumps exactly when an incarnation dies. */
readonly epoch: number
}
const FIRST_INCARNATION: MaybeIncarnation = { adopted: undefined, epoch: 0 }
function RootEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
const host = useHost()
const Comp = entry.component as FC<InjectedProps>
@@ -117,9 +117,10 @@ const projectionHookCache = new WeakMap<SessionMaybeProvideInfo, (
) => unknown>()
/**
* Root-level binding provider. It follows current selection without a key, so
* session-maybe entries retain their React identity while the context value
* moves between absent and definite session bundles.
* Root-level binding provider. It follows current selection without a key;
* per-entry identity is the outlet's adoption bookkeeping (SessionMaybeEntry):
* a blank-born incarnation adopts the first session without remounting, and
* every later transition (switch or loss) remounts like a strict entry.
*/
export function SessionMaybeProvider({ children }: { children: ReactNode }) {
const host = useHost()
@@ -10,7 +10,7 @@
*/
import { describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render } from '@testing-library/react'
import { useEffect, type ReactNode } from 'react'
import { useEffect, useState, type ReactNode } from 'react'
import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import {
@@ -863,3 +863,70 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
expect(props['shared']).toBe('owner') // owner overrides inject
})
})
describe('session-maybe adoption identity', () => {
const SINGLE_MAYBE: DeclaredSpec = { kind: 'single', scope: 'session-maybe' }
/** Mount a maybe entry that records its mount count and local state. */
function mountMaybeCounter(h: Fake) {
let mounts = 0
const seen: { sessionId: string | undefined; mount: number }[] = []
h.declare('k.maybe', SINGLE_MAYBE)
h.add('k.maybe', {
component: ({ sessionId }: { sessionId?: string }) => {
// Local mount marker: useState initializer runs once per incarnation.
const [mount] = useState(() => ++mounts)
seen.push({ sessionId, mount })
return <b>{`${sessionId ?? 'blank'}#${mount}`}</b>
},
})
const { view } = mountRoot(h, { 'k.maybe': SINGLE_MAYBE }, renderSlot => renderSlot('k.maybe', {}))
return { view, seen }
}
it('adopts the first session: blank → first id keeps the incarnation (no remount)', () => {
const h = makeHost()
h.addSession('s1')
const { view } = mountMaybeCounter(h)
expect(view.container.textContent).toBe('blank#1')
act(() => { h.current.set('s1') })
// Same incarnation (#1): the blank shell adopted s1.
expect(view.container.textContent).toBe('s1#1')
})
it('remounts on a post-adoption session switch (local state must not leak across sessions)', () => {
const h = makeHost()
h.addSession('s1')
h.addSession('s2')
const { view } = mountMaybeCounter(h)
act(() => { h.current.set('s1') })
expect(view.container.textContent).toBe('s1#1')
act(() => { h.current.set('s2') })
// New incarnation (#2): strict-session behavior after adoption.
expect(view.container.textContent).toBe('s2#2')
})
it('remounts into a fresh blank incarnation on session loss, then adopts anew', () => {
const h = makeHost()
h.addSession('s1')
h.addSession('s2')
const { view } = mountMaybeCounter(h)
act(() => { h.current.set('s1') })
expect(view.container.textContent).toBe('s1#1')
act(() => { h.current.set(undefined) })
// The adopted incarnation dies with its session; blank state is fresh.
expect(view.container.textContent).toBe('blank#2')
act(() => { h.current.set('s2') })
// The fresh blank adopts again — still incarnation #2, no flash.
expect(view.container.textContent).toBe('s2#2')
})
it('keeps the incarnation across a no-op republish of the same session', () => {
const h = makeHost()
h.addSession('s1')
const { view } = mountMaybeCounter(h)
act(() => { h.current.set('s1') })
act(() => { h.current.set('s1') })
expect(view.container.textContent).toBe('s1#1')
})
})