Skip to content

feat: ref.live — standard signals as the read-side adapter - #25

Merged
cardmagic merged 6 commits into
mainfrom
feat/live-signals
Aug 24, 2026
Merged

feat: ref.live — standard signals as the read-side adapter#25
cardmagic merged 6 commits into
mainfrom
feat/live-signals

Conversation

@cardmagic

@cardmagic cardmagic commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Implements #24.

The shape

One side-effect import enables reference.live, completing one property, three tenses:

import "solid-objects/signals"

const counter = Counter.ref("page-hits")

snapshot.count            // past: one committed read
await counter.count       // present: ask the actor now
counter.live.count        // continuous: a read-only standard signal
counter.live.snapshot     // the authorized snapshot, kept current

From Lit: watch(counter.live.count) with the SignalWatcher mixin, no glue. Any consumer of the TC39 signals API composes the same way.

Design

  • Optional-peer discipline. The signals entry installs a factory into the reference core; signal-polyfill (new optional peer) never loads unless solid-objects/signals is imported, and reference.live throws a pointer to that import otherwise — the same seam pattern as the platform registration.
  • Lifecycle from watched/unwatched. The first watcher opens an in-process runtime.realtime session (subscribe replays committed observables immediately); the last watcher's departure closes it after a configurable linger (configureLiveSignals). Entries dedupe per runtime and actor identity.
  • Read-only by construction. Exposed signals are Signal.Computed mirrors; the package types use a structural LiveSignal { get() } so the type surface never requires the optional peer.
  • The privacy model maps. Value-broadcast observables feed named signals from envelopes; invalidation-only names stay undefined by design, and live.snapshot re-fetches the authorized snapshot (coalesced) on each accepted envelope. Personalized payload projections arrive as live.payloads.<name> signals with their independent per-name revision fences; a newly watched payload name re-sends the subscription with the grown name list.
  • Revision fence. Envelopes apply only on a monotonic revision advance for the same instance — the browser client's fence, one layer lower.

Tests

  • 10 Vitest tests: replay into a fresh watcher, follow-on commits through the broadcast outbox, invalidation-only snapshot refresh, stale-revision rejection (using snapshotWithIncarnation for the real instance id), subscribe-on-watch and release-after-linger with a subscription-count leak check, proxy and signal identity stability with read-only enforcement, the helpful error when the entry is not imported, subscription retry after denials, canonical-entry behavior across references, and payload delivery with mid-session subscription growth.
  • 1 new Playwright test (9 total pass): a WASM-runtime actor in a Chromium module worker increments while a standard signal watcher observes the committed values.
  • Full suite: 334 unit tests pass; check (including the browser-import graph, which now includes src/signals.ts) passes.

Docs

docs/api.md gains the solid-objects/signals section (shape, behavior, the reserved snapshot name, the authorization note) and a reference.live bullet; docs/parity.md records the capability as JavaScript-only with the Turbo/Action Cable correspondence — Rails already fills this slot natively, which is the parity ledger's own definition working as intended; CHANGELOG.md starts the next Unreleased section.

Deviation from the RFP, recorded honestly

v1 rides runtime-backed references (Node and the browser host) rather than the thin-page realtime client: references carry their runtime, and the in-process realtime session already delivers envelopes on both platforms, so no ambient connection registry was needed. Follow-ups tracked in #24: the thin-page client variant, the optimistic layer, and the shuffleupandplay Lit demo. Payload signals landed in this PR after review.

Implements #24. One side-effect import of solid-objects/signals
enables reference.live: read-only signals on the proposed standard
JavaScript signals API, completing the one-property-three-tenses
shape (snapshot.count, await counter.count, counter.live.count).

- The signals entry installs a factory into the reference core, so
  signal-polyfill stays an optional peer that never loads unless the
  entry is imported; reference.live throws a pointer otherwise.
- Signals are Signal.Computed mirrors over internal state, read-only
  by construction. watched/unwatched callbacks drive the lifecycle:
  the first watcher opens an in-process runtime.realtime session
  (subscribe replays committed observables immediately), and the
  last watcher's departure closes it after a configurable linger.
- Value-broadcast observables feed named signals from envelopes;
  invalidation-only names stay undefined by design, and
  live.snapshot re-fetches the authorized snapshot, coalesced, on
  each accepted envelope. Envelopes apply only on a monotonic
  revision advance for the same instance, the browser client's
  fence.
- Entries dedupe per runtime and actor identity, so two refs to one
  actor share one session.

Vitest covers replay, follow-on commits through the broadcast
outbox, invalidation-only snapshot refresh, the revision fence, the
watch/linger lifecycle with a subscription-count leak check, proxy
stability, read-only enforcement, and the helpful error without the
entry. Playwright proves the stack in Chromium: a WASM-runtime actor
increments and a standard signal watcher observes the committed
values inside the browser worker.

