Skip to content

Proposal: View signals #261

@terrablue

Description

@terrablue

Motivation

Server signals and Store signals provide reactive server-side values. However, without a way to surface them in views, they can only be consumed manually over raw WebSocket routes — which requires boilerplate and leaves the frontend unaware of the reactive nature of the data.

This proposal closes that gap: when a server signal is passed as a prop to response.view, Primate automatically uses its current value for SSR and opens a WebSocket connection to keep the prop live on the client. From the frontend's perspective, the prop is just a normal reactive prop — no special API, no useSignal, no createSignal. The framework's existing prop reactivity does the rest.

This proposal, together with Store signals, fully replaces the earlier @primate/live concept at #62 .

Proposed Solution

1) Signal detection in response.view

When response.view is called with props, it inspects each prop value. If a prop implements ReadableSignal — detected via a well-known symbol or interface — it is treated as a live prop:

import response from "primate/response";
import Post from "#store/Post";
 
const livePosts = Post.signal({ sort: { createdAt: "desc" }, limit: 20 });
 
route.get(() => response.view("Index.svelte", {
  posts: livePosts,   // ReadableSignal — treated as live prop
  title: "My Blog",  // plain value — passed through as-is
}));

Plain props are passed through unchanged. Signal props are resolved for SSR and kept live over WebSocket.

2) SSR with authoritative initial value

For each signal prop, response.view calls await signal.get() to resolve the current value before rendering. The initial HTML is always rendered with the authoritative latest known value — SSR integrity is never compromised:

// internally, before rendering:
const resolved = {
  posts: await livePosts.get(),  // authoritative async read
  title: "My Blog",              // plain, passed through
};
// render with resolved props

3) WebSocket connection and multiplexing

After SSR, Primate opens a single shared WebSocket connection per page for all live props. Each signal is identified by its prop key, which serves as the wire identity:

// client subscribes on connect
{ type: "subscribe", prop: "posts" }
 
// server pushes update when signal changes
{ type: "update", prop: "posts", value: [...] }

Multiple signal props share the same connection — Primate does not open one connection per signal.

4) Client-side prop update

When the client receives an update message, Primate's frontend binding passes the new value into the framework's normal prop update mechanism. No special client API is needed — the framework treats it as a regular prop change:

  • Svelte: prop is a reactive $prop, update triggers re-render automatically
  • React: prop is passed into state, setState triggers re-render
  • Solid: prop flows into a signal, reactive graph updates automatically
  • Vue: prop is reactive ref, template re-renders automatically
  • Angular: prop flows into an input() signal, reactive graph updates automatically

The frontend binding for each framework (@primate/svelte, @primate/react etc.) handles this transparently. From the component author's perspective, a live prop looks and behaves identically to a plain prop.

5) Connection lifecycle and ref-counting

The shared WS connection is opened when the first signal prop is active on a page, and closed when the last one is no longer needed. Primate's client runtime maintains a reference count per route:

  • navigating to a page with signal props → open connection, subscribe to signals
  • navigating away → unsubscribe all signals, close connection if ref count reaches zero

6) Wire protocol

The multiplexing protocol is minimal:

// client → server: subscribe to a signal prop
{ type: "subscribe", prop: string }
 
// server → client: push updated value
{ type: "update", prop: string, value: unknown }
 
// client → server: unsubscribe (on navigate away)
{ type: "unsubscribe", prop: string }

Values are JSON-serialized. The protocol is internal to Primate and not part of the public API.

Security

Signal props expose whatever the signal's value contains over WebSocket. The same access controls that apply to the route apply to the signal — if a route is behind a guard, the signal updates are also gated by that guard. Consumers are responsible for not passing sensitive data as signal props to unauthenticated routes.

Summary of changes

Area Change
response.view Detect ReadableSignal props, resolve via get() for SSR, register for WS push
WS multiplexing Single shared connection per page, prop key as wire identity
Client runtime Per-framework binding update to receive update messages and feed into prop system
@primate/svelte, @primate/react, @primate/solid, @primate/vue @primate/angular Each updated to handle live prop updates transparently
Documentation Add "View Signals" section covering live props, SSR behaviour, and connection lifecycle
Tests Add tests for SSR resolution, WS connection lifecycle, multiplexing, and per-framework prop updates

Open questions

  • How is ReadableSignal detected on a prop value — via a well-known symbol (e.g. Symbol.for("primate.signal")) or via duck-typing (checking for .get, .subscribe)? A symbol is more robust; duck-typing is more flexible.
  • Should the wire protocol support batching — sending multiple prop updates in a single message — for efficiency when several signals update simultaneously?
  • What is the reconnection strategy if the WebSocket drops mid-session?

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions