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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user