Radiant0.3.0-rc.2

Controller Tooling & HMR

RadiantController registration is intentionally stable by default.

That stability matters because controller identifiers are global and controller runtimes may already have live instances attached to DOM nodes by the time another module evaluates.

Default Contract

The default registry contract is:

  • @controller(...) uses first-registration-wins semantics
  • registerController(...) keeps the original class for an identifier
  • a later registration does not mutate already-connected instances

That is the safe runtime contract for application code.

It prevents accidental double imports, repeated side-effect evaluation, or duplicate bundle edges from silently swapping controller behavior underneath a live page.

Explicit Mutation Path

When tooling really does need mutation, the registry exposes separate opt-in APIs through @ecopages/radiant/controller-registry.

Use these APIs deliberately:

  • replaceController(identifier, ControllerClass) replaces the registered class and reconnects matching live hosts
  • enableControllerReplacementForHmr() switches decorator-driven registration into replacement mode for the current runtime

Example:

import { enableControllerReplacementForHmr } from '@ecopages/radiant/controller-registry';
 
if (import.meta.hot) {
	enableControllerReplacementForHmr();
}

That helper is intentionally named for tooling. It is not meant as normal application setup.

Current Docs-App Integration

In this docs app, the HMR hook currently lives in the shared browser shell:

That script runs on every docs page and enables controller replacement only when import.meta.hot exists.

This is the current best integration point inside the repo because it is:

  • global for the docs browser runtime
  • cheap to reason about
  • isolated from production builds
  • independent from individual controller example modules

Shared Runtime Integration

The long-term integration point is the Ecopages JSX browser-runtime bootstrap, not each application shell.

Today that bootstrap only installs the Radiant hydrator. In practice, that means the docs app still owns the HMR-specific opt-in in its shared browser shell.

The current bootstrap output is:

import { installRadiantHydrator } from '@ecopages/radiant/client/hydrator';
installRadiantHydrator();

That makes it the natural future integration point for controller HMR too.

Planned Plugin Port

The clean upstream direction is:

  1. Extend the Ecopages JSX runtime bootstrap so it can emit additional Radiant browser setup, not only hydrator installation.
  2. Import enableControllerReplacementForHmr() in that same bootstrap source.
  3. Guard it behind the browser HMR runtime so production output keeps the default stable registration contract.

At a high level, the upstream bootstrap would become:

import { installRadiantHydrator } from '@ecopages/radiant/client/hydrator';
import { enableControllerReplacementForHmr } from '@ecopages/radiant/controller-registry';
 
installRadiantHydrator();
 
if (import.meta.hot) {
	enableControllerReplacementForHmr();
}

Design Debt To Keep Visible

There is one real piece of design debt here:

  • the global browser bootstrap currently belongs to the JSX plugin, while controller registration belongs to the Radiant runtime

That split is reasonable, but it means controller HMR currently needs an app-level bridge until the plugin grows a better browser bootstrap extension point.

So the current state is intentionally transitional:

  • app shell today
  • plugin bootstrap later

That is better than making @controller(...) mutating by default.

Recommendation

Use this rule:

  • application runtime: keep default registration semantics
  • tooling and HMR: opt into explicit replacement
  • framework integration: move the opt-in to the highest shared browser bootstrap you control

For this repo today, that highest shared bootstrap is the base layout script.

For Ecopages JSX generally, the right eventual destination is the plugin runtime bundle service.

See Also