feat(gui): generic projection value store — host-pushed whole values, higher-seq-wins

The push-model client base (session-projection RFC final): ProjectionValueStore
holds key → {value, seq} per session, seeded by the tail page's projections
block and updated by session/projection frames under one rule — higher seq
wins on both paths (stale baseline cannot overwrite a newer frame; replayed
frames cannot regress; an omitting fresh baseline clears = capability absent);
truncate() drops phantom rows past a subscribed durable baseline. Per-key
identity-stable faces (always defined; absence is an undefined snapshot) feed
useProjection; the renderer contract's projections member becomes faceOf.
12 store specs cover both seq directions, absence, truncation, and batching.
This commit is contained in:
imccyu
2026-07-27 22:01:48 +08:00
parent 1097330df2
commit 531eb7cf0e
5 changed files with 397 additions and 24 deletions

View File

@@ -86,12 +86,12 @@ function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S,
/**
* The useProjection framework seat (session-projection RFC), one bound
* function per provide bundle (cached by info identity — components may hold
* it across renders). Key-addressed: the key resolves a per-session cell
* source, whose bound selector hook comes from the same per-source cache as
* every other kit hook, so exactly one uSES subscription runs per call and
* the subscribe reference stays stable while the cell lives. An unresolved
* key (no cell, no session, plugin unloaded) reads `undefined` — capability
* absence — through the absent source, keeping the hook order constant.
* it across renders). Key-addressed: the key resolves a per-session value
* face off the projection store; the bound selector hook comes from the same
* per-source cache as every other kit hook, so exactly one uSES subscription
* runs per call and the subscribe reference stays stable per key. A key no
* baseline or frame has carried (or a no-session bundle) reads `undefined` —
* capability absence — keeping the hook order constant.
*/
export function projectionHook(info: SessionMaybeProvideInfo): (
key: string, selector?: (value: unknown) => unknown, eq?: (a: unknown, b: unknown) => boolean
@@ -99,14 +99,14 @@ export function projectionHook(info: SessionMaybeProvideInfo): (
let hook = projectionHookCache.get(info)
if (hook === undefined) {
hook = (key, selector, eq) => {
const cell = info.projections?.cellOf(key)
// The absent branch binds the shared absent source so the caller's
// selector still runs over `undefined` (absence flows through the
// selector) and the uSES call count stays constant across resolution.
const useCell = observableHook(cell ?? absentSource)
// Whole values are frozen event/wire data (identical reference between
// events), so the identity selector needs no equality function.
return useCell(selector ?? (value => value), eq)
// The no-session (faceless) branch binds the shared absent source so
// the caller's selector still runs over `undefined` (absence flows
// through the selector) and the uSES call count stays constant.
const useValue = observableHook(info.projections?.faceOf(key) ?? absentSource)
// Whole values are finished wire payloads (reference changes only when
// a frame or baseline lands), so the identity selector needs no
// equality function.
return useValue(selector ?? (value => value), eq)
}
projectionHookCache.set(info, hook)
}

View File

@@ -3,8 +3,8 @@
* useProjection standard-kit delivery (session-projection RFC): the fifth
* framework hook seat rides the same provide channel as useSession — a
* session slot component receives `useProjection` in its kit, key-addressed
* over the bundle's projection face; unresolved keys (no cell, no face, no
* session) uniformly read `undefined`; live cell changes re-render; the
* over the bundle's projection face; unresolved keys (no value, no face, no
* session) uniformly read `undefined`; live value changes re-render; the
* selector overload runs over the whole value.
*/
import { describe, expect, it } from 'vitest'
@@ -27,6 +27,8 @@ type UseProjectionProp = (key: string, selector?: (v: unknown) => unknown) => un
function makeHost() {
const current = observable<string | undefined>(undefined)
const cells = new Map<string, ReturnType<typeof observable<unknown>>>()
/** Store-parallel face: always defined per key; an unseen key snapshots undefined. */
const absent = { getSnapshot: () => undefined, subscribe: () => () => {} }
const sessionEntries: StoredEntry[] = []
let withFace = true
const rootEntry: StoredEntry = {
@@ -39,7 +41,7 @@ function makeHost() {
sessionId: id,
hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
props: {},
...(withFace ? { projections: { cellOf: (key: string) => cells.get(key) } } : {}),
...(withFace ? { projections: { faceOf: (key: string) => cells.get(key) ?? absent } } : {}),
})
const host: SlotRendererHost = {
subscribe: () => () => {},
@@ -66,7 +68,7 @@ function makeHost() {
}
describe('useProjection standard-kit delivery', () => {
it('reads the cell value through the kit, undefined for unresolved keys, and follows live changes', () => {
it('reads the projected value through the kit, undefined for unresolved keys, and follows live changes', () => {
const h = makeHost()
const cell = observable<unknown>({ marks: ['a'] })
h.cells.set('test/marks', cell)