A modern, function-first Inversion of Control (IoC) container and reactive state management library anchored directly to HTMLElement and physical DOM nodes.
- Physical DOM as Scope Hierarchy: DOM nesting maps directly to IoC container scope hierarchies without virtual container trees.
- Zero-Leak Lifecycle Co-location: Automatic garbage collection and subscription cleanup tied to physical DOM attachment.
- Atomic Bidirectional Bridge: Synchronize JS reactive state and DOM properties (
dataset.*,style.*,aria-*,value) with loop guards. - Framework-Agnostic Lingua Franca: Built on native W3C Context Protocol (
ContextRequestEvent) and Custom Events, seamlessly bridging Astro, Islands, Web Components, and mixed frameworks. - Pure FP Two-Phase Architecture: Pure lazy blueprint declaration in Phase 1 + physical DOM mount execution in Phase 2.
npm install @sandlada/document-contextIn Astro or Islands architecture, independent <script is:inline> blocks bundled across different components can share state and services seamlessly through the physical DOM tree without sharing JavaScript heap memory references.
Define the pure blueprint and mount it to the root document element with persistent storage and DOM property synchronization:
<script is:inline type="module">
import { createContext, pipe, withBridge, withStorage, mount } from '@sandlada/document-context'
const themeBlueprint = pipe(
createContext({ isDark: false }),
withBridge({
properties: {
isDark: 'dataset.themeDark'
}
}),
withStorage({
adapter: 'localStorage',
key: 'app-theme',
hydrationStrategy: 'storageFirst'
})
)
// Mount to document root element
window.__themeSession = mount(themeBlueprint)(document.documentElement)
</script>An independent toggle button component modifies state using the curried update verb:
<script is:inline type="module">
import { update } from '@sandlada/document-context'
const btn = document.getElementById('theme-toggle-btn')
btn?.addEventListener('click', () => {
const toggle = update((s) => ({ isDark: !s.isDark }))
toggle(window.__themeSession)
})
</script>Another independent consumer component subscribes to reactive state changes or reads snapshots via select / subscribe:
<script is:inline type="module">
import { select, subscribe } from '@sandlada/document-context'
const statusEl = document.getElementById('theme-status-text')
const session = window.__themeSession
// 1. Initial snapshot read
const isDark = select((s) => s.isDark)(session)
if (statusEl) statusEl.textContent = isDark ? '🌙 Dark' : '☀️ Light'
// 2. Reactive subscription
subscribe((state) => {
if (statusEl) {
statusEl.textContent = state.isDark ? '🌙 Dark' : '☀️ Light'
}
})(session)
</script>@sandlada/document-context provides modular subpath exports for clean tree-shaking:
@sandlada/document-context/core@sandlada/document-context/bridge@sandlada/document-context/storage@sandlada/document-context/dom@sandlada/document-context/signals
Creates a pure immutable blueprint seed. Zero DOM access, zero I/O side effects.
const blueprint = createContext({ count: 0, theme: 'light' })Functional composition pipeline with progressive TypeScript generic type inference overloads.
const appBlueprint = pipe(
createContext({ count: 0 }),
withProvider('logger', () => new ConsoleLogger()),
withBridge({ properties: { count: 'dataset.count' } })
)Registers a synchronous service provider on the blueprint.
lifecycle:'scoped'(default) |'singleton'|'transient'multi:boolean(defaultfalse)
withProvider('auth', (session) => new AuthService(session), { lifecycle: 'scoped' })Registers an asynchronous service provider with lazy execution and Promise coalescing.
withAsyncProvider('user', async (session) => {
const res = await fetch('/api/user', { signal: session.abortSignal })
return res.json()
})Attaches declarative lifecycle hooks ('mount', 'dispose', 'suspend', 'resuscitate', 'adopt').
withHook('mount', (session) => {
console.log('Mounted on', session.target)
return () => console.log('Cleanup on dispose')
})Execution boundary that activates the runtime ISession on a physical HTMLElement.
const session = mount(appBlueprint)(document.getElementById('app-root')!)Asynchronous mount boundary awaiting eager storage hydration and async initialization.
const session = await mountAsync(appBlueprint)(document.getElementById('app-root')!)Curried synchronous state snapshot reader.
const getCount = select((s: { count: number }) => s.count)
const currentCount = getCount(session)Curried state transition verb. Accepts a partial state object or an updater function (prevState) => nextState. Returns boolean (false if session is disposed).
const increment = update<{ count: number }>((s) => ({ count: s.count + 1 }))
increment(session)Subscribes to reactive state transitions. Returns an unsubscribe teardown function.
const unsubscribe = subscribe((state) => {
console.log('New state:', state)
})(session)Explicitly tears down the session, executing LIFO cleanups, poisoning the state store, and aborting session.abortSignal.
Returns an RxJS Observable<DocumentContextError> streaming non-fatal errors (e.g., storage parsing failures, hook exceptions).
Registers bidirectional property synchronization between JavaScript state and DOM element properties.
properties: Record of dot-paths (dataset.*,style.*,style.--*,aria-*,value,checked,hidden,elementInternals.value).events: Array of DOM events triggering DOM-to-state sync (default:['input', 'change']).batch: Microtask write batching (default:true).
withBridge({
properties: {
count: 'dataset.count',
theme: 'dataset.theme',
inputValue: {
target: 'value',
parse: Number
}
}
})Configures persistent state storage, schema migrations, and cross-tab synchronization.
adapter:'localStorage'|'sessionStorage'| custom adapterkey: Storage string keyhydrationStrategy:'storageFirst'(default) |'domFirst'|'blueprintFirst'|'merge'crossTabSync: BroadcastChannel & Window storage sync (default:true)version: Schema version numbermigrate: Migration transition function(oldData, oldVersion) => newData
withStorage({
adapter: 'localStorage',
key: 'user-settings',
hydrationStrategy: 'storageFirst',
version: 2,
migrate: (oldData: any, oldVersion) => {
if (oldVersion === 1) return { ...oldData, newField: 'default' }
return oldData
}
})Curried dependency injection verb. Dispatches standard ContextRequestEvent bubbling up the physical DOM tree across Shadow DOM boundaries.
// In a child Web Component or Element
const auth = inject('auth')(this)Asynchronous dependency injection with Promise Coalescing, DFS circular dependency detection, and multi-caller abort isolation.
const user = await injectAsync('user', { signal: abortController.signal })(childElement)Multi-provider accumulation protocol collecting all matching services up the ancestor hierarchy.
direction:'bottomUp'(default) |'topDown'
const plugins = injectAll('plugin', { direction: 'topDown' })(childElement)Adapts a reactive state slice into a TC39 Signal-compatible object with a .get() method.
import { toSignal } from '@sandlada/document-context/signals'
const countSignal = toSignal(session, (s) => s.count)
console.log(countSignal.get())String tokens support 100% type safety and auto-completion across bundle boundaries via TypeScript declaration merging:
// types/context.d.ts
import type { AuthService, Logger } from './services'
declare module '@sandlada/document-context' {
interface ServiceRegistry {
'auth:service': AuthService
'logger:service': Logger
}
}MIT