App and lifecycle API
Mount behavior where you need it, and release it before its host removes the DOM. Brio’s lifecycle APIs keep those two steps explicit.
Managed dynamic insertion
Section titled “Managed dynamic insertion”Use app.insert() when new content needs Brio bindings and owned cleanup,
rather than writing HTML into a managed subtree yourself.
const handle = app.insert('#items', 'append', '<button on-click="$data.count++">${$data.count}</button>', { data: { count: 0 } });handle.remove();app.insert(target, placement, content, options?) accepts a node or selector.
| Placement | Result |
|---|---|
before, after | Insert beside the target. |
prepend, append | Insert inside the target. |
replaceWith, replaceChildren | Replace the target or its children. |
textContent | Insert literal text; no HTML parsing or interpolation evaluation. |
HTML strings are automatically trusted and hydrated. Nodes and document fragments are cloned, leaving originals untouched. Multiple top-level nodes and structural directives are supported.
Insertion targets and expression helpers
Section titled “Insertion targets and expression helpers”The app method is insert (without $); the matching $insert helper is available in directive expressions and $context.$insert
in scripts. Its selectors are restricted to the originating mount root.
App-level selectors must resolve within exactly one of that app’s mounted roots;
missing, ambiguous, cross-app, and disposed targets throw. Direct node references
also work for child placements on managed detached b-if.detach elements and teleported
elements. A contextual helper expires when its mount is unmounted, even if the
same root element is mounted again.
Inserted context, handles, and cleanup
Section titled “Inserted context, handles, and cleanup”Child placements inherit the target’s context; sibling placements inherit its
parent’s context. { data } creates local reactive state with inherited state
accessible through $data.$parent; loop context remains available through $for.
Inserted content is owned by the destination scope, not the calling button/script.
The handle exposes current nodes, removed, and an idempotent remove().
Removal, replacement, enclosing scope disposal, and unmount clean up bindings,
listeners, and nested insertions. Raw DOM removal does not trigger cleanup.
Insertion ownership rules
Section titled “Insertion ownership rules”Mount roots cannot be replaced or used for sibling placements. Containers owned
by b-ignore, b-text, b-html, or router outlets reject child insertion rather
than competing with those directives. Roots generated by b-if and b-for
cannot be replaced directly, since those controllers retain their identities;
replace the enclosing container’s children instead. before/after placements
next to a b-for row throw, because the loop owns the space between its rows;
insert into the row itself or outside the loop instead. Replacement is destructive: old content
is disposed first; a hydration failure removes the failed insertion and throws,
but does not restore the old content.
Trust and insertion
Section titled “Trust and insertion”Use only templates you trust as application code. Automatic trustedHTML is an
ergonomic trust assertion, not sanitization; inserted expressions and scripts
execute code. Sanitizing HTML alone does not make it a trusted Brio template.
Low-level dynamic hydration
Section titled “Low-level dynamic hydration”Prefer app.mount() for independent roots: it manages runtime and cleanup
automatically. For integrations needing direct control, ESM imports expose
hydrateRoot(element, context, runtime) and hydrateNode(node, context, runtime).
- Supply an explicit context, including
$data. - Supply a runtime with
app,state, andcomponents. - Invoke hydration inside
createScope().run(...). - Dispose that scope before removing or replacing the hydrated content.
Hydrate only new content, not an already hydrated subtree. Raw DOM removal does not dispose bindings or listeners.
App configuration and methods
Section titled “App configuration and methods”createApp({ state?: () => Record<string, unknown>, stores?: Record<string, unknown>, components?: Record<string, ComponentDefinition>, router?: Router, plugins?: BrioPlugin[],})| API | Purpose |
|---|---|
app.component(name, definition) | Register a reusable component with a valid hyphenated custom-element name. |
app.plugin(plugin) | Install a plugin once per app. |
app.mount(elementOrSelector) | Mount one independently disposable root; returns { root, unmounted, unmount() }. |
app.unmount(options?) | Unmount all roots; leave the app mountable. |
app.dispose(options?) | Permanently dispose the app and its scopes. |
app.state | State shared by this app’s mounted roots. |
app.stores | Fixed read-only mapping of explicitly supplied stores; no automatic reactivity or persistence. |
The mount root is owned by Brio: unmount restores its original attributes and children by default. Put b-if and b-for on descendants, not on the mount root.
Framework SSR and hydration are not part of this release. Brio enhances existing HTML on the client, so server-rendered multi-page sites need no Brio server runtime.
Server-rendered pages and page swaps
Section titled “Server-rendered pages and page swaps”The host owns navigation and DOM replacement. Brio owns behavior attached to each mounted root. No document observer or navigation adapter runs automatically.
const app = createApp({ state: () => ({ count: 0 }) });const mount = app.mount(element);
mount.unmount(); // Cleanup and restore authored HTML.// For outgoing DOM, use one of these instead:mount.unmount({ restore: false }); // Cleanup this root without snapshot restoration.app.unmount({ restore: false }); // Cleanup all roots; app remains reusable.app.dispose({ restore: false }); // Cleanup all roots and permanently close the app.What cleanup releases
Section titled “What cleanup releases”Root-owned cleanup always runs, including listeners, effects, observers, refs,
and resources. App plugins and their resources live until app disposal, not
individual root unmounting. { restore: false } skips original-attribute/child restoration;
it does not freeze the DOM: directive cleanup may remove owned output.
The resulting DOM is not a reusable template. Mount fresh server markup for
the next page. Teardown is idempotent; all roots are released even when cleanup
reports errors. Failed mounting still restores its original markup.
Page swaps and persistent roots
Section titled “Page swaps and persistent roots”Before a client-side swap, dispose apps belonging to outgoing roots. After the swap, mount fresh roots. For a persistent island, preserve the actual root element and its existing mount; do not dispose or mount it again. DOM IDs do not transfer state. Keep persistent and page-local apps separate when they need independent app/plugin lifetimes.
Browser back/forward cache
Section titled “Browser back/forward cache”For ordinary document navigation, no pagehide handler is required. Do not
unconditionally dispose on pagehide: event.persisted === true means the
browser may resume this same document from its back/forward cache. Keep its
mounts alive and do not remount on pageshow. If registering pagehide cleanup
for non-cached navigation, guard !event.persisted and do not use { once: true }
(a cached departure would consume the listener).
Astro islands
Section titled “Astro islands”Use the optional adapter when Astro owns page navigation and Brio owns the interactive islands. Register app factories once; the adapter handles discovery and page-swap cleanup.
@jnreis/briojs/astro is an optional client integration, exported from the same
package. It requires no Astro server plugin, component wrapper, separate package,
or addition to the Brio browser core.
Register island factories
Section titled “Register island factories”Install the adapter once from a shared layout’s processed client script:
<script> import { enhanceAstro, lazyApp } from '@jnreis/briojs/astro'; import { createGreeting } from '../apps/greeting';
const integration = enhanceAstro({ apps: { greeting: createGreeting, counter: lazyApp(() => import('../apps/counter')), }, onError(error, root) { console.error('Unable to enhance island', root, error); // Show an application-specific error message if appropriate. }, });
if (import.meta.hot) { import.meta.hot.dispose(() => integration.dispose()); }</script>Mark the island roots
Section titled “Mark the island roots”Declare roots by registered name; sibling instances may use the same name:
<section b-app="counter" b-props="{ initial: 5 }"> <button b-on-click="$data.count++" b-text="$data.count">5</button></section>Return one fresh app per island
Section titled “Return one fresh app per island”Each factory receives (root, props) and returns a fresh, unmounted Brio app.
Factories may ignore either argument. Each island owns its entire app, including
plugins; do not return an app shared with another island or an existing mount.
Factories are synchronous; lazyApp explicitly marks asynchronous module loading.
import { createApp } from '@jnreis/briojs';import type { AstroAppFactory } from '@jnreis/briojs/astro';
const createCounter: AstroAppFactory = (_root, props) => { const initial = props.initial ?? 0; if (typeof initial !== 'number' || !Number.isFinite(initial)) { throw new TypeError('Counter initial must be a finite number.'); } return createApp({ state: () => ({ count: initial }) });};
export default createCounter;Eager and lazy code loading
Section titled “Eager and lazy code loading”Registry entries are loaded code, not app instances.
| Entry | Loading behavior |
|---|---|
| Ordinary factory function | Eager: the factory code is already loaded. |
lazyApp(() => import(...)) | Loads when a matching island is discovered; the module must default-export its factory. |
One loader promise is shared per lazy entry during an installation, but every root gets its own app. This is code loading, not visibility, idle, or interaction scheduling.
Authored server HTML remains until mounting; provide useful static content if desired. A module failure is cached without automatic retries.
Island metadata and props
Section titled “Island metadata and props”b-app and b-props are adapter metadata, not core directives:
- Names are exact registered keys. Unknown names and nested
b-approots are errors; use components for nested behavior. Independent siblings are supported. b-propsis an optional trusted JavaScript expression, evaluated once per new root before code loading, with$elavailable and no app$data.- It must return a synchronous plain object with data properties, not accessors, arrays, promises, or null. Absent props default to an empty object. Top-level props are shallow-copied/read-only; nested values are not deeply frozen.
- Props are passed to the factory, not automatically merged into app state. Runtime HTML props require validation; TypeScript does not type-check the HTML.
- Regular
b-bind-*attributes keep their normal DOM-binding meaning.
Passing server data safely
Section titled “Passing server data safely”The expression uses Brio’s trusted JavaScript evaluator, not a sandbox or JSON parser. Do not interpolate untrusted code into it. Astro frontmatter runs on the server; processed scripts run in the browser. For server data, use Astro’s HTML attribute serialization and generate the expression as data, for example:
---const initial = 5;---<section b-app="counter" b-props={JSON.stringify({ initial })}> <button b-on-click="$data.count++" b-text="$data.count">5</button></section>JSON object text is also a valid object expression here; Brio itself still uses JavaScript evaluation. Follow the host’s safe serialization rules, and never use HTML string concatenation with untrusted content.
Astro navigation and persistent islands
Section titled “Astro navigation and persistent islands”With Astro ClientRouter, the adapter releases outgoing apps before swaps and
discovers incoming roots on astro:page-load. Repeated events never remount
existing roots. Roots at or inside a matching transition:persist boundary keep
their actual app, DOM identity, and original props. Incoming props do not reset
them. If the boundary disappears, their apps are disposed. Use unique Astro
persistence names and leave Astro in charge of moving the preserved DOM.
Pending loads and island errors
Section titled “Pending loads and island errors”Pending lazy initialization survives when its actual root is preserved.
Otherwise it is abandoned before the factory is called. Dynamic imports cannot
be cancelled, but obsolete completions never mount outgoing islands. Live load,
props, factory, mounting, and swap-cleanup errors go to the required
onError(error, root) callback; other islands continue. Failed roots do not retry
on repeated page-load events. Exceptions thrown by onError are surfaced as
uncaught errors. Invalid configuration and duplicate active installation throw
synchronously.
Disposing the Astro integration
Section titled “Disposing the Astro integration”integration.dispose() is idempotent: it unregisters host listeners, abandons
pending initialization, and disposes every app, restoring its authored snapshot.
Cleanup failures are thrown after releasing the other apps and installation
lock. Reinstallation is allowed after disposal. Outgoing page swaps instead use
restore: false. There is no document MutationObserver or public scan/retry API:
DOM changes outside Astro navigation remain the host’s responsibility.
Compatibility and the runnable fixture
Section titled “Compatibility and the runnable fixture”The adapter leaves back/forward-cached documents live. It is tested against
Astro 5.18.2 and its generated data-astro-transition-persist marker; reverify
when upgrading Astro or customizing its swap algorithm. The runnable
fixture registry exercises eager/lazy
apps and persistent ancestors. Pulse events in the fixture are test diagnostics
for leaked listeners, not part of the adapter API.
Run npm run dev:astro to explore the fixture. Run npx playwright install chromium
once, then npm run test:astro for production-build browser tests of repeated
navigation, actual DOM/state persistence, resource cancellation, cleanup counts,
and browser back/forward restoration.