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