Skip to content

SSR & per-request isolation

BlaC’s registry is a module-level singleton. In the browser that is exactly what you want: every useBloc call across your app resolves the same (class, key) to the same instance, so state is shared with no providers. On a server, that same singleton is a problem — one process handles many requests, and a shared registry means one user’s bloc state can bleed into another user’s request.

This page names that hazard plainly and shows the fix that already ships: swapping the active registry per request via setRegistry / getRegistry, so each request gets its own isolated set of bloc instances.

On the client there is one user and one registry, for the life of the tab. On the server there is one process and one module-level registry (globalRegistry), but many concurrent users. Every registry function — acquire, ensure, borrow, release, and watch (which resolves via ensure) — routes through a single module-level variable inside @blac/core:

// packages/blac-core/src/registry/config.ts (verified)
let _registry = globalRegistry;
export function getRegistry(): StateContainerRegistry {
return _registry;
}
export function setRegistry(registry: StateContainerRegistry): void {
_registry = registry;
}

If nothing swaps _registry, every request shares globalRegistry. Concretely:

  • Request A renders for user A, acquire(SessionCubit) creates an instance keyed 'default' and fills it with user A’s data.
  • Request B renders for user B. acquire(SessionCubit) finds that same 'default' instance already on the shelf and reuses it — now serving user A’s session data to user B.

This is not a re-render bug or a hydration mismatch. It is a correctness and security hole: server-rendered HTML for one user can contain another user’s state. The same goes for any keyed instance whose key is not perfectly request-unique — and for keepAlive blocs, which by design survive ref count zero and therefore persist across requests in a long-lived process.

@blac/core exposes the registry as a swappable slot, not a hard-coded global. Three exports do the work:

import {
class StateContainerRegistry

Central registry for managing StateContainer instances. Handles instance lifecycle, named ref tracking, and lifecycle event emission.

@example

const registry = new StateContainerRegistry();
const instance = registry.acquire(MyBloc); // ownership, must release
const other = registry.ensure(OtherBloc); // no ownership, bloc-to-bloc
registry.on('stateChanged', (container, prev, next) => {
console.log('State changed:', prev, '->', next);
});

stateChanged fires once per emit (microtask-deferred but uncoalesced), so listeners see every intermediate transition. Plugins' onStateChange fires once per channel flush with coalesced prev/next and a PathSet. Pick the transition log for devtools/time-travel, the plugin hook for change-driven work.

StateContainerRegistry
,
function getRegistry(): StateContainerRegistry

@public

getRegistry
,
function setRegistry(registry: StateContainerRegistry): void

@public

setRegistry
,
const globalRegistry: StateContainerRegistry

Global default registry instance

@public

globalRegistry
,
} from '@blac/core';
// Construct a fresh, empty registry — its own instance map, no shared state.
const
const requestRegistry: StateContainerRegistry
requestRegistry
= new
new StateContainerRegistry(): StateContainerRegistry

Central registry for managing StateContainer instances. Handles instance lifecycle, named ref tracking, and lifecycle event emission.

@example

const registry = new StateContainerRegistry();
const instance = registry.acquire(MyBloc); // ownership, must release
const other = registry.ensure(OtherBloc); // no ownership, bloc-to-bloc
registry.on('stateChanged', (container, prev, next) => {
console.log('State changed:', prev, '->', next);
});

stateChanged fires once per emit (microtask-deferred but uncoalesced), so listeners see every intermediate transition. Plugins' onStateChange fires once per channel flush with coalesced prev/next and a PathSet. Pick the transition log for devtools/time-travel, the plugin hook for change-driven work.

StateContainerRegistry
();
// Make it the active registry. Every acquire/ensure/release/watch now uses it.
function setRegistry(registry: StateContainerRegistry): void

@public

setRegistry
(
const requestRegistry: StateContainerRegistry
requestRegistry
);
// Read the active registry (defaults to globalRegistry until you swap it).
const
const active: StateContainerRegistry
active
:
class StateContainerRegistry

Central registry for managing StateContainer instances. Handles instance lifecycle, named ref tracking, and lifecycle event emission.

@example