Deviation from the RFP recorded honestly: v1 rides runtime-backed
references (Node and the browser host), not the thin-page realtime
client, because refs carry their runtime and the in-process realtime
session already delivers envelopes on both platforms. The thin-page
variant and the shuffleupandplay Lit demo are follow-ups.
Counter.ref("page-hits") is the canonical form with configure();
runtime.ref is the isolated-runtime variant.
@greptile-apps

greptile-apps Bot commented Aug 23, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds an optional standard-signals adapter for actor references, backed by runtime realtime subscriptions.

  • Adds stable read-only signals for observable values, authorized snapshots, and personalized payloads.
  • Adds watcher-driven subscription lifecycle, retry handling, weak canonical-entry caching, and revision fences.
  • Adds package exports, documentation, unit coverage, and a browser-worker integration test.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
src/signals.ts Implements live signal caching, watcher lifecycle, retry recovery, payload handling, and revision-fenced updates.
src/reference.ts Adds the typed reference.live surface and optional side-effect-installed factory seam.
test/live-signals.test.ts Covers replay, updates, snapshot refresh, fencing, lifecycle, retries, canonical identity, payloads, and read-only behavior.
test/browser/live-signals-worker.mjs Exercises live signals against a browser WASM runtime actor in a module worker.
package.json Exports the signals entry point and declares signal-polyfill as an optional peer.

Reviews (4): Last reviewed commit: "feat: payload signals on live references" | Re-trigger Greptile

Greptile round one found two lifecycle gaps:

- A denied or transiently failed subscribe left a truthy but
  unsubscribed session installed, so watched signals could never
  recover. The failure path now tears the session down and, while
  watchers remain, retries after a configurable retryMilliseconds
  (default one second). A test denies the first two subscriptions and
  proves the third succeeds and values flow.
- The per-runtime entry cache retained signal state for every actor
  identity ever watched. An entry now registers itself on open and
  evicts itself on close, so an abandoned actor holds no cached
  state; a rewatched signal reopens and re-registers through the
  entry it still holds. liveEntryCount(runtime) exposes the cache
  size, and a test proves 1 while watched, 0 after the linger, and 1
  again on rewatch.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptile-apps review

Both findings addressed in the latest commit: a failed subscribe now tears down its session and retries on retryMilliseconds while watchers remain (test: two denials, then recovery), and entries evict from the runtime cache on close with liveEntryCount(runtime) as the diagnostic (test: 1 watched, 0 after linger, 1 on rewatch).

Greptile round two found the race the previous eviction created: a
retained proxy could resurrect an evicted entry while a new reference
had already created a replacement, leaving two independently active
entries and subscriptions for one actor.

The registry now holds entries through WeakRef with a
FinalizationRegistry sweep. One entry stays canonical per actor for
as long as any proxy or signal for it is reachable, so competing
entries cannot exist by construction, and an entry whose consumers
are all garbage-collected leaves the cache with them. Eviction is no
longer tied to the linger; the linger only closes the session.

The regression test encodes the reported scenario: watch through one
reference, linger past close, watch through a second reference, then
rewatch the first — one subscription, one entry, identical signal
objects from both references, and one increment observed by both.
This commit also removes a stray NUL byte that had been hiding in
the entry key template and defeating text searches of the file.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptile-apps review

Round-two finding addressed: the registry now holds entries by WeakRef with a FinalizationRegistry sweep, so one entry stays canonical per actor for as long as any proxy or signal is reachable — competing entries cannot exist by construction — and fully abandoned entries leave the cache via GC. The regression test encodes the exact reported scenario (retained proxy + replacement reference) and asserts one subscription, one entry, and identical signal objects from both references.

The session machinery already delivered payload envelopes; the live
adapter dropped them. live.payloads.<name> now carries personalized
payload projections: a newly watched payload name re-sends the
subscription with the grown name list, each payload keeps the
independent per-name revision fence the wire protocol defines, and
payloads evaluate under the live session's authorization context.
payloads joins snapshot as a reserved name on live.

The test watches one payload, sees it flow, then watches a second
payload mid-session and proves the re-subscription delivers both
with per-name fencing and stable signal identity. This removes the
payload-signals item from the deferred list in #24; the thin-page
client, the optimistic layer, and the Lit demo remain follow-ups on
their own merits.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptile-apps review

@cardmagic cardmagic self-assigned this Aug 24, 2026
package.json and src/version.ts advance together, and the Unreleased
notes become the dated 0.14.1 section the publish job reads.
@cardmagic
cardmagic merged commit 5a717e4 into main Aug 24, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant