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 semanticsregisterController(...)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 hostsenableControllerReplacementForHmr()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:
- Extend the Ecopages JSX runtime bootstrap so it can emit additional Radiant browser setup, not only hydrator installation.
- Import
enableControllerReplacementForHmr()in that same bootstrap source. - 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.