const registry = new StateContainerRegistry();
const instance = registry.acquire(MyBloc); // ownership, must release
const other = registry.ensure(OtherBloc); // no ownership, bloc-to-bloc
registry.on('stateChanged', (container, prev, next) => {
console.log('State changed:', prev, '->', next);
});

stateChanged fires once per emit (microtask-deferred but uncoalesced), so listeners see every intermediate transition. Plugins' onStateChange fires once per channel flush with coalesced prev/next and a PathSet. Pick the transition log for devtools/time-travel, the plugin hook for change-driven work.

StateContainerRegistry
=
function getRegistry(): StateContainerRegistry

@public

getRegistry
();
// Restore the process-wide default when the request is done.
function setRegistry(registry: StateContainerRegistry): void

@public

setRegistry
(
const globalRegistry: StateContainerRegistry

Global default registry instance

@public

globalRegistry
);
ExportWhat it isRole in SSR
StateContainerRegistryThe registry class. new StateContainerRegistry() is an empty registry with its own instance map.Construct one per request.
setRegistry(registry)Sets the module-level active registry.Point the active slot at the request’s registry.
getRegistry()Returns the current active registry (initially globalRegistry).Read it back if you need to operate on the active one directly.
globalRegistryThe default singleton, used until setRegistry is called.The value you restore the slot to after a request.

A fresh StateContainerRegistry starts empty: no registered types, no instances, no refs. Bloc classes are not pre-registered against a registry — the first acquire/ensure of a class against whichever registry is active registers and creates the instance there. So once setRegistry(requestRegistry) runs, the very first acquire(SessionCubit) in that request creates a brand-new SessionCubit inside requestRegistry, isolated from every other request.

The pattern: scope the render with RegistryProvider

Section titled “The pattern: scope the render with RegistryProvider”

setRegistry is a single global slot, so “set at the start of the handler, restore at the end” only holds if nothing async interleaves between two requests. Under real concurrency — awaiting a data fetch mid-render while another request runs — a bare set/restore races: request B’s setRegistry clobbers the slot while request A is suspended.

@blac/react avoids the slot entirely. RegistryProvider scopes every descendant useBloc to a registry passed through React context, so concurrent renders cannot interfere:

import { StateContainerRegistry } from '@blac/core';
import { RegistryProvider } from '@blac/react';
async function handleRequest(req: Request): Promise<Response> {
const registry = new StateContainerRegistry();
try {
const html = renderToString(
<RegistryProvider registry={registry}>
<App url={req.url} />
</RegistryProvider>,
);
return new Response(`<!doctype html><div id="root">${html}</div>`, {
headers: { 'content-type': 'text/html' },
});
} finally {
registry.clearAll();
}
}

Because the registry travels through context rather than a module variable, two requests rendering concurrently in the same process each resolve their own instances — no AsyncLocalStorage bridge, no restore step, nothing to race.

On the client there is one registry for the whole tab, so isolation is a non-issue — do not call setRegistry in browser code. Client useBloc calls resolve against globalRegistry (the default the slot points at until something swaps it), exactly as in a non-SSR app.

That means the registries on the two sides are independent: the server’s per-request registry produced the HTML string, and the client’s globalRegistry rehydrates from scratch on mount. BlaC does not serialize the server registry into the client — blocs re-create from their constructors during hydration. If a bloc needs server-computed data to render identically on both sides, pass that data in as args (or serialize it into the page and feed it to the bloc’s init), the same way you would seed any client-side state for hydration.

Under Vite’s dev SSR (vite-node, ssrLoadModule) each request goes through the same module graph in one process, so the module-level registry slot is shared exactly as in production — the per-request pattern above applies unchanged in dev. The one extra hazard is HMR: hot-reloading the module that owns globalRegistry, or your ALS store, can leave you with two copies of the singleton (the old one and the freshly evaluated one) and instances split between them. If you see stale or duplicated instances only after a hot update, force a full reload rather than chasing it as a logic bug.

  • Instance Management — the registry, ref counting, clearAll, and the lending-library model
  • Passing Inputs — args and instance identity, the seam for feeding server data into hydration
  • Configuration — keepAlive (which makes cross-request persistence worse) and the circuit breakers
  • watch — observing blocs outside React, and how it resolves through the active registry
  • Testing core logic — clearAll for isolating the registry between tests, the same primitive you use per request