@peter.naydenov/visual-controller-for-preact
v2.0.0
Published
Visual Controller for Preact - Control multiple Preact apps with a single controller
Maintainers
Readme
Visual Controller for Preact
Run multiple Preact apps on the same page from a single controller. Each app gets its own region defined by invisible markers — no app container ids, no authored wrapper elements, and no collisions between regions.
import VisualController from '@peter.naydenov/visual-controller-for-preact'
import HeaderApp from './apps/header.jsx'
import SidebarApp from './apps/sidebar.jsx'
import CartApp from './apps/cart.jsx'
const html = new VisualController({})
html.set(({ start, end }) => {
document.querySelector('header').append(start, end)
return 'header'
})
html.set(({ start, end }) => {
document.querySelector('aside').append(start, end)
return 'sidebar'
})
html.set(({ start, end }) => {
document.querySelector('main').append(start, end)
return 'cart'
})
html.publish('header', HeaderApp)
html.publish('sidebar', SidebarApp)
html.publish('cart', CartApp)Each publish is independent. Apps can be added, removed, swapped, or destroyed at runtime. Each Preact app receives the same shared dependencies through its props.
Region-based API. The id-based API from older releases is replaced by
setregions and alias-firstpublishcalls. See Migration from the id-based API when upgrading.
Why use this
Most pages need more than one Preact app — a header from one team, a sidebar from another, and a checkout widget from a third. The challenge is coordinating them without coupling the applications to DOM ids or to one another.
The marker model keeps the integration simple. Instead of authoring <div id="app"> and passing its id to the controller, place invisible markers directly in the DOM and give the region an alias:
html.set(({ start, end }) => {
document.querySelector('#main').append(start, end)
return 'app'
})
html.publish('app', MyComponent, { greeting: 'Hi!' })The controller owns the mount location. Regions can share a parent, aliases remain stable while apps are swapped, and the mount container is an internal implementation detail.
The dynamic lifecycle is the other half:
html.publish('header', HeaderApp)
html.publish('header', PromoBannerApp)
html.destroy('header')
html.publish('header', HeaderApp)Destroying an app removes its rendered content but keeps the region markers, so the same alias can host another app later.
Quick start
npm installimport VisualController from '@peter.naydenov/visual-controller-for-preact'
import HeaderApp from './header.jsx'
import SidebarApp from './sidebar.jsx'
const html = new VisualController({})
html.set(({ start, end }) => {
document.querySelector('#main').append(start, end)
return 'header'
})
html.set(({ start, end }) => {
document.querySelector('#main').append(start, end)
return 'sidebar'
})
html.publish('header', HeaderApp, { greeting: 'Hi!' })
html.publish('sidebar', SidebarApp)<main id="main">
<h2>Static page heading</h2>
</main>The same parent hosts two regions without app-container ids. The controller selects each region by the alias returned from set.
The marker model is a slim inlined subset of @peter.naydenov/dim, stored in src/dim.js. The dim package is not required at runtime.
API
set : 'Define a region by placing markers in the DOM'
, publish : 'Mount a Preact app into a region by alias'
, destroy : 'Unmount the app or apps and keep the markers'
, has : 'Check whether an app is published in a region'
, getApp : 'Return the setupUpdates interface for a published app'
, isEmpty : 'Check whether a region has no content'
, list : 'Return every alias registered via set'
, reset : 'Unmount all apps, clear state, and remove markers'html.set(fn, ...args)
Define a region. The callback receives { start, end } text-node markers and must attach both markers to the DOM. Whatever string the callback returns becomes the alias used by the other methods.
html.set(({ start, end }) => {
document.querySelector('#main').append(start, end)
return 'header'
})Additional arguments are forwarded to the callback:
html.set(({ start, end }, locale) => {
document.querySelector('#main').append(start, end)
return `header-${locale}`
}, 'en')The markers can be placed anywhere they can be inserted into the DOM. Multiple regions can live inside the same parent. They remain in place until reset() or until their parent is removed.
html.publish(alias, component, data?, extraParams?)
Mount a Preact app into a region. The controller inserts a <span style="display:contents"> between the markers and mounts the component into it.
| Arg | Required | Default | Description |
| --- | --- | --- | --- |
| alias | yes | — | Region alias returned from set. |
| component | yes | — | A Preact component. |
| data | no | {} | Data passed to the component as props.data. |
| extraParams | no | {} | Reserved for future use. Accepted and ignored. |
Returns a Promise resolving to the object registered with setupUpdates, or false on error.
html.publish('header', HeaderApp)
html.publish('header', HeaderApp, { greeting: 'Hi!' })
html.publish('header', HeaderApp, { greeting: 'Hi!' }, {})Calling publish for an alias that already has an app destroys the old app first, then mounts the new component in the same region.
html.destroy(target?)
Unmount the app published in a region and empty the range. The markers stay in the DOM, so the alias can be published again later.
html.destroy('header')
html.destroy()
html.destroy(['header', 'sidebar'])The return values are:
destroy(alias)returnstruewhen an app was destroyed andfalsewhen no app was published for that alias.destroy()destroys every published app and returns the number destroyed.destroy(aliases)destroys the listed apps, skips missing aliases, and returns the number destroyed.
What destroy() touches: the Preact app, its internal mount span, and the controller cache entry.
What destroy() does not touch: the region markers, the alias in list(), or the internal region registry.
Use reset() for a full cleanup that also removes the markers.
html.has(alias)
Return true when an app is currently published in the region and false otherwise. A declared but empty region returns false.
html.has('header')html.getApp(alias)
Return the object provided to setupUpdates by the published component, or false when no app is published for the alias.
const app = html.getApp('header')
if (app && app.changeMessage) {
app.changeMessage('New value')
}html.isEmpty(alias)
Check whether a region has no content between its markers. Returns true for an empty or orphaned region, false after an app has been published, and undefined for an unknown alias. Unknown aliases also log an error.
html.isEmpty('header')After destroy, the markers remain and the region is empty again.
html.list()
Return every alias registered via set, regardless of whether an app is currently published. The list is cleared by reset().
html.list()html.reset()
Unmount every published app, clear the controller state, and remove every marker from the DOM. After reset(), the aliases are gone and the regions must be created again with set() before publishing.
html.reset()Inside a component
A component receives three controller props:
dependenciescontains the object passed tonew VisualController(dependencies).datacontains the optional data object passed topublish.setupUpdatesregisters methods that can later be called throughgetApp.
import { h } from 'preact'
import { useState } from 'preact/hooks'
function HeaderApp ( props ) {
const
{ dependencies, data = {}, setupUpdates } = props
, [ message, setMessage ] = useState ( data.greeting || 'Hello from Preact!' )
, [ count, setCount ] = useState ( 0 )
;
function changeMessage ( newMessage ) {
setMessage ( newMessage )
}
function increment () {
setCount ( current => current + 1 )
}
function getCount () {
return count
}
setupUpdates ({ changeMessage, increment, getCount })
return h ( 'div', null,
h ( 'h2', null, message )
, h ( 'p', null, `Count: ${count}` )
, h ( 'button', { onClick: increment }, 'Increment' )
)
}dependencies is available to the component when shared services are needed:
const dependencies = { store, api, eventBus }
const html = new VisualController ( dependencies )External access goes through the region alias:
const updates = html.getApp('header')
if (updates) {
updates.changeMessage('New message content')
updates.increment()
updates.getCount()
}Other details
SSR hydration
When a region already contains HTML, publish detects the existing content and uses Preact's hydrate API. No additional controller configuration is needed.
The controller handles three cases:
- Empty range — inserts a
<span style="display:contents">and mounts withrender. - Single element between markers — mounts directly to that element with
hydrate. - Multiple sibling nodes between markers — wraps them in a mount span and hydrates the wrapper.
If the server-rendered markup does not match the component output, Preact reports the normal hydration mismatch behavior.
Custom elements
Preact renders custom elements without a controller-specific flag. Use the custom element tag in a component as usual:
h('profile-card', { userId: '42' })Development
Setup and common commands:
npm install
npm test # run the test suite once
npm run cover # run coverage once
npm run types # regenerate dist/main.d.ts from JSDoc
npm run build # build and regenerate types
npm run dev # run the demo at http://localhost:5173/Source layout:
| Path | Purpose |
| --- | --- |
| src/main.js | The Preact controller and public API. |
| src/dim.js | Slim inlined subset of the dim marker model. |
| test/01_general.test.jsx | Controller test suite. |
| demo/app.jsx | Header and Sidebar Preact demo apps. |
| demo/main.jsx | Demo regions, publishing, swapping, and controls. |
| index.html | Entry point for npm run dev. |
| dist/ | Build artifacts used for package publishing. |
Adding a new method
- Add the function to
src/main.jswith JSDoc. - Export it from the return object at the bottom of
src/main.js. - Add it to the
VisualControllerInstancetypedef near the top of the file. - Add tests in
test/01_general.test.jsx. - Update the README API table and method section.
- Add a bullet to
Changelog.mdunder the current version.
Keeping the inlined dim subset in sync
The dim marker model is owned by the official @peter.naydenov/dim package. If its API changes, compare src/dim.js with the upstream implementation and update the subset used by this controller. The controller uses set, get, reset, aliases, and the range's isEmpty method.
Migration from the id-based API
Older releases mounted an app by looking up a DOM id:
html.publish(Hello, { greeting: 'Hi!' }, 'app')
html.destroy('app')
html.has('app')
html.getApp('app')The region-based API declares the location first and passes the alias first to publish:
html.set(({ start, end }) => {
document.querySelector('#main').append(start, end)
return 'app'
})
html.publish('app', Hello, { greeting: 'Hi!' })
html.destroy('app')
html.has('app')
html.getApp('app')The migration changes are:
- Add one
setcall for every mount location. - Attach both markers in the
setcallback. - Return a string alias from the callback.
- Change
publish(component, data, id)topublish(alias, component, data?, extraParams?). - Use aliases instead of container ids with
destroy,has, andgetApp. - Use
isEmpty,list, andresetfor region inspection and cleanup. - Remember that
destroykeeps markers andresetremoves them.
Extra
Visual Controller has versions for other front-end frameworks:
Credits
Visual Controller for Preact is created and supported by Peter Naydenov.
License
Released under the MIT License.
