Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/island-realtime-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion packages/realtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down
5 changes: 5 additions & 0 deletions packages/realtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,8 @@ export {
useRecord,
useRecords,
} from './use-record';
export {
type RecordChanges,
type RecordChangesHandle,
useRecordChanges,
} from './use-record-changes';
8 changes: 7 additions & 1 deletion packages/realtime/src/use-channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
12 changes: 10 additions & 2 deletions packages/realtime/src/use-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -29,6 +31,7 @@ export function useChannel<K extends string>(
const signal = signalFor('useChannel');
if (isServerRender()) {
return Object.assign((): ChannelState => 'joining', {
error: (): unknown => undefined,
release: nothing,
[Symbol.dispose]: nothing,
});
Expand All @@ -42,11 +45,16 @@ export function useChannel<K extends string>(
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. */
Expand Down
67 changes: 67 additions & 0 deletions packages/realtime/src/use-record-changes.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, readonly string[]> =>
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<string, readonly string[]>[] = [];
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<string, readonly string[]>[] = [];
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([]);
});
});
47 changes: 47 additions & 0 deletions packages/realtime/src/use-record-changes.ts
Original file line number Diff line number Diff line change
@@ -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<string, readonly string[]>;

/** 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<string, string[]>();
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 });
}
3 changes: 2 additions & 1 deletion wiki/Realtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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.
Expand Down
Loading