diff --git a/CHANGELOG.md b/CHANGELOG.md index b3d945001..8027e3cac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major ## [Unreleased] +### Added + +- **`useRecordChanges(types, listener)` — hear THAT records moved, holding none of them.** The only + way to react to a channel's `records` was a whole-type `useRecords(type, {})`, which holds the + type: none of its rows is evicted while the reader lives, so a header that re-renders a server + page on any change kept every record of every watched type for the tab's life. The listener gets + one call per store update, the moved keys grouped by type; it holds no row and no type, and it is + follow-only (`FOLLOW_ONLY_EXPORTS`), so its island is never held behind the restore. New types + `RecordChanges`, `RecordChangesHandle`. +- **`useChannel(...).error()`** — the node's refusal of a `failed` membership (`X_TOPIC_FORBIDDEN` + and the rest), read reactively. A page could see `failed` but not why, so a signed-out session + looked like any other failure. + ### Fixed - **The page socket's redial curve resets on the node's first frame, not on the open.** A proxy or diff --git a/packages/cli/src/island-realtime-state.ts b/packages/cli/src/island-realtime-state.ts index 01ecebfb7..eb968ada0 100644 --- a/packages/cli/src/island-realtime-state.ts +++ b/packages/cli/src/island-realtime-state.ts @@ -18,6 +18,8 @@ export const FOLLOW_ONLY_EXPORTS: readonly string[] = [ 'usePresence', 'useConnection', 'hasPageSocket', + // Hears THAT records moved and renders none of them, so a restored store contradicts nothing. + 'useRecordChanges', 'readPresence', 'channel', 'channelRef', diff --git a/packages/realtime/README.md b/packages/realtime/README.md index 498cf7694..7f1a6f7ee 100644 --- a/packages/realtime/README.md +++ b/packages/realtime/README.md @@ -84,7 +84,7 @@ class — stay on `.`. | wire | `.` | `PROTOCOL_VERSION` (3), `encode`, `decode`, `Frame` | | the node | `./server` | `syncNode` / `listenSyncNode` (`sync` role) | | a socket's identity | `./server` | `SyncAuthenticator`, `SyncGrant`, `GrantBook`, `sweepGrants`, `DEFAULT_REAUTH_INTERVAL_MS` | -| hooks | `.` | `useQuery`, `useRecord`, `useRecords` (by key, or a whole type: `RecordSelection`), `useMutation`, `useMutationQueue`, `useOutbox`, `useConnection`, `useChannel`, `usePresence`, `hasPageSocket`, `installRealtime` | +| hooks | `.` | `useQuery`, `useRecord`, `useRecords` (by key, or a whole type: `RecordSelection`), `useRecordChanges`, `useMutation`, `useMutationQueue`, `useOutbox`, `useConnection`, `useChannel`, `usePresence`, `hasPageSocket`, `installRealtime` | | channel records, no replicator | `./server` | `recordPublisher` — an app's committed rows as channel `records`, sequenced per producer ([below](#channel-records-with-no-replicator)) | | channels | `.` | `channel`, `channelRef`, `ChannelHandle`, `topic`, `readPresence`, the channel frame types | | first paint | `.` | `holdFirstPaint`, `FIRST_PAINT_HOLD_MS` (1 s) — what the island bootstrap awaits before `mount`, for an island that reads the store or the outbox: the boot's restore and the open outbox, capped (#506). An island taking only `useChannel` / `usePresence` / `useConnection` never awaits it and is never hidden | @@ -279,6 +279,7 @@ same type `@ultimat3/ui` renders. | A query ref is `{ name, live?, entity? }`, never the query VALUE | importing a `query()` drags its read path into the island (698,801 B measured) | | Lists hold keys; rows are the store's | a record updated by any answer or frame re-renders every list showing it, with no refetch | | A list that must GROW with the store is `useRecords(type, { where?, order? })` | a `useQuery` list holds its own answer's keys and `useRecords(type, keys)` the keys it was given, so a record a channel's `records` frame adopts reaches neither. The whole-type form is the channel-records counterpart of a live window: `ready` from its first read, every record of the type the store holds (optimistic ones included), default order by key, and none evicted while it is held — a row it alone kept goes when the last reader of the type lets go (#680) | +| Knowing THAT records moved is `useRecordChanges(types, listener)` | one call per store update, the moved keys per type, and no hold on any row or type, so a page that re-renders on a channel's `records` pins nothing. `useChannel(...).error()` is the node's refusal of a `failed` membership | | Every write is HTTP through core's `clientTransport` | one write path: the action's authz, idempotency and contract; the socket carries none | | The answer's records are adopted before the overlay goes | a convergent twin never flickers back to the pre-write value | | **No `solid-js` import.** Each bundle installs its own `SignalFactory` | every island carries its own solid-js; a signal from another bundle is invisible to its effects | diff --git a/packages/realtime/src/index.ts b/packages/realtime/src/index.ts index 68901f902..4fc3030f3 100644 --- a/packages/realtime/src/index.ts +++ b/packages/realtime/src/index.ts @@ -202,3 +202,8 @@ export { useRecord, useRecords, } from './use-record'; +export { + type RecordChanges, + type RecordChangesHandle, + useRecordChanges, +} from './use-record-changes'; diff --git a/packages/realtime/src/use-channel.test.ts b/packages/realtime/src/use-channel.test.ts index 8923d240b..daed38bd2 100644 --- a/packages/realtime/src/use-channel.test.ts +++ b/packages/realtime/src/use-channel.test.ts @@ -99,11 +99,17 @@ describe('useChannel — membership', () => { socket.deliver({ ...records(1, 1), channel: 'org-feed.o2' }); expect(denied()).toBe('failed'); expect(other()).toBe('live'); + // The refusal itself, so a page can tell a signed-out session from any other failure. + expect(denied.error()).toMatchObject({ code: 'X_TOPIC_FORBIDDEN' }); + expect(other.error()).toBeUndefined(); }); test('on a server render it opens nothing', () => { resetPage(); - expect(useChannel(orgFeed, { orgId: 'o1' })()).toBe('joining'); + const ssr = useChannel(orgFeed, { orgId: 'o1' }); + expect(ssr()).toBe('joining'); + expect(ssr.error()).toBeUndefined(); + ssr.release(); }); test('usePresence on a server render is an empty roster and opens nothing', () => { diff --git a/packages/realtime/src/use-channel.ts b/packages/realtime/src/use-channel.ts index f1afe3115..5cd18a298 100644 --- a/packages/realtime/src/use-channel.ts +++ b/packages/realtime/src/use-channel.ts @@ -8,8 +8,10 @@ import { installedPage } from './page-store'; import { isServerRender, signalFor } from './reactivity'; import type { PresenceMember } from './sync-protocol'; -/** The membership's state, callable, plus its release (Solid: `onCleanup`). */ +/** The membership's state, callable, plus its refusal and its release (Solid: `onCleanup`). */ export type ChannelAccessor = (() => ChannelState) & { + /** What the node answered when it refused the membership (`failed`); `undefined` otherwise. */ + error(): unknown; release(): void; [Symbol.dispose](): void; }; @@ -29,6 +31,7 @@ export function useChannel( const signal = signalFor('useChannel'); if (isServerRender()) { return Object.assign((): ChannelState => 'joining', { + error: (): unknown => undefined, release: nothing, [Symbol.dispose]: nothing, }); @@ -42,11 +45,16 @@ export function useChannel( version(); return membership.state(); }; + // Read through the same version, so a component rendering the refusal re-renders with it. + const error = (): unknown => { + version(); + return membership.error(); + }; const release = (): void => { off(); membership.release(); }; - return Object.assign(read, { release, [Symbol.dispose]: release }); + return Object.assign(read, { error, release, [Symbol.dispose]: release }); } /** Who is in a channel's room, as the node's presence set says — one entry per member id. */ diff --git a/packages/realtime/src/use-record-changes.test.ts b/packages/realtime/src/use-record-changes.test.ts new file mode 100644 index 000000000..b50e81daf --- /dev/null +++ b/packages/realtime/src/use-record-changes.test.ts @@ -0,0 +1,67 @@ +// `useRecordChanges`: told what moved, per type, and holding nothing — so a row no other reader +// holds is still evicted, a type nobody else holds is not pinned, and a server render is inert. + +import { afterEach, describe, expect, test } from 'bun:test'; +import { pageHarness, resetPage } from './hooks-fixture'; +import { useRecord } from './use-record'; +import { type RecordChanges, useRecordChanges } from './use-record-changes'; + +afterEach(() => { + resetPage(); +}); + +const plain = (changes: RecordChanges): Record => + Object.fromEntries(changes); + +describe('useRecordChanges', () => { + test('one call per store update, grouped by type, only for the watched types', () => { + const { page } = pageHarness(); + const seen: Record[] = []; + useRecordChanges(['posts', 'runs'], (changes) => seen.push(plain(changes))); + + page.store.batch(() => { + page.store.adopt('posts', { p1: { id: 'p1' }, p2: { id: 'p2' } }); + page.store.adopt('runs', { r1: { id: 'r1' } }); + page.store.adopt('users', { u1: { id: 'u1' } }); + }); + page.store.adopt('users', { u2: { id: 'u2' } }); + + expect(seen).toEqual([{ posts: ['p1', 'p2'], runs: ['r1'] }]); + }); + + test('a removal is a change too', () => { + const { page } = pageHarness(); + page.store.adopt('posts', { p1: { id: 'p1' } }); + const seen: Record[] = []; + useRecordChanges(['posts'], (changes) => seen.push(plain(changes))); + page.store.remove('posts', ['p1']); + expect(seen).toEqual([{ posts: ['p1'] }]); + }); + + test('holds nothing: the last real holder leaving still evicts the row', () => { + const { page } = pageHarness(); + useRecordChanges(['posts'], () => undefined); + const post = useRecord('posts', 'p1'); + page.store.adopt('posts', { p1: { id: 'p1' } }); + post.release(); + expect(page.store.peek('posts', 'p1')).toBeUndefined(); + }); + + test('on a server render it subscribes to nothing, and its release is a no-op', () => { + resetPage(); + const handle = useRecordChanges(['posts'], () => undefined); + handle(); + handle.release(); + handle[Symbol.dispose](); + }); + + test('released, it hears nothing more; releasing twice is a no-op', () => { + const { page } = pageHarness(); + const seen: unknown[] = []; + const handle = useRecordChanges(['posts'], (changes) => seen.push(changes)); + handle.release(); + handle(); + page.store.adopt('posts', { p1: { id: 'p1' } }); + expect(seen).toEqual([]); + }); +}); diff --git a/packages/realtime/src/use-record-changes.ts b/packages/realtime/src/use-record-changes.ts new file mode 100644 index 000000000..8760edf7c --- /dev/null +++ b/packages/realtime/src/use-record-changes.ts @@ -0,0 +1,47 @@ +// Which records of some types moved, without holding any of them. A whole-type `useRecords` is the +// way to SHOW every record of a type, and holding the type is its price: none of its rows is +// evicted while the reader lives. A reader that only wants to know THAT something moved — re-render +// a server page, flash a counter — pays nothing here: it holds no row and no type. + +import { pageStore } from './page-store'; +import { isServerRender } from './reactivity'; + +/** Per type, the keys of its records that one store update moved (written, removed or evicted). */ +export type RecordChanges = ReadonlyMap; + +/** The listener's undo — call it, `release()` it or `using` it (Solid: `onCleanup`). */ +export type RecordChangesHandle = (() => void) & { release(): void; [Symbol.dispose](): void }; + +const nothing = (): void => undefined; + +/** + * Calls `listener` once per store update that moved at least one record of `types`, with what + * moved, grouped by type. Holds nothing: a record no other reader holds is evicted as before. + */ +export function useRecordChanges( + types: readonly string[], + listener: (changed: RecordChanges) => void, +): RecordChangesHandle { + if (isServerRender()) + return Object.assign(nothing, { release: nothing, [Symbol.dispose]: nothing }); + const watched = new Set(types); + const unsubscribe = pageStore('useRecordChanges').subscribe((changed) => { + const moved = new Map(); + for (const rk of changed) { + const at = rk.indexOf(':'); + const type = rk.slice(0, at); + if (!watched.has(type)) continue; + const keys = moved.get(type) ?? []; + keys.push(rk.slice(at + 1)); + moved.set(type, keys); + } + if (moved.size > 0) listener(moved); + }); + let held = true; + const release = (): void => { + if (!held) return; + held = false; + unsubscribe(); + }; + return Object.assign(release, { release, [Symbol.dispose]: release }); +} diff --git a/wiki/Realtime.md b/wiki/Realtime.md index 77a3ce3db..437de1fe8 100644 --- a/wiki/Realtime.md +++ b/wiki/Realtime.md @@ -190,7 +190,7 @@ kept) until it mounts over the restored store. | Island | Held | `mount` waits for the restore | |---|---|---| | takes `useQuery`, `useRecord`, `useRecords`, `useMutation`, `useMutationQueue`, `useOutbox`, or any other name not listed below, anywhere in its own graph | yes — until it mounts, 3 s at most | yes, 1 s at most (`FIRST_PAINT_HOLD_MS`) | -| takes only `useChannel`, `usePresence`, `useConnection`, `hasPageSocket`, `readPresence`, `channel`, `channelRef`, `topic` | **no** — its server markup is on screen from the first byte | no — it mounts as soon as the page runtime is there | +| takes only `useChannel`, `usePresence`, `useConnection`, `hasPageSocket`, `useRecordChanges`, `readPresence`, `channel`, `channelRef`, `topic` | **no** — its server markup is on screen from the first byte | no — it mounts as soon as the page runtime is there | | takes the barrel with no list of names (`import * as`, `export *`, `import()`) | yes | yes | | reaches no realtime | no | no | @@ -273,6 +273,7 @@ however many lists, islands and paths hold it. It shipped in 21.0.0 | A row that is not an object, or has no key, is dropped and reported (`X_RECORD_REJECTED`) | a keyless row would overwrite another record | | A record lives exactly as long as something holds it | the last release drops it, so an infinite scroll is not a leak | | A list that must grow with the store is `useRecords(type, { where?, order? })` | a `useQuery` list holds its own answer's keys and `useRecords(type, keys)` the keys it was given, so a record a channel's `records` frame adopts reaches neither. The whole-type form shows every record of the type the store holds — `ready` from its first read, ordered by key unless `order` says otherwise, none evicted while it is held. The channel-records counterpart of a live window (25.0.0, #680) | +| A reader that only needs to know THAT records moved is `useRecordChanges(types, listener)` | it is called once per store update with the moved keys per type and holds no row and no type, so it pins nothing: re-rendering a server page on a channel's `records` costs no memory. Follow-only, so its island is never held | **The key comes from the server, on every path.** A browser cannot compute a key without importing the app's `entity()` declarations, so it never does.