feat: ref.live — standard signals as the read-side adapter - #25
Conversation
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 SummaryThe PR adds an optional standard-signals adapter for actor references, backed by runtime realtime subscriptions.
Confidence Score: 5/5The PR appears safe to merge because no blocking failure remains. No blocking failure remains. Important Files Changed
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.
|
@greptile-apps review Both findings addressed in the latest commit: a failed subscribe now tears down its session and retries on |
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.
|
@greptile-apps review Round-two finding addressed: the registry now holds entries by |
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.
|
@greptile-apps review |
package.json and src/version.ts advance together, and the Unreleased notes become the dated 0.14.1 section the publish job reads.
Implements #24.
The shape
One side-effect import enables
reference.live, completing one property, three tenses:From Lit:
watch(counter.live.count)with theSignalWatchermixin, no glue. Any consumer of the TC39 signals API composes the same way.Design
signal-polyfill(new optional peer) never loads unlesssolid-objects/signalsis imported, andreference.livethrows a pointer to that import otherwise — the same seam pattern as the platform registration.watched/unwatched. The first watcher opens an in-processruntime.realtimesession (subscribe replays committed observables immediately); the last watcher's departure closes it after a configurable linger (configureLiveSignals). Entries dedupe per runtime and actor identity.Signal.Computedmirrors; the package types use a structuralLiveSignal { get() }so the type surface never requires the optional peer.undefinedby design, andlive.snapshotre-fetches the authorized snapshot (coalesced) on each accepted envelope. Personalized payload projections arrive aslive.payloads.<name>signals with their independent per-name revision fences; a newly watched payload name re-sends the subscription with the grown name list.Tests
snapshotWithIncarnationfor 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.check(including the browser-import graph, which now includessrc/signals.ts) passes.Docs
docs/api.mdgains thesolid-objects/signalssection (shape, behavior, the reservedsnapshotname, the authorization note) and areference.livebullet;docs/parity.mdrecords 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.mdstarts 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
shuffleupandplayLit demo. Payload signals landed in this PR after review.