experimental-a2 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.js CHANGED
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { t as createClient } from "./client-BKlyLiOU.js";
2
+ import { t as createClient } from "./client-Dj5d3SP_.js";
3
3
  import { createContext, createElement, useContext, useEffect, useMemo, useSyncExternalStore } from "react";
4
4
  //#region src/react.ts
5
5
  /**
@@ -13,10 +13,15 @@ import { createContext, createElement, useContext, useEffect, useMemo, useSyncEx
13
13
  * renders resolve the same live session. Everything is typed by the
14
14
  * reducer value — no type arguments, and nothing here ever touches the
15
15
  * machine module.
16
+ *
17
+ * The standalone `useSession(client, sessionId, options?)` is the
18
+ * provider-less primitive for client-first apps: identity-mapped
19
+ * handles, one leased stream per session, and an app-owned hydration
20
+ * input — the hook holds the stream until the app hands it a
21
+ * `{ state, index }` fold.
16
22
  */
17
23
  function createReact(options) {
18
24
  const Context = createContext(null);
19
- const providerMounts = /* @__PURE__ */ new WeakMap();
20
25
  const resolveSession = "client" in options ? (sessionId, _api, initialState, initialIndex, initialEvents, participant) => options.client.session(sessionId, {
21
26
  initialState,
22
27
  initialIndex,
@@ -53,48 +58,82 @@ function createReact(options) {
53
58
  initialEvents,
54
59
  participant
55
60
  ]);
56
- useEffect(() => {
57
- const mounts = providerMounts.get(client) ?? 0;
58
- providerMounts.set(client, mounts + 1);
59
- if (mounts === 0) client.connect();
60
- return () => {
61
- const remaining = (providerMounts.get(client) ?? 1) - 1;
62
- if (remaining === 0) {
63
- providerMounts.delete(client);
64
- client.close();
65
- } else providerMounts.set(client, remaining);
66
- };
67
- }, [client]);
61
+ useSessionLifecycle(client, true);
68
62
  return createElement(Context.Provider, { value: client }, props.children);
69
63
  }
70
- function useSession() {
64
+ function useBoundSession() {
71
65
  const client = useContext(Context);
72
66
  if (!client) throw new Error("useSession must be rendered inside its matching SessionProvider");
73
- const snapshot = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);
74
- return useMemo(() => {
75
- const { setPresence } = client;
76
- const { presence } = snapshot;
77
- return {
78
- state: snapshot.state,
79
- events: snapshot.events,
80
- index: snapshot.index,
81
- connection: snapshot.connection,
82
- push: client.push,
83
- loadHistory: client.loadHistory,
84
- history: snapshot.history,
85
- ...setPresence && presence ? {
86
- presence,
87
- setPresence
88
- } : {}
89
- };
90
- }, [snapshot, client]);
67
+ return useSessionResult(client);
91
68
  }
92
69
  return {
93
70
  SessionProvider,
94
- useSession
71
+ useSession: useBoundSession
95
72
  };
96
73
  }
74
+ /**
75
+ * Hold a stream lease while mounted and `live` — the client refcounts
76
+ * leases per handle, so however many providers and hooks mount one
77
+ * session, the first connects and the last release closes
78
+ * (StrictMode-safe: the lease is retaken on remount). A standalone
79
+ * hook without a `hydrate` value passes `live: false` and holds the
80
+ * stream — connecting an unhydrated session would replay the whole
81
+ * log, and the library never picks the expensive path silently.
82
+ */
83
+ function useSessionLifecycle(session, live) {
84
+ useEffect(() => {
85
+ if (!live) return;
86
+ return session.connect();
87
+ }, [session, live]);
88
+ }
89
+ function useSessionResult(session) {
90
+ const snapshot = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);
91
+ return useMemo(() => {
92
+ const { setPresence } = session;
93
+ const { presence } = snapshot;
94
+ return {
95
+ state: snapshot.state,
96
+ events: snapshot.events,
97
+ index: snapshot.index,
98
+ connection: snapshot.connection,
99
+ push: session.push,
100
+ loadHistory: session.loadHistory,
101
+ history: snapshot.history,
102
+ ...setPresence && presence ? {
103
+ presence,
104
+ setPresence
105
+ } : {}
106
+ };
107
+ }, [snapshot, session]);
108
+ }
109
+ /**
110
+ * The standalone, provider-less hook — for apps whose components reach
111
+ * sessions ad hoc (a sidebar of channel sessions, a user session read
112
+ * from a menu). Takes the shared client and a session id; the handle
113
+ * is identity-mapped, so every hook and provider mounting the same
114
+ * session shares one runtime and one leased stream. Hydration is
115
+ * app-owned: pass `hydrate` when your fetch lands; until then the
116
+ * stream is held, and your data layer's loading state is the pending
117
+ * state.
118
+ */
119
+ function useSession(client, sessionId, options) {
120
+ const { participant, hydrate } = options ?? {};
121
+ const session = useMemo(() => client.session(sessionId, {
122
+ ...hydrate === void 0 ? {} : {
123
+ initialState: hydrate.state,
124
+ initialIndex: hydrate.index
125
+ },
126
+ ...participant === void 0 ? {} : { participant }
127
+ }), [
128
+ client,
129
+ sessionId,
130
+ hydrate,
131
+ participant
132
+ ]);
133
+ useSessionLifecycle(session, hydrate !== void 0);
134
+ return useSessionResult(session);
135
+ }
97
136
  //#endregion
98
- export { createReact };
137
+ export { createReact, useSession };
99
138
 
100
139
  //# sourceMappingURL=react.js.map
