Chapter 55: Boundaries and Context
Boundaries are how Kitsune applications carry application context through the DOM.
The pattern was introduced in Chapter 36 (architectural argument) and Chapter 41 (educational implementation). This chapter develops the production version — the declarative element, the programmatic API, the production patterns for dynamic boundaries, the testing helpers, and the operational concerns.
The Two Forms
Section titled “The Two Forms”A boundary can be declared two ways. Both produce the same context-collection behavior.
Declarative:
<kit-boundary surface="profile-editor" feature="account" entity-type="profile" entity-id="me"> <!-- content --></kit-boundary>The <kit-boundary> element takes its context as attributes. The element renders as display: contents (invisible to layout). The context applies to every event fired inside.
Programmatic:
import { runtime } from './runtime'
const boundary = runtime.attachBoundary(element, { context: { surface: 'profile-editor', feature: 'account', entityType: 'profile', entityId: 'me' }})
// later:boundary.updateContext({ entityId: 'someone-else' })boundary.destroy()The function takes an element and writes data-meta-* attributes onto it. The handle lets the caller update or remove the context later.
The two forms are interchangeable. The metadata boundary’s context-collection algorithm reads both meta-* (for <kit-boundary> custom elements) and data-meta-* (for elements decorated programmatically) without distinction.
The Context Vocabulary
Section titled “The Context Vocabulary”Kitsune’s standard context dimensions:
surface — where in the application this region appears. profile-editor, user-table, checkout-cart, settings-page. Surfaces nest; the metadata boundary collects them into a surfaces array from outer to inner.
feature — which product capability this region belongs to. preferences, billing, admin, onboarding. The feature is supplied by the outer boundary; inner boundaries can override.
entity-type and entity-id — what the region is about. entityType="user" plus entityId="user_123". The entity is typically supplied by the inner boundary closest to the relevant element.
route — the current URL path. Usually supplied by the router adapter at the document root.
mode — the user’s relationship to the entity. self, admin, anonymous. Used by audit and permission modules to distinguish user editing their own profile from admin editing someone else’s.
tenant — for multi-tenant applications, the tenant identifier. Supplied by the application’s session boundary.
private — a boolean marker for sensitive regions. Modules that observe events respect the marker by not capturing payload data.
These are conventions, not requirements. Applications can add their own dimensions (a currentExperimentArm, a featureVersion, anything else). The metadata boundary collects whatever attributes match the configured collection rules.
The Context-Collection Algorithm
Section titled “The Context-Collection Algorithm”The algorithm walks the DOM tree from the event target upward to the shell root, collecting context as it goes:
function collectContext(target: Element, root: Element): CollectedContext { const context: CollectedContext = { surfaces: [] } let current: Element | null = target
while (current && current !== root.parentElement) { const local = readBoundaryAttributes(current) mergeContext(context, local) current = current.parentElement }
context.surfaces.reverse() // outer-to-inner order return context}The merge rules:
Surfaces accumulate. Every surface attribute encountered during the walk gets added to the array. The result is an ordered list from the outermost surface to the innermost.
Other single-value fields use inner-most wins. The walk encounters the inner boundary first; if it declares feature="preferences", the walk records it; outer boundaries don’t override.
The walk stops at the shell root. The architecture doesn’t reach past the application’s shell into the document outside.
The merge is deterministic. Given the same DOM, the same context is produced. The collection cost is proportional to the depth of the DOM tree — typically O(10-20) ancestor walks — and runs only on events the metadata boundary observes.
Dynamic Boundaries
Section titled “Dynamic Boundaries”Production applications often have boundaries that change over time. A list of items, each row a boundary with its own entity. The list re-renders; rows are added and removed; the boundaries change too.
The programmatic API handles this naturally:
class UserTable extends LitElement { @property() users: User[] = []
private boundaries = new Map<string, BoundaryHandle>()
updated(changed: PropertyValues) { if (changed.has('users')) { this.rebuildBoundaries() } }
private rebuildBoundaries() { // Destroy boundaries for removed users const currentIds = new Set(this.users.map((u) => u.id)) for (const [id, handle] of this.boundaries) { if (!currentIds.has(id)) { handle.destroy() this.boundaries.delete(id) } }
// Attach boundaries for new users for (const user of this.users) { if (!this.boundaries.has(user.id)) { const row = this.shadowRoot!.querySelector(`[data-user="${user.id}"]`)! const handle = runtime.attachBoundary(row, { context: { surface: 'user-row', entityType: 'user', entityId: user.id } }) this.boundaries.set(user.id, handle) } } }
disconnectedCallback() { for (const handle of this.boundaries.values()) handle.destroy() this.boundaries.clear() }}The component manages its boundary handles alongside its rendering. The handles get cleaned up when the rows disappear. The architecture doesn’t leak.
For most applications, the declarative pattern works without this complexity:
<table> ${this.users.map((user) => html` <tr> <kit-boundary surface="user-row" entity-type="user" entity-id="${user.id}" > <!-- row content --> </kit-boundary> </tr> `)}</table>The boundaries appear and disappear with the rows. The Lit reactive update handles the boundary lifecycle automatically. The component’s code stays simple.
The programmatic API is for cases where the declarative pattern doesn’t fit — typically because the application’s renderer is something other than Lit, or because the boundary’s context depends on values that aren’t naturally attributes.
The Router Adapter
Section titled “The Router Adapter”A specific module pattern worth showing: the router adapter.
import { defineKitModule } from '@kitsune/core'
export function routerAdapter() { let boundary: BoundaryHandle | null = null
return defineKitModule({ name: 'router', onStart: ({ runtime }) => { boundary = runtime.attachBoundary(document.documentElement, { context: { route: location.pathname, search: location.search } })
window.addEventListener('popstate', updateContext) window.addEventListener('pushstate', updateContext)
function updateContext() { boundary?.updateContext({ route: location.pathname, search: location.search })
runtime.emit({ type: 'route.changed', payload: { route: location.pathname, search: location.search } }) } }, onStop: () => { boundary?.destroy() boundary = null } })}The adapter attaches a boundary at the document root with the current route. When the user navigates, the boundary’s context updates and a route.changed event fires.
Every event fired anywhere in the application now inherits the route context. Modules subscribed to events can read the route from the event’s context without depending on a global router state.
The pattern composes with any routing technology. The adapter is the same regardless of whether the application uses the History API directly, the View Transitions API for navigations, or a particular client-side router. The boundary is the architectural contract; the routing technology is the implementation detail.
Boundaries and Custom Elements
Section titled “Boundaries and Custom Elements”Custom elements can be their own boundaries by stamping attributes on themselves:
class UserProfile extends LitElement { @property() userId = ''
connectedCallback() { super.connectedCallback() this.dataset.metaSurface = 'user-profile' this.dataset.metaEntityType = 'user' this.dataset.metaEntityId = this.userId }
updated(changed: PropertyValues) { if (changed.has('userId')) { this.dataset.metaEntityId = this.userId } }}A <user-profile user-id="user_123"> becomes its own boundary. Events fired inside inherit the user-profile context. The component is portable — placing it anywhere produces the same context behavior.
This pattern is common for entity-bound components — components that represent a specific entity (a user, a document, a product). The component owns its entity context; the application doesn’t have to remember to wrap each instance.
Testing Boundary Behavior
Section titled “Testing Boundary Behavior”The @kitsune/testing package includes helpers for boundary-based tests:
import { createTestShell, fixture, expectEvent } from '@kitsune/testing'
test('events inherit boundary context', async () => { const { shell, runtime } = await createTestShell({ template: html` <kit-boundary surface="page" feature="test"> <kit-boundary surface="section" entity-type="item" entity-id="abc"> <kit-button meta-event="item.activated">Click</kit-button> </kit-boundary> </kit-boundary> ` })
const button = shell.runtime.querySelector('kit-button')! button.click()
const event = await expectEvent(runtime, 'item.activated') expect(event.context.surfaces).toEqual(['page', 'section']) expect(event.context.feature).toBe('test') expect(event.context.entity).toEqual({ type: 'item', id: 'abc' })
await shell.stop()})The test renders nested boundaries, clicks a button inside, and asserts the event’s context. The architecture’s collection algorithm is verified directly.
Production Boundary Patterns
Section titled “Production Boundary Patterns”A few patterns the Kitsune team has converged on for production applications.
One outermost boundary per shell. The shell’s mount establishes the root boundary with the application name. Everything else nests inside.
Route boundary just inside the shell. The router adapter attaches the route context at document.documentElement. Routes change without re-rendering the shell.
Surface boundaries at page sections. Each major UI region (header, main, sidebar, footer) gets its own <kit-boundary> with a surface attribute. The granularity matches the application’s navigation structure.
Entity boundaries close to the entity. The smallest boundary that contains the entity’s controls gets the entity context. A user-row in a list has its own boundary; a product-card has its own; a comment-thread has its own.
Private boundaries around sensitive regions. Payment forms, authentication forms, anything that should bypass analytics get the private attribute. The modules respect the marker.
Avoid unnecessary boundary depth. Boundaries are cheap, but excessive nesting makes the context-collection walk longer. Most applications don’t need more than 3-5 levels of boundaries.
Bridge to Metadata
Section titled “Bridge to Metadata”The next chapter (Chapter 56) takes the metadata protocol seriously — meta-event, meta-command, meta-intent, meta-prop-*, the full attribute vocabulary, the production patterns for declaring meaning in markup.
The boundaries supply context; the metadata supplies meaning. Together they’re what makes the runtime’s events expressive.
Exercise: Build a Boundary-Aware Search
Section titled “Exercise: Build a Boundary-Aware Search”Build a search UI where the search results are boundary-aware. Each result is a row with its own entity context. Clicking a result fires result.activated with the row’s entity in the event’s context.
The markup:
<kit-boundary surface="search" feature="discovery"> <input type="search" data-meta-event="search.query_changed" /> <ul> <li> <kit-boundary surface="search-result" entity-type="document" entity-id="d_1"> <kit-button meta-event="result.activated">Document 1</kit-button> </kit-boundary> </li> <li> <kit-boundary surface="search-result" entity-type="document" entity-id="d_2"> <kit-button meta-event="result.activated">Document 2</kit-button> </kit-boundary> </li> </ul></kit-boundary>Implement:
- A search module that handles
search.query_changedand fetches results (mock the fetch). - The module renders the results into the DOM, with each result wrapped in a
<kit-boundary>carrying the entity. - An analytics module that observes
result.activatedand tracks which result was activated. - Verify that clicking different rows produces events with different entity contexts.
Then extend:
- Add a recent searches dropdown. Each recent search is its own boundary. Clicking one re-runs the search.
- Add a filter control inside the search boundary. The filter dispatches a command (
search.filter_changed) that the search module observes. - Verify the architecture composes — events from filters, results, and recent searches all carry the right context.
Reflect on:
- How does the boundary’s context flow when the results re-render?
- What’s the lifecycle pattern for boundary attribution on dynamic content?
- If you wanted to add a featured-result badge that only appears on highly-relevant matches, how would you wire it through the architecture?
The exercise is the boundary system at production scale. The architecture handles dynamic content cleanly; the context attribution is automatic from the DOM structure; the events carry the right information without prop-drilling.