Runtime Architecture¶
This document describes every type and subsystem in the nojs/runtime package: what it does, how the pieces connect, and how the rendering lifecycle progresses from component definition to DOM update.
Table of Contents¶
- Package overview
- Build tag strategy
- Core interfaces
- Component
- Renderer
- NavigationManager
- Navigator
- ComponentBase — embedded state helpers
- Lifecycle interfaces
- Mountable
- ParameterReceiver
- Unmountable
- PropUpdater
- RendererImpl — the concrete renderer
- Internal state
- Construction
- RenderRoot
- RenderChild
- ReRender and ReRenderSlot
- Component cleanup
- Navigate
- Dev vs. production lifecycle dispatch
- Full render lifecycle walkthrough
- Slot / layout scoped re-renders
- Thread safety
- File map
1. Package overview¶
The runtime package is the execution engine of nojs. It owns:
- The
ComponentandRendererinterfaces that all components and the renderer must satisfy. ComponentBase— a composable struct that every component embeds to receiveStateHasChanged(),Navigate(), and slot-parent tracking for free.- Lifecycle interfaces (
Mountable,ParameterReceiver,Unmountable,PropUpdater) that components opt into. RendererImpl— the concrete WASM-only renderer that manages the component instance tree, drives the virtual DOM lifecycle (initial render, patch, clear), and wires up client-side navigation.
Signals are out of scope for this package. Cross-component and persistent application state is handled by
github.com/ForgeLogic/nojs/signals(Signal[T]), which has no dependency on the runtime, VDOM, or renderer. See the Signals documentation for details.
The package is deliberately split between build-tag-free files (interfaces / ComponentBase) and WASM-only files (RendererImpl, lifecycle dispatch) so that compiler-generated Render() methods and tests can be compiled on native platforms without a WASM toolchain.
2. Build tag strategy¶
| File | Build constraint | Purpose |
|---|---|---|
component.go |
none | Component interface + ComponentFactory |
componentbase.go |
none | ComponentBase struct |
componentlifecycle.go |
js \|\| wasm |
Lifecycle interfaces (Mountable, etc.) |
navigation.go |
js && wasm |
NavigationManager + Navigator interfaces |
renderer.go |
none | Renderer interface |
renderer_impl.go |
js \|\| wasm |
Concrete RendererImpl |
renderer_dev.go |
(js \|\| wasm) && dev |
Lifecycle dispatch — dev mode (panics propagate) |
renderer_prod.go |
(js \|\| wasm) && !dev |
Lifecycle dispatch — prod mode (panics recovered) |
Files with no build tag can be imported by native Go test binaries. This keeps the AOT-generated Render() methods and their unit tests fully buildable without a WASM target.
3. Core interfaces¶
Component¶
// component.go
type Component interface {
Render(r Renderer) *vdom.VNode
SetRenderer(r Renderer)
}
Every UI building block implements Component. The framework calls SetRenderer during mounting so the component can later trigger re-renders via StateHasChanged(). Render returns the complete virtual DOM subtree for that component on every render cycle.
ComponentFactory is related:
type ComponentFactory func(params map[string]string) Component
The router uses ComponentFactory to instantiate components for matched routes, passing URL path parameters (e.g., {id} → "42") as the params map.
Renderer¶
// renderer.go
type Renderer interface {
RenderChild(key string, childWithProps Component) *vdom.VNode
ReRender()
ReRenderSlot(slotParent Component) error
Navigate(path string) error
}
AOT-generated Render() methods call r.RenderChild(...) for every child component they embed. The Renderer interface has no build tags, which is what makes generated code cross-platform.
| Method | Called by | Purpose |
|---|---|---|
RenderChild |
Generated Render() methods |
Create/reuse a child component instance and return its VNode |
ReRender |
ComponentBase.StateHasChanged() |
Full re-render from the root |
ReRenderSlot |
ComponentBase.StateHasChanged() (slot path) |
Scoped re-render of a layout's slot content |
Navigate |
ComponentBase.Navigate() |
Delegate to the router |
NavigationManager¶
// navigation.go (js && wasm only)
type NavigationManager interface {
Start(onChange func(chain []Component, key string)) error
Navigate(path string) error
GetComponentForPath(path string) (Component, bool)
}
The framework core is router-agnostic. Any router that satisfies NavigationManager can be injected into RendererImpl at construction time. nil is a valid value — the renderer operates without routing.
Start— reads the initial browser URL, resolves the component chain, firesonChange, then begins listening topopstateevents.Navigate— pushes a new entry onto the browser history stack and firesonChange.GetComponentForPath— resolves a path to a component without side effects (used for pre-resolution).
The onChange callback receives a chain ([]Component) rather than a single component because a routed app may compose a layout, a sub-layout, and a leaf page all in one navigation.
Navigator¶
// navigation.go (js && wasm only)
type Navigator interface {
Navigate(path string) error
}
A narrower interface used when only navigation is needed (i.e., from components). ComponentBase satisfies this interface through its Navigate method.
4. ComponentBase — embedded state helpers¶
// componentbase.go (no build tag)
type ComponentBase struct {
renderer Renderer
slotParent Component
}
Embed ComponentBase in every component struct:
type Counter struct {
runtime.ComponentBase
Count int
}
Embedding provides:
| Method | Signature | Purpose |
|---|---|---|
SetRenderer |
(r Renderer) |
Injected by the framework; stores the renderer for later use |
GetRenderer |
() Renderer |
Returns the stored renderer (for advanced use cases) |
StateHasChanged |
() |
Signal that state mutated; triggers a re-render |
SetSlotParent |
(parent Component) |
Called by the renderer when this component is slotted inside a layout |
Navigate |
(path string) error |
Request client-side navigation via the router |
StateHasChanged routing logic¶
ComponentBase.StateHasChanged()
│
├─ slotParent != nil ──► renderer.ReRenderSlot(slotParent) // scoped re-render
│
└─ slotParent == nil ──► renderer.ReRender() // full re-render
This means page components that live inside a layout's []*vdom.VNode slot trigger only a slot re-render — the layout shell is diffed but not recreated.
5. Lifecycle interfaces¶
All lifecycle interfaces are declared in componentlifecycle.go (build tag: js || wasm).
Mountable¶
type Mountable interface {
OnMount()
}
Called once, before the component's first render, after its instance is created. Use it for one-time initialization such as kicking off async data fetches.
func (c *UserProfile) OnMount() {
c.IsLoading = true
go c.fetchUserData() // goroutine calls StateHasChanged when done
}
ParameterReceiver¶
type ParameterReceiver interface {
OnParametersSet()
}
Called before every render, including the first one. Useful for detecting prop changes and reacting to them (e.g., fetching new data when a DataID prop changes).
func (c *DataDisplay) OnParametersSet() {
if c.DataID != c.prevDataID {
c.prevDataID = c.DataID
go c.fetchData()
}
}
Unmountable¶
type Unmountable interface {
OnUnmount()
}
Called once when the component instance is removed from the active tree (e.g., when a route changes away from the component). Use it to cancel goroutines, clear intervals, or release any held resources.
func (c *TimerComponent) OnUnmount() {
if c.cancel != nil {
c.cancel() // stops the ticker goroutine
}
}
PropUpdater¶
type PropUpdater interface {
ApplyProps(source Component)
}
Do not implement this manually. The AOT compiler generates ApplyProps for every component. The renderer calls it when a component instance already exists in the cache and new props arrive from the parent — it copies exported (prop) fields while leaving unexported (state) fields untouched.
6. RendererImpl — the concrete renderer¶
RendererImpl is declared in renderer_impl.go and is only available in WASM builds (js || wasm).
Internal state¶
type RendererImpl struct {
mu sync.Mutex
instances map[string]Component // keyed component instance cache
initialized map[string]bool // tracks which instances have been OnMount'd
activeKeys map[string]bool // components active in the current render cycle
currentComponent Component // the root component
currentKey string // reconciliation key (e.g., current route path)
navManager NavigationManager // optional router
mountID string // CSS selector for the DOM mount point
prevVDOM *vdom.VNode // VDOM from the previous render cycle
instanceVDOMCache map[Component]*vdom.VNode // per-instance VDOM (for slot diffs)
renderingStack []Component // stack of currently-rendering components
}
Key design decisions:
instancesmap — keyed by a globally unique string built from the parent component pointer and the child's logical key (e.g.,"0xc000123456:counter-0"). This prevents key collisions when multiple parent components render children with the same logical key.renderingStack— a call-stack maintained duringRender()traversal; used to construct the globally unique child key duringRenderChild.instanceVDOMCache— stores the last rendered VNode for each component instance, enablingReRenderSlotto diff against the previous tree without a full root re-render.
Construction¶
renderer := runtime.NewRenderer(navManager, "#app")
navManager may be nil for apps without routing. mountID is a CSS selector for the DOM element that acts as the application root (e.g., "#app").
RenderRoot¶
Called once at application startup and on every full re-render (ReRender):
- Resets
activeKeysmap. - Calls
SetRendereronr.currentComponent. - Pushes root onto
renderingStack. - Calls
OnMount(first render only) thenOnParametersSet. - Calls
currentComponent.Render(r)to produce the new VDOM tree. - Pops root from
renderingStack. - Attaches
currentKeyto the root VNode asComponentKey. - Initial render: clears the mount point and calls
vdom.RenderToSelector. - Subsequent render, same key: calls
vdom.Patchfor minimal DOM updates. - Subsequent render, key changed (navigation): clears the mount point, re-renders fresh, calls
OnUnmounton the old root, resetsinitialized. - Stores
newVDOMinprevVDOMandinstanceVDOMCache. - Calls
cleanupUnmountedComponents.
RenderChild¶
Called by generated Render() methods for every embedded child component:
vnode := r.RenderChild("counter-0", &Counter{Count: props.InitialCount})
- Builds a globally unique key:
fmt.Sprintf("%p:%s", parent, key). - Marks the key as active (
activeKeys[globalKey] = true). - First render: stores the provided
childWithPropsas the canonical instance. - Subsequent renders: retrieves the cached instance; calls
ApplyProps(ifPropUpdateris implemented) to update props without losing state. - Calls
SetRenderer, thenSetSlotParent(if applicable). - Calls lifecycle methods:
OnMount(first time only), thenOnParametersSet. - Pushes the instance onto
renderingStack, callsinstance.Render(r), pops. - Returns the VNode.
ReRender and ReRenderSlot¶
| Method | Scope | Mechanism |
|---|---|---|
ReRender() |
Full application | Delegates to RenderRoot() |
ReRenderSlot(parent) |
Single layout's slot | Re-renders the parent layout, diffs against cached VNode, patches |
ReRenderSlot is the performance-critical path for page components inside layouts. The algorithm:
- Retrieve the parent layout's previous VNode from
instanceVDOMCache. - Push the parent onto
renderingStack(for consistent child key generation). - Call
parent.Render(r)to produce a new VNode (the layout re-renders with updated slot content). - Pop from
renderingStack. - Call
vdom.Patch(mountID, prevParentVDOM, newParentVDOM). - Update
instanceVDOMCache[parent].
If no cached VNode exists yet, reRenderFull is used as a fallback.
Component cleanup¶
After every RenderRoot, cleanupUnmountedComponents iterates instances. Any instance whose key is not in activeKeys was not reached during the render walk — meaning it was removed from the tree. For such instances:
OnUnmount()is called (if the component isUnmountable).- The instance is removed from
instancesandinitialized.
activeKeys is then reset for the next cycle.
Navigate¶
func (r *RendererImpl) Navigate(path string) error
Delegates to r.navManager.Navigate(path). Returns an error if no router is configured.
7. Dev vs. production lifecycle dispatch¶
Lifecycle methods (OnMount, OnParametersSet, OnUnmount) are called through thin dispatcher methods so their error-handling behaviour can differ between builds.
| Build tag | File | Behaviour |
|---|---|---|
(js \|\| wasm) && dev |
renderer_dev.go |
Panics propagate — crash fast for developer feedback |
(js \|\| wasm) && !dev |
renderer_prod.go |
Panics are recovered and logged via fmt.Printf — the app keeps running |
To build for development (default framework build): pass -tags dev.
To build for production: omit the dev tag.
8. Full render lifecycle walkthrough¶
Below is the sequence of events from a button click to an updated DOM.
User clicks button
│
▼
Component event handler (e.g., Counter.Increment())
│ mutates c.Count
▼
c.StateHasChanged()
│
├── slotParent != nil ──► RendererImpl.ReRenderSlot(layout)
│ │
│ ├─ layout.Render(r) ──► new VNode tree
│ ├─ vdom.Patch(...) ──► DOM updated
│ └─ instanceVDOMCache updated
│
└── slotParent == nil ──► RendererImpl.RenderRoot()
│
├─ OnParametersSet (if impl.)
├─ currentComponent.Render(r)
│ └─ r.RenderChild("x", &Child{...})
│ ├─ ApplyProps (if re-render)
│ ├─ OnParametersSet (if impl.)
│ └─ child.Render(r) ──► child VNode
├─ (Initial) vdom.RenderToSelector
│ or
│ vdom.Patch(mountID, prevVDOM, newVDOM)
├─ prevVDOM = newVDOM
└─ cleanupUnmountedComponents
9. Slot / layout scoped re-renders¶
The slot system enables efficient updates when page-level state changes inside a layout shell. The mechanism is purely in-memory with no DOM markers.
How slots work¶
- A layout component declares a
[]*vdom.VNodefield (e.g.,BodyContent []*vdom.VNode). - The router (or parent) populates this field with children VNodes.
- When the layout is rendered, its generated
Render()injects those children into the appropriate place in the VNode tree. - The AOT compiler detects the
[]*vdom.VNodefield type and generates code to inject child component VNodes into it.
How state changes propagate¶
When a page component (living inside BodyContent) calls StateHasChanged():
ComponentBasechecksb.slotParent(set by the renderer on first slot mount).- If non-nil,
ReRenderSlot(b.slotParent)is called instead ofReRender(). ReRenderSlotre-renders only the layout — the layout template re-embeds the updated page VNodes.vdom.Patchdiffs old vs new layout VNode and applies only the changed DOM nodes.
This means layout chrome (navigation bars, sidebars, headers) does not touch the DOM if only page content changed.
10. Thread safety¶
RendererImpl protects all mutable state with a single sync.Mutex (r.mu). Every public method that reads or writes renderer state acquires the mutex. This allows event handlers in WASM goroutines (e.g., async data fetches calling StateHasChanged) to safely trigger re-renders without data races.
Lifecycle callbacks (OnMount, OnParametersSet, OnUnmount) are called while the mutex is held — avoid acquiring the same mutex inside lifecycle methods to prevent deadlocks.
11. File map¶
| File | Build tag | Contents |
|---|---|---|
component.go |
none | Component interface, ComponentFactory |
componentbase.go |
none | ComponentBase struct with StateHasChanged, Navigate, SetSlotParent |
componentlifecycle.go |
js \|\| wasm |
Mountable, ParameterReceiver, Unmountable, PropUpdater |
navigation.go |
js && wasm |
NavigationManager, Navigator |
renderer.go |
none | Renderer interface |
renderer_impl.go |
js \|\| wasm |
RendererImpl, NewRenderer, full rendering engine |
renderer_dev.go |
(js \|\| wasm) && dev |
callOnMount, callOnParametersSet, callOnUnmount — dev (panic pass-through) |
renderer_prod.go |
(js \|\| wasm) && !dev |
callOnMount, callOnParametersSet, callOnUnmount — prod (panic recovery) |