package/dist/react.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"react.js","names":[],"sources":["../src/react.ts"],"sourcesContent":["'use client'\n/**\n * experimental-a2/react — React bindings over experimental-a2/client.\n *\n * `createReact({ client })` is a factory (like createContext): call it\n * once in a `'use client'` module with a shared A2 client and export the\n * bound pair. Server components import `SessionProvider` from that module\n * as a client reference; client components import `useSession` from the\n * same file. The shared client also makes imperative pushes and provider\n * renders resolve the same live session. Everything is typed by the\n * reducer value — no type arguments, and nothing here ever touches the\n * machine module.\n */\n\nimport {\n createContext,\n createElement,\n useContext,\n useEffect,\n useMemo,\n useSyncExternalStore,\n type ReactElement,\n type ReactNode,\n} from 'react'\nimport {\n createClient,\n type A2Client,\n type Connection,\n type HistoryState,\n type LoadHistoryOptions,\n type PushResult,\n type SessionClient,\n type SessionClientPresence,\n type SessionSnapshot,\n} from './client.ts'\nimport type {\n AppendInput,\n ContractEvent,\n EventDefs,\n PresenceDefs,\n PresenceMap,\n PresencePatch,\n WithPresence,\n} from './contract.ts'\nimport type { Reducer } from './reducer.ts'\n\nexport type SessionProviderProps<D extends EventDefs, S> = {\n /** Which session to subscribe to. */\n sessionId: string\n /** Base path of the route exposing GET (stream) and POST (push). */\n api: string\n /** The server-rendered fold — the first paint, no client JS needed. */\n initialState: S\n /** The fold's frontier — the stream resumes exactly there. */\n initialIndex: number\n /** Server-rendered event history through `initialIndex`. */\n initialEvents?: ContractEvent<D>[]\n /**\n * This client's presence identity — required to call `setPresence`.\n * Caller-supplied (a user id, a tab nonce, a guest name): A2 does\n * not invent an identity story.\n */\n participant?: string\n children?: ReactNode\n}\n\nexport type BoundSessionProviderProps<D extends EventDefs, S> = Omit<\n SessionProviderProps<D, S>,\n 'api'\n>\n\n/**\n * The presence members of the hook result — intersected in via\n * `WithPresence`, so they exist exactly when the contract declares\n * presence fields (the reducer is the client's typed handle on it).\n */\nexport type UseSessionPresence<P extends PresenceDefs> = {\n /** The replicated presence map, this client included (local echo). */\n presence: PresenceMap<P>\n /**\n * Fire-and-forget: validated locally, echoed into the map\n * immediately, coalesced on the wire to a fixed cadence (a leading\n * send immediately, then at most one merged send per interval;\n * later values win per field), stamped with the frontier at send\n * time as `seen`. Requires the provider's `participant`.\n */\n setPresence: (values: PresencePatch<P>['values']) => void\n}\n\nexport type UseSessionResult<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n> = WithPresence<P, UseSessionPresence<P>> & {\n /** The live view, folded through the shared reducer. */\n state: S\n /** The raw event feed — observed, seeded, or backscrolled. */\n events: ContractEvent<D>[]\n /** The stream frontier — `lastSeenIndex` for cancellation. */\n index: number\n /**\n * The connection, as a discriminated union — `error` exists only\n * while disconnected; \"reconnecting…\" is\n * `status === 'connecting' && reconnects > 0`.\n */\n connection: Connection\n /**\n * Typed optimistic append. Awaiting it gives the server ack;\n * `.confirmed` resolves when the live stream has delivered the batch\n * back. Rejects with the server's A2Error codes.\n */\n push: (...events: AppendInput<D>[]) => PushResult<D>\n /**\n * Backscroll: fetch a bounded slice of older events into `events`.\n * Defaults page backward 50 at a time from the oldest loaded event.\n * Display data only — `state` and the optimistic overlay never move.\n */\n loadHistory: (options?: LoadHistoryOptions) => Promise<ContractEvent<D>[]>\n /** Backscroll progress: `complete` means the feed reaches index 1. */\n history: HistoryState\n}\n\nexport type A2React<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n> = {\n SessionProvider: (props: SessionProviderProps<D, S>) => ReactElement\n useSession: () => UseSessionResult<D, S, P>\n}\n\nexport type BoundA2React<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n> = {\n SessionProvider: (props: BoundSessionProviderProps<D, S>) => ReactElement\n useSession: () => UseSessionResult<D, S, P>\n}\n\nexport function createReact<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n>(options: { client: A2Client<D, S, P> }): BoundA2React<D, S, P>\nexport function createReact<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n>(options: { reducer: Reducer<D, S, P> }): A2React<D, S, P>\nexport function createReact<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n>(\n options: { client: A2Client<D, S, P> } | { reducer: Reducer<D, S, P> },\n): BoundA2React<D, S, P> | A2React<D, S, P> {\n const Context = createContext<SessionClient<D, S, P> | null>(null)\n const providerMounts = new WeakMap<SessionClient<D, S, P> & object, number>()\n const resolveSession: (\n sessionId: string,\n api: string | undefined,\n initialState: S,\n initialIndex: number,\n initialEvents: ContractEvent<D>[] | undefined,\n participant: string | undefined,\n ) => SessionClient<D, S, P> =\n 'client' in options\n ? (\n sessionId,\n _api,\n initialState,\n initialIndex,\n initialEvents,\n participant,\n ) =>\n options.client.session(sessionId, {\n initialState,\n initialIndex,\n ...(initialEvents === undefined ? {} : { initialEvents }),\n ...(participant === undefined ? {} : { participant }),\n })\n : (() => {\n const clients = new Map<string, A2Client<D, S, P>>()\n return (\n sessionId: string,\n api: string | undefined,\n initialState: S,\n initialIndex: number,\n initialEvents: ContractEvent<D>[] | undefined,\n participant: string | undefined,\n ) => {\n if (api === undefined) {\n throw new TypeError(\n 'SessionProvider requires api when createReact binds a reducer',\n )\n }\n let client = clients.get(api)\n if (!client) {\n client = createClient({ reducer: options.reducer, api })\n clients.set(api, client)\n }\n return client.session(sessionId, {\n initialState,\n initialIndex,\n ...(initialEvents === undefined ? {} : { initialEvents }),\n ...(participant === undefined ? {} : { participant }),\n })\n }\n })()\n\n function SessionProvider(\n props: SessionProviderProps<D, S> | BoundSessionProviderProps<D, S>,\n ): ReactElement {\n const {\n sessionId,\n initialState,\n initialIndex,\n initialEvents,\n participant,\n } = props\n const api = 'api' in props ? props.api : undefined\n const client = useMemo(\n () =>\n resolveSession(\n sessionId,\n api,\n initialState,\n initialIndex,\n initialEvents,\n participant,\n ),\n [sessionId, api, initialState, initialIndex, initialEvents, participant],\n )\n useEffect(() => {\n const mounts = providerMounts.get(client) ?? 0\n providerMounts.set(client, mounts + 1)\n if (mounts === 0) client.connect()\n return () => {\n const remaining = (providerMounts.get(client) ?? 1) - 1\n if (remaining === 0) {\n providerMounts.delete(client)\n client.close()\n } else {\n providerMounts.set(client, remaining)\n }\n }\n }, [client])\n return createElement(Context.Provider, { value: client }, props.children)\n }\n\n function useSession(): UseSessionResult<D, S, P> {\n const client = useContext(Context)\n if (!client) {\n throw new Error(\n 'useSession must be rendered inside its matching SessionProvider',\n )\n }\n const snapshot: SessionSnapshot<D, S, P> = useSyncExternalStore(\n client.subscribe,\n client.getSnapshot,\n client.getSnapshot,\n )\n return useMemo(() => {\n // Runtime mirror of the WithPresence surface: the client attaches\n // setPresence (and the snapshot carries presence) exactly when the\n // reducer declares presence fields — no runtime-inert members.\n const { setPresence } = client as Partial<SessionClientPresence<P>>\n const { presence } = snapshot as Partial<{ presence: PresenceMap<P> }>\n return {\n state: snapshot.state,\n events: snapshot.events,\n index: snapshot.index,\n connection: snapshot.connection,\n push: client.push,\n loadHistory: client.loadHistory,\n history: snapshot.history,\n ...(setPresence && presence ? { presence, setPresence } : {}),\n } as UseSessionResult<D, S, P>\n }, [snapshot, client])\n }\n\n return { SessionProvider, useSession }\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAsJA,SAAgB,YAKd,SAC0C;CAC1C,MAAM,UAAU,cAA6C,IAAI;CACjE,MAAM,iCAAiB,IAAI,QAAiD;CAC5E,MAAM,iBAQJ,YAAY,WAEN,WACA,MACA,cACA,cACA,eACA,gBAEA,QAAQ,OAAO,QAAQ,WAAW;EAChC;EACA;EACA,GAAI,kBAAkB,KAAA,IAAY,CAAC,IAAI,EAAE,cAAc;EACvD,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;CACrD,CAAC,WACI;EACL,MAAM,0BAAU,IAAI,IAA+B;EACnD,QACE,WACA,KACA,cACA,cACA,eACA,gBACG;GACH,IAAI,QAAQ,KAAA,GACV,MAAM,IAAI,UACR,+DACF;GAEF,IAAI,SAAS,QAAQ,IAAI,GAAG;GAC5B,IAAI,CAAC,QAAQ;IACX,SAAS,aAAa;KAAE,SAAS,QAAQ;KAAS;IAAI,CAAC;IACvD,QAAQ,IAAI,KAAK,MAAM;GACzB;GACA,OAAO,OAAO,QAAQ,WAAW;IAC/B;IACA;IACA,GAAI,kBAAkB,KAAA,IAAY,CAAC,IAAI,EAAE,cAAc;IACvD,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;GACrD,CAAC;EACH;CACF,EAAA,CAAG;CAET,SAAS,gBACP,OACc;EACd,MAAM,EACJ,WACA,cACA,cACA,eACA,gBACE;EACJ,MAAM,MAAM,SAAS,QAAQ,MAAM,MAAM,KAAA;EACzC,MAAM,SAAS,cAEX,eACE,WACA,KACA,cACA,cACA,eACA,WACF,GACF;GAAC;GAAW;GAAK;GAAc;GAAc;GAAe;EAAW,CACzE;EACA,gBAAgB;GACd,MAAM,SAAS,eAAe,IAAI,MAAM,KAAK;GAC7C,eAAe,IAAI,QAAQ,SAAS,CAAC;GACrC,IAAI,WAAW,GAAG,OAAO,QAAQ;GACjC,aAAa;IACX,MAAM,aAAa,eAAe,IAAI,MAAM,KAAK,KAAK;IACtD,IAAI,cAAc,GAAG;KACnB,eAAe,OAAO,MAAM;KAC5B,OAAO,MAAM;IACf,OACE,eAAe,IAAI,QAAQ,SAAS;GAExC;EACF,GAAG,CAAC,MAAM,CAAC;EACX,OAAO,cAAc,QAAQ,UAAU,EAAE,OAAO,OAAO,GAAG,MAAM,QAAQ;CAC1E;CAEA,SAAS,aAAwC;EAC/C,MAAM,SAAS,WAAW,OAAO;EACjC,IAAI,CAAC,QACH,MAAM,IAAI,MACR,iEACF;EAEF,MAAM,WAAqC,qBACzC,OAAO,WACP,OAAO,aACP,OAAO,WACT;EACA,OAAO,cAAc;GAInB,MAAM,EAAE,gBAAgB;GACxB,MAAM,EAAE,aAAa;GACrB,OAAO;IACL,OAAO,SAAS;IAChB,QAAQ,SAAS;IACjB,OAAO,SAAS;IAChB,YAAY,SAAS;IACrB,MAAM,OAAO;IACb,aAAa,OAAO;IACpB,SAAS,SAAS;IAClB,GAAI,eAAe,WAAW;KAAE;KAAU;IAAY,IAAI,CAAC;GAC7D;EACF,GAAG,CAAC,UAAU,MAAM,CAAC;CACvB;CAEA,OAAO;EAAE;EAAiB;CAAW;AACvC"}
1
+ {"version":3,"file":"react.js","names":[],"sources":["../src/react.ts"],"sourcesContent":["'use client'\n/**\n * experimental-a2/react — React bindings over experimental-a2/client.\n *\n * `createReact({ client })` is a factory (like createContext): call it\n * once in a `'use client'` module with a shared A2 client and export the\n * bound pair. Server components import `SessionProvider` from that module\n * as a client reference; client components import `useSession` from the\n * same file. The shared client also makes imperative pushes and provider\n * renders resolve the same live session. Everything is typed by the\n * reducer value — no type arguments, and nothing here ever touches the\n * machine module.\n *\n * The standalone `useSession(client, sessionId, options?)` is the\n * provider-less primitive for client-first apps: identity-mapped\n * handles, one leased stream per session, and an app-owned hydration\n * input — the hook holds the stream until the app hands it a\n * `{ state, index }` fold.\n */\n\nimport {\n createContext,\n createElement,\n useContext,\n useEffect,\n useMemo,\n useSyncExternalStore,\n type ReactElement,\n type ReactNode,\n} from 'react'\nimport {\n createClient,\n type A2Client,\n type Connection,\n type HistoryState,\n type LoadHistoryOptions,\n type PushResult,\n type SessionClient,\n type SessionClientPresence,\n type SessionSnapshot,\n} from './client.ts'\nimport type {\n AppendInput,\n ContractEvent,\n EventDefs,\n PresenceDefs,\n PresenceMap,\n PresencePatch,\n WithPresence,\n} from './contract.ts'\nimport type { Reducer } from './reducer.ts'\n\nexport type UseSessionOptions<S> = {\n /** Overrides the client's `participant` for this session — for apps\n * whose identity is only known at mount time. Bound once per\n * session handle. */\n participant?: string\n /**\n * The server fold this session mounts from: the `{ state, index }`\n * pair `session.state(reducer)` returns, fetched by the app through\n * its own route. Atomic on purpose — the fold and its frontier\n * travel together or not at all. While `undefined` (the app's fetch\n * has not landed), the hook holds the stream; when the value\n * arrives, the session hydrates through the ordinary re-hydration\n * seam and the stream connects at that frontier. To fold from the log's start\n * deliberately, hand the fold's true starting point:\n * `{ state: reducer.initialState, index: 0 }` — state at index 0 is\n * the reducer's seed by definition, so the explicit replay needs no\n * special vocabulary. There is no pending/ready echo on the result:\n * whether the fold has been handed over is this option — the\n * caller's own input — so the data layer's loading state IS the\n * pending state.\n */\n hydrate?: { state: S; index: number } | undefined\n}\n\nexport type SessionProviderProps<D extends EventDefs, S> = {\n /** Which session to subscribe to. */\n sessionId: string\n /** Base path of the route exposing GET (stream) and POST (push). */\n api: string\n /** The server-rendered fold — the first paint, no client JS needed. */\n initialState: S\n /** The fold's frontier — the stream resumes exactly there. */\n initialIndex: number\n /** Server-rendered event history through `initialIndex`. */\n initialEvents?: ContractEvent<D>[]\n /** Overrides the client's `participant` for this session — for apps\n * whose identity is only known at mount time. */\n participant?: string\n children?: ReactNode\n}\n\nexport type BoundSessionProviderProps<D extends EventDefs, S> = Omit<\n SessionProviderProps<D, S>,\n 'api'\n>\n\n/**\n * The presence members of the hook result — intersected in via\n * `WithPresence`, so they exist exactly when the contract declares\n * presence fields (the reducer is the client's typed handle on it).\n */\nexport type UseSessionPresence<P extends PresenceDefs> = {\n /** The replicated presence map, this client included (local echo). */\n presence: PresenceMap<P>\n /**\n * Fire-and-forget: validated locally, echoed into the map\n * immediately, coalesced on the wire to a fixed cadence (a leading\n * send immediately, then at most one merged send per interval;\n * later values win per field), stamped with the frontier at send\n * time as `seen`. Requires the provider's `participant`.\n */\n setPresence: (values: PresencePatch<P>['values']) => void\n}\n\nexport type UseSessionResult<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n> = WithPresence<P, UseSessionPresence<P>> & {\n /** The live view, folded through the shared reducer. */\n state: S\n /** The raw event feed — observed, seeded, or backscrolled. */\n events: ContractEvent<D>[]\n /** The stream frontier — `lastSeenIndex` for cancellation. */\n index: number\n /**\n * The connection, as a discriminated union — `error` exists only\n * while disconnected; \"reconnecting…\" is\n * `status === 'connecting' && reconnects > 0`.\n */\n connection: Connection\n /**\n * Typed optimistic append. Awaiting it gives the server ack;\n * `.confirmed` resolves when the live stream has delivered the batch\n * back. Rejects with the server's A2Error codes.\n */\n push: (...events: AppendInput<D>[]) => PushResult<D>\n /**\n * Backscroll: fetch a bounded slice of older events into `events`.\n * Defaults page backward 50 at a time from the oldest loaded event.\n * Display data only — `state` and the optimistic overlay never move.\n */\n loadHistory: (options?: LoadHistoryOptions) => Promise<ContractEvent<D>[]>\n /** Backscroll progress: `complete` means the feed reaches index 1. */\n history: HistoryState\n}\n\nexport type A2React<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n> = {\n SessionProvider: (props: SessionProviderProps<D, S>) => ReactElement\n useSession: () => UseSessionResult<D, S, P>\n}\n\nexport type BoundA2React<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n> = {\n SessionProvider: (props: BoundSessionProviderProps<D, S>) => ReactElement\n useSession: () => UseSessionResult<D, S, P>\n}\n\nexport function createReact<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n>(options: { client: A2Client<D, S, P> }): BoundA2React<D, S, P>\nexport function createReact<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n>(options: { reducer: Reducer<D, S, P> }): A2React<D, S, P>\nexport function createReact<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n>(\n options: { client: A2Client<D, S, P> } | { reducer: Reducer<D, S, P> },\n): BoundA2React<D, S, P> | A2React<D, S, P> {\n const Context = createContext<SessionClient<D, S, P> | null>(null)\n const resolveSession: (\n sessionId: string,\n api: string | undefined,\n initialState: S,\n initialIndex: number,\n initialEvents: ContractEvent<D>[] | undefined,\n participant: string | undefined,\n ) => SessionClient<D, S, P> =\n 'client' in options\n ? (\n sessionId,\n _api,\n initialState,\n initialIndex,\n initialEvents,\n participant,\n ) =>\n options.client.session(sessionId, {\n initialState,\n initialIndex,\n ...(initialEvents === undefined ? {} : { initialEvents }),\n ...(participant === undefined ? {} : { participant }),\n })\n : (() => {\n const clients = new Map<string, A2Client<D, S, P>>()\n return (\n sessionId: string,\n api: string | undefined,\n initialState: S,\n initialIndex: number,\n initialEvents: ContractEvent<D>[] | undefined,\n participant: string | undefined,\n ) => {\n if (api === undefined) {\n throw new TypeError(\n 'SessionProvider requires api when createReact binds a reducer',\n )\n }\n let client = clients.get(api)\n if (!client) {\n client = createClient({ reducer: options.reducer, api })\n clients.set(api, client)\n }\n return client.session(sessionId, {\n initialState,\n initialIndex,\n ...(initialEvents === undefined ? {} : { initialEvents }),\n ...(participant === undefined ? {} : { participant }),\n })\n }\n })()\n\n function SessionProvider(\n props: SessionProviderProps<D, S> | BoundSessionProviderProps<D, S>,\n ): ReactElement {\n const {\n sessionId,\n initialState,\n initialIndex,\n initialEvents,\n participant,\n } = props\n const api = 'api' in props ? props.api : undefined\n const client = useMemo(\n () =>\n resolveSession(\n sessionId,\n api,\n initialState,\n initialIndex,\n initialEvents,\n participant,\n ),\n [sessionId, api, initialState, initialIndex, initialEvents, participant],\n )\n useSessionLifecycle(client, true)\n return createElement(Context.Provider, { value: client }, props.children)\n }\n\n function useBoundSession(): UseSessionResult<D, S, P> {\n const client = useContext(Context)\n if (!client) {\n throw new Error(\n 'useSession must be rendered inside its matching SessionProvider',\n )\n }\n return useSessionResult(client)\n }\n\n return { SessionProvider, useSession: useBoundSession }\n}\n\n// ── the shared session lifecycle ───────────────────────────────\n\n/**\n * Hold a stream lease while mounted and `live` — the client refcounts\n * leases per handle, so however many providers and hooks mount one\n * session, the first connects and the last release closes\n * (StrictMode-safe: the lease is retaken on remount). A standalone\n * hook without a `hydrate` value passes `live: false` and holds the\n * stream — connecting an unhydrated session would replay the whole\n * log, and the library never picks the expensive path silently.\n */\nfunction useSessionLifecycle<D extends EventDefs, S, P extends PresenceDefs>(\n session: SessionClient<D, S, P>,\n live: boolean,\n): void {\n useEffect(() => {\n if (!live) return\n return session.connect()\n }, [session, live])\n}\n\nfunction useSessionResult<D extends EventDefs, S, P extends PresenceDefs>(\n session: SessionClient<D, S, P>,\n): UseSessionResult<D, S, P> {\n const snapshot: SessionSnapshot<D, S, P> = useSyncExternalStore(\n session.subscribe,\n session.getSnapshot,\n session.getSnapshot,\n )\n return useMemo(() => {\n // Runtime mirror of the WithPresence surface: the client attaches\n // setPresence (and the snapshot carries presence) exactly when the\n // reducer declares presence fields — no runtime-inert members.\n const { setPresence } = session as Partial<SessionClientPresence<P>>\n const { presence } = snapshot as Partial<{ presence: PresenceMap<P> }>\n return {\n state: snapshot.state,\n events: snapshot.events,\n index: snapshot.index,\n connection: snapshot.connection,\n push: session.push,\n loadHistory: session.loadHistory,\n history: snapshot.history,\n ...(setPresence && presence ? { presence, setPresence } : {}),\n } as UseSessionResult<D, S, P>\n }, [snapshot, session])\n}\n\n/**\n * The standalone, provider-less hook — for apps whose components reach\n * sessions ad hoc (a sidebar of channel sessions, a user session read\n * from a menu). Takes the shared client and a session id; the handle\n * is identity-mapped, so every hook and provider mounting the same\n * session shares one runtime and one leased stream. Hydration is\n * app-owned: pass `hydrate` when your fetch lands; until then the\n * stream is held, and your data layer's loading state is the pending\n * state.\n */\nexport function useSession<\n D extends EventDefs,\n S,\n P extends PresenceDefs = Record<never, never>,\n>(\n client: A2Client<D, S, P>,\n sessionId: string,\n options?: UseSessionOptions<S>,\n): UseSessionResult<D, S, P> {\n const { participant, hydrate } = options ?? {}\n const session = useMemo(\n () =>\n client.session(sessionId, {\n ...(hydrate === undefined\n ? {}\n : { initialState: hydrate.state, initialIndex: hydrate.index }),\n ...(participant === undefined ? {} : { participant }),\n }),\n [client, sessionId, hydrate, participant],\n )\n useSessionLifecycle(session, hydrate !== undefined)\n return useSessionResult(session)\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAiLA,SAAgB,YAKd,SAC0C;CAC1C,MAAM,UAAU,cAA6C,IAAI;CACjE,MAAM,iBAQJ,YAAY,WAEN,WACA,MACA,cACA,cACA,eACA,gBAEA,QAAQ,OAAO,QAAQ,WAAW;EAChC;EACA;EACA,GAAI,kBAAkB,KAAA,IAAY,CAAC,IAAI,EAAE,cAAc;EACvD,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;CACrD,CAAC,WACI;EACL,MAAM,0BAAU,IAAI,IAA+B;EACnD,QACE,WACA,KACA,cACA,cACA,eACA,gBACG;GACH,IAAI,QAAQ,KAAA,GACV,MAAM,IAAI,UACR,+DACF;GAEF,IAAI,SAAS,QAAQ,IAAI,GAAG;GAC5B,IAAI,CAAC,QAAQ;IACX,SAAS,aAAa;KAAE,SAAS,QAAQ;KAAS;IAAI,CAAC;IACvD,QAAQ,IAAI,KAAK,MAAM;GACzB;GACA,OAAO,OAAO,QAAQ,WAAW;IAC/B;IACA;IACA,GAAI,kBAAkB,KAAA,IAAY,CAAC,IAAI,EAAE,cAAc;IACvD,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;GACrD,CAAC;EACH;CACF,EAAA,CAAG;CAET,SAAS,gBACP,OACc;EACd,MAAM,EACJ,WACA,cACA,cACA,eACA,gBACE;EACJ,MAAM,MAAM,SAAS,QAAQ,MAAM,MAAM,KAAA;EACzC,MAAM,SAAS,cAEX,eACE,WACA,KACA,cACA,cACA,eACA,WACF,GACF;GAAC;GAAW;GAAK;GAAc;GAAc;GAAe;EAAW,CACzE;EACA,oBAAoB,QAAQ,IAAI;EAChC,OAAO,cAAc,QAAQ,UAAU,EAAE,OAAO,OAAO,GAAG,MAAM,QAAQ;CAC1E;CAEA,SAAS,kBAA6C;EACpD,MAAM,SAAS,WAAW,OAAO;EACjC,IAAI,CAAC,QACH,MAAM,IAAI,MACR,iEACF;EAEF,OAAO,iBAAiB,MAAM;CAChC;CAEA,OAAO;EAAE;EAAiB,YAAY;CAAgB;AACxD;;;;;;;;;;AAaA,SAAS,oBACP,SACA,MACM;CACN,gBAAgB;EACd,IAAI,CAAC,MAAM;EACX,OAAO,QAAQ,QAAQ;CACzB,GAAG,CAAC,SAAS,IAAI,CAAC;AACpB;AAEA,SAAS,iBACP,SAC2B;CAC3B,MAAM,WAAqC,qBACzC,QAAQ,WACR,QAAQ,aACR,QAAQ,WACV;CACA,OAAO,cAAc;EAInB,MAAM,EAAE,gBAAgB;EACxB,MAAM,EAAE,aAAa;EACrB,OAAO;GACL,OAAO,SAAS;GAChB,QAAQ,SAAS;GACjB,OAAO,SAAS;GAChB,YAAY,SAAS;GACrB,MAAM,QAAQ;GACd,aAAa,QAAQ;GACrB,SAAS,SAAS;GAClB,GAAI,eAAe,WAAW;IAAE;IAAU;GAAY,IAAI,CAAC;EAC7D;CACF,GAAG,CAAC,UAAU,OAAO,CAAC;AACxB;;;;;;;;;;;AAYA,SAAgB,WAKd,QACA,WACA,SAC2B;CAC3B,MAAM,EAAE,aAAa,YAAY,WAAW,CAAC;CAC7C,MAAM,UAAU,cAEZ,OAAO,QAAQ,WAAW;EACxB,GAAI,YAAY,KAAA,IACZ,CAAC,IACD;GAAE,cAAc,QAAQ;GAAO,cAAc,QAAQ;EAAM;EAC/D,GAAI,gBAAgB,KAAA,IAAY,CAAC,IAAI,EAAE,YAAY;CACrD,CAAC,GACH;EAAC;EAAQ;EAAW;EAAS;CAAW,CAC1C;CACA,oBAAoB,SAAS,YAAY,KAAA,CAAS;CAClD,OAAO,iBAAiB,OAAO;AACjC"}
@@ -218,7 +218,11 @@ That identity is an in-memory L1, not another source of truth. Repeated
218
218
  `session(id)` calls reuse it while active and for five idle minutes by
219
219
  default. A newer server fold advances it, pending pushes stay overlaid,
220
220
  and a stale server render cannot rewind it. The optional IndexedDB cache
221
- is the L2: it survives reloads; the memory runtime does not.
221
+ is the L2: it survives reloads; the memory runtime does not. The
222
+ stream is shared the same way: `connect()` takes a lease and returns
223
+ its release, so however many hooks, providers, or plain calls hold one
224
+ session open, the first lease connects and the last release closes
225
+ (`close()` is the hard stop).
222
226
 
223
227
  One detail worth knowing: every push carries a client-generated event id.
224
228
  That id is how the ack finds its optimistic entry, and it makes retrying
@@ -234,6 +238,99 @@ revisits paint from the local copy, the stream resumes from the cached
234
238
  frontier, and offline pushes queue and replay. See
235
239
  [Local-first](/guides/local-first).
236
240
 
241
+ ## Client-first sessions
242
+
243
+ Everything above assumes a server component hands the first fold down.
244
+ A client-rendered app often has nothing to hand: a sidebar showing ten
245
+ channel sessions, a user session read from deep inside a menu, panes
246
+ that mount from client-side navigation. No server fold, and no natural
247
+ provider boundary for components that reach sessions ad hoc.
248
+
249
+ For that shape, `experimental-a2/react` exports a standalone hook. Any
250
+ component can mount any session, no provider needed:
251
+
252
+ ```tsx app/orders/[orderId]/order-badge.tsx
253
+ 'use client'
254
+ import { useSession } from 'experimental-a2/react'
255
+ import { ordersClient } from './session'
256
+
257
+ export function OrderBadge({ orderId }: { orderId: string }) {
258
+ // your fetch (SWR, a router loader, a parent's payload): anything
259
+ // that lands the { state, index } your route returns (below), e.g.
260
+ // const { data } = useSWR(`/api/orders/${orderId}/state`)
261
+ const data = undefined // still loading
262
+
263
+ const { state } = useSession(ordersClient, orderId, { hydrate: data })
264
+ if (!data) return <span>…</span> // your loading state IS the pending state
265
+ return <span>{state.status}</span>
266
+ }
267
+ ```
268
+
269
+ The fold comes from your own route, and the route is one line of A2:
270
+
271
+ ```ts app/api/orders/[orderId]/state/route.ts
272
+ import { ordersServer } from '@/server/orders'
273
+ import { ordersReducer } from '@/reducer'
274
+
275
+ export async function GET(
276
+ _req: Request,
277
+ { params }: { params: Promise<{ orderId: string }> },
278
+ ) {
279
+ const { orderId } = await params
280
+ // here's where you'd do auth, or any other checks
281
+ return Response.json(
282
+ await ordersServer.session(orderId).state(ordersReducer),
283
+ )
284
+ }
285
+ ```
286
+
287
+ Note what is not on the wire: a reducer name. The route calls
288
+ `session.state(reducer)` with the reducer by reference, the same
289
+ module your client bundle folds with. That matters because renaming a
290
+ reducer is the [snapshot invalidation knob](/concepts/state): if the
291
+ name traveled on the wire, every rename would open a version-skew
292
+ window between deployed clients and servers. By reference, the knob
293
+ stays free. And unlike a history slice, which is immutable once read
294
+ and earns a standard wire shape, a state read is a moving snapshot;
295
+ there is nothing for the library to standardize, so the read stays
296
+ behind your route, under your auth and caching policy.
297
+
298
+ `hydrate` is one atomic option: the `{ state, index }` pair
299
+ `session.state(reducer)` returns. State and its frontier travel
300
+ together or not at all; a half-present handoff cannot compile. While
301
+ it is `undefined` (your fetch has not landed), the hook holds the
302
+ stream and `state` is the reducer's `initialState`. There is no
303
+ pending flag on the result: whether the fold has been handed over is
304
+ your own input, so your data layer's loading state is the pending
305
+ state. When the value arrives, the session hydrates
306
+ and the stream connects at that frontier. The hold is deliberate:
307
+ connecting without a fold means replaying the whole log through the
308
+ browser, and the library never picks the expensive path silently. When
309
+ a full replay is what you want (a short log, a debug view), hand the
310
+ fold's true starting point:
311
+ `hydrate: { state: ordersReducer.initialState, index: 0 }`. State at
312
+ index 0 is the reducer's seed by definition, so the explicit replay
313
+ needs no special vocabulary; the stream then delivers every event
314
+ live.
315
+
316
+ The rest behaves like the provider. The handle is identity-mapped
317
+ (every hook and provider mounting the same session shares one runtime)
318
+ and each mount holds a lease on the shared stream (see
319
+ [the client identity note](#the-client-component)). A later, further
320
+ fold handed to the same session
321
+ advances it and a stale one is ignored, the same never-move-backward
322
+ rule as everywhere else. For presence, identity comes from the
323
+ client's `participant` (set once on `createClient`) or the hook's
324
+ `participant` option, which overrides it. The
325
+ result is exactly the shape the bound hook returns. There is no
326
+ loading or error member: the
327
+ library performs no fetch here, so your data layer's states are the
328
+ states, retried however your data layer retries.
329
+
330
+ `SessionProvider` keeps its required `initialState`/`initialIndex`.
331
+ The provider is the server-handoff tool; the hook is the client-first
332
+ tool.
333
+
237
334
  ## Keep the backend out of the bundle
238
335
 
239
336
  The split is structural, not disciplinary. Core `experimental-a2` (the contract,
@@ -94,8 +94,11 @@ presence-only push acks `[]`.
94
94
 
95
95
  ## The browser
96
96
 
97
- The provider takes a `participant` id (yours to mint: a user id, a tab
98
- nonce, the playground's guest name). The hook grows two members. The
97
+ Presence needs a `participant` id (yours to mint: a user id, a tab
98
+ nonce, the playground's guest name). State it once on
99
+ `createClient({ participant })` when the app knows it at module scope,
100
+ or pass it to the provider, whose `participant` prop overrides the
101
+ client's. The hook grows two members. The
99
102
  session module is the usual pair from
100
103
  [Live UI](/guides/react#the-session-module), bound to a reducer that
101
104
  carries the canvas contract:
@@ -650,7 +650,7 @@ transition and the destination should adopt the same optimistic session.
650
650
  | `initialState` | server-rendered state |
651
651
  | `initialIndex` | the fold's frontier, where the stream resumes |
652
652
  | `initialEvents` | optional earlier raw events for a history UI |
653
- | `participant` | this client's presence identity; required to call `setPresence` |
653
+ | `participant` | overrides the client's `participant`; one of the two is required to call `setPresence` |
654
654
 
655
655
  Opens the stream on mount, closes it on unmount, reconnects with
656
656
  backoff from the current frontier. `participant` binds at the session's
@@ -706,8 +706,9 @@ invert). No ack, no `confirmed`, no retry.
706
706
  Both members exist only when the contract declares `presence`; their
707
707
  value and field types come from its schemas, through the reducer, with
708
708
  no type arguments (the reducer is the client's typed handle on the
709
- contract; it still never folds presence). `setPresence` requires the
710
- provider's `participant`. If the map holds only your own echo with
709
+ contract; it still never folds presence). `setPresence` requires a
710
+ `participant`: the client's default, or the provider's override. If
711
+ the map holds only your own echo with
711
712
  participants active, the GET route forgot `presence: true`. See
712
713
  [Presence](/guides/presence).
713
714
 
@@ -754,6 +755,37 @@ connection as dead (aborts it and reconnects), so `live` means bytes
754
755
  are actually flowing, not "the socket hasn't errored yet". See
755
756
  [Live UI](/guides/react).
756
757
 
758
+ ### `useSession(client, sessionId, options?)`
759
+
760
+ ```ts
761
+ useSession(client: A2Client, sessionId: string, options?: {
762
+ participant?: string // overrides the client's participant
763
+ hydrate?: { state: S; index: number }
764
+ }): UseSessionResult
765
+ ```
766
+
767
+ The standalone, provider-less hook. It returns the same result shape
768
+ as the bound hook, for apps whose components reach sessions ad hoc (a
769
+ sidebar of channel sessions, a user session read from a menu). The
770
+ handle is identity-mapped: every hook and provider mounting the same
771
+ session shares one runtime. The stream is refcounted: the first mount
772
+ connects, the last unmount closes (StrictMode-safe).
773
+
774
+ `hydrate` is the hydration input, one atomic option: the
775
+ `{ state, index }` pair `session.state(reducer)` returns, fetched by
776
+ your app through its own route and handed over whenever it lands.
777
+ While it is `undefined`, the hook holds the stream (connecting without
778
+ a fold would replay the whole log). There is no pending flag on the
779
+ result: whether the fold has been handed over is your own input, so
780
+ your data layer's loading state is the pending state.
781
+ When it arrives, the session hydrates under the usual
782
+ never-move-backward rule and the stream connects at that frontier.
783
+ For a deliberate full replay, hand the fold's true starting point:
784
+ `{ state: reducer.initialState, index: 0 }`. State at index 0 is the
785
+ reducer's seed by definition, so the explicit replay needs no special
786
+ vocabulary. See
787
+ [Client-first sessions](/guides/react#client-first-sessions).
788
+
757
789
  ## `experimental-a2/ai`
758
790
 
759
791
  ### `agent(options)`
@@ -1035,6 +1067,7 @@ createClient(options: {
1035
1067
  reducer: Reducer
1036
1068
  api: ClientApi
1037
1069
  gcTime?: number // idle session lifetime; 5 minutes by default
1070
+ participant?: string // default presence identity for every session
1038
1071
  }): A2Client
1039
1072
 
1040
1073
  type ClientApi =
@@ -1049,11 +1082,19 @@ queue with ack/rollback, and the local fold. `client.session(id, {
1049
1082
  initialState?, initialIndex?, initialEvents?, participant? })` returns a
1050
1083
  handle with `getSnapshot()`/`subscribe()` (the `useSyncExternalStore`
1051
1084
  contract), `push()`, `loadHistory()`, `connect()`, and `close()`.
1085
+ `connect()` takes a lease on the live stream and returns its release:
1086
+ leases refcount per handle (the first connects, releasing the last
1087
+ closes, releasing twice is a no-op), so independent consumers of one
1088
+ identity-mapped handle never fight over the stream. `close()` is the
1089
+ hard stop: it drops every outstanding lease and closes now; a later
1090
+ `connect()` starts fresh.
1052
1091
  Snapshots carry `state`, `events`, `index`, `history`, and `connection`
1053
1092
  (the same fields `useSession` exposes), and `push` returns the same
1054
1093
  ack-then-`confirmed` result. On contracts that declare `presence` the handle also carries
1055
1094
  `setPresence()` and snapshots carry the `presence` map, exactly like
1056
- the hook; `participant` is the identity `setPresence` sends under. Use
1095
+ the hook; `participant` is the identity `setPresence` sends under:
1096
+ stated once on `createClient` as the default for every handle, or per
1097
+ session as the override. Use
1057
1098
  it directly from any other framework, or none.
1058
1099
 
1059
1100
  Within one `A2Client`, repeated `session(id)` calls return the same live
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Durable sync and reactions for things with a lifecycle: one event log, derived state, and live client per session.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/client.ts CHANGED
@@ -177,13 +177,19 @@ export type SessionClient<
177
177
  * this throws a TypeError there.
178
178
  */
179
179
  loadHistory(options?: LoadHistoryOptions): Promise<ContractEvent<D>[]>
180
- /** Open the live stream (idempotent while open). Reconnects with
181
- * backoff and resumes from the frontier until `close()`. */
182
- connect(): void
183
180
  /**
184
- * Stop the live stream. Not terminal: `connect()` starts it again
185
- * from the current frontier which is what makes the React
186
- * StrictMode mount dance (setup cleanup setup) work.
181
+ * Take a lease on the live stream and return its release. Leases
182
+ * refcount per handlethe first connects, releasing the last
183
+ * closes so independent consumers of one identity-mapped handle
184
+ * (two hooks, a hook plus vanilla code) never fight over the
185
+ * stream. Releasing twice is a no-op. While any lease is held the
186
+ * stream reconnects with backoff and resumes from the frontier.
187
+ */
188
+ connect(): () => void
189
+ /**
190
+ * The hard stop: drops every outstanding lease and closes the
191
+ * stream now. Not terminal — a later `connect()` starts fresh from
192
+ * the current frontier.
187
193
  */
188
194
  close(): void
189
195
  }
@@ -193,11 +199,8 @@ export type SessionOptions<D extends EventDefs, S> = {
193
199
  initialIndex?: number
194
200
  /** Server-rendered history through `initialIndex`. Seeds the event feed. */
195
201
  initialEvents?: ContractEvent<D>[]
196
- /**
197
- * This client's presence identity — required to call `setPresence`.
198
- * Caller-supplied (a user id, a tab nonce, a guest name): A2 does
199
- * not invent an identity story. Bound once per session handle.
200
- */
202
+ /** Overrides the client's `participant` for this session handle.
203
+ * Bound once per handle. */
201
204
  participant?: string
202
205
  }
203
206
 
@@ -263,6 +266,14 @@ export type CreateClientOptions<
263
266
  /** How long an idle session keeps its in-memory identity, in
264
267
  * milliseconds. Defaults to five minutes; `Infinity` disables GC. */
265
268
  gcTime?: number
269
+ /**
270
+ * This client's presence identity — required to call `setPresence`.
271
+ * Caller-supplied (a user id, a tab nonce, a guest name): A2 does
272
+ * not invent an identity story; the app states it once here. The
273
+ * default for every session handle this client creates;
274
+ * `session(id, { participant })` overrides it per handle.
275
+ */
276
+ participant?: string
266
277
  }
267
278
 
268
279
  const PUSH_ATTEMPTS = 3
@@ -1022,7 +1033,7 @@ export function createClient<
1022
1033
  // stream; the own entry is the local echo — server copies of self
1023
1034
  // are ignored (the echo is at least as new, and comparing it to
1024
1035
  // server stamps would put two clocks in one order).
1025
- let participant = sessionOptions?.participant
1036
+ let participant = sessionOptions?.participant ?? options.participant
1026
1037
  // Participant and field keys arrive off the wire, so the map and
1027
1038
  // every entry are built null-prototype — see `nullProtoRecord`.
1028
1039
  let presenceState: PresenceMap = nullProtoRecord()
@@ -1292,7 +1303,7 @@ export function createClient<
1292
1303
  const setPresence = (values: PresencePatch<P>['values']): void => {
1293
1304
  if (participant === undefined) {
1294
1305
  throw new TypeError(
1295
- 'setPresence requires a participant — pass one in the session options (the SessionProvider participant prop)',
1306
+ 'setPresence requires a participant — pass one to createClient({ participant }) or in the session options (the SessionProvider/useSession participant prop)',
1296
1307
  )
1297
1308
  }
1298
1309
  const validated: Record<string, unknown> = nullProtoRecord()
@@ -1600,11 +1611,34 @@ export function createClient<
1600
1611
 
1601
1612
  // ── the live stream ──────────────────────────────────────────
1602
1613
  // A run-generation model instead of a terminal `closed` flag:
1603
- // close() bumps the generation (the running loop notices and
1614
+ // stopping bumps the generation (the running loop notices and
1604
1615
  // exits), connect() starts a fresh one. Reentrant by design.
1616
+ // Consumers hold leases: connect() counts one and returns its
1617
+ // release, the last release stops the stream, and close() is the
1618
+ // hard stop — it drops every lease and stops now (bumping
1619
+ // `leaseEpoch` makes the outstanding releases inert).
1605
1620
  let generation = 0
1606
1621
  let active = false
1607
1622
  let abort: AbortController | null = null
1623
+ let leases = 0
1624
+ let leaseEpoch = 0
1625
+
1626
+ const stopStream = (): void => {
1627
+ touch(sessionId)
1628
+ if (!active) return
1629
+ active = false
1630
+ generation += 1 // the running loop notices and exits
1631
+ abort?.abort()
1632
+ abort = null
1633
+ // A pending trailing send dies with the stream — losing one is
1634
+ // fine by definition; sets made while closed accumulate in
1635
+ // `presenceResend` and repaint after the next connect.
1636
+ clearTimeout(presenceTimer)
1637
+ presenceTimer = undefined
1638
+ presenceBuffer = null
1639
+ status = 'closed'
1640
+ notify()
1641
+ }
1608
1642
 
1609
1643
  const runStream = async (run: number): Promise<void> => {
1610
1644
  let backoff = STREAM_TIMINGS.reconnectBaseMs
@@ -1727,27 +1761,29 @@ export function createClient<
1727
1761
  loadHistory,
1728
1762
  connect() {
1729
1763
  touch(sessionId)
1730
- if (active) return
1731
- active = true
1732
- status = 'connecting'
1733
- notify()
1734
- void runStream(generation)
1764
+ leases += 1
1765
+ const epoch = leaseEpoch
1766
+ if (!active) {
1767
+ active = true
1768
+ status = 'connecting'
1769
+ notify()
1770
+ void runStream(generation)
1771
+ }
1772
+ let released = false
1773
+ return () => {
1774
+ // Idempotent, and inert after a hard close() — a stale
1775
+ // release must not touch leases taken since.
1776
+ if (released || leaseEpoch !== epoch) return
1777
+ released = true
1778
+ touch(sessionId)
1779
+ leases -= 1
1780
+ if (leases === 0) stopStream()
1781
+ }
1735
1782
  },
1736
1783
  close() {
1737
- touch(sessionId)
1738
- if (!active) return
1739
- active = false
1740
- generation += 1 // the running loop notices and exits
1741
- abort?.abort()
1742
- abort = null
1743
- // A pending trailing send dies with the stream — losing one is
1744
- // fine by definition; sets made while closed accumulate in
1745
- // `presenceResend` and repaint after the next connect.
1746
- clearTimeout(presenceTimer)
1747
- presenceTimer = undefined
1748
- presenceBuffer = null
1749
- status = 'closed'
1750
- notify()
1784
+ leases = 0
1785
+ leaseEpoch += 1
1786
+ stopStream()
1751
1787
  },
1752
1788
  hydrate(next) {
1753
1789
  touch(sessionId)