@jacare/core
v0.1.17
Published
Jacaré runtime — fine-grained reactivity engine
Readme
@jacare/core
The runtime for Jacaré — a fine-grained reactive UI framework with no Virtual DOM.
Jacaré compiles .jcr modules into direct DOM updates. When a value changes, only the nodes that depend on it update. This package provides the reactivity engine, DOM bindings, navigation, forms, SSR helpers, and lifecycle hooks.
Table of contents
- Install
- Quick start
- Core concepts
- Reactivity API
- Template bindings
- Components
- Navigation
- Forms
- Lifecycle hooks
- Scope debugging
- Server-side rendering
- Island mount kit
- API reference
- Links
Install
npm install @jacare/coreFor a full app with CLI, compiler, and Vite plugin:
npm install -g @jacare/cli
npm install @jacare/core
npm install -D @jacare/vite-pluginnpm: https://www.npmjs.com/package/@jacare/core
Quick start
Create a .jcr file — a plain JavaScript module with a view tagged template:
import { pulse, derive, view } from '@jacare/core'
const count = pulse(0)
const label = derive(() => `Count: ${count()}`)
export default view`
<section class="counter">
<p>${label}</p>
<button on-click=${() => count.update((n) => n + 1)}>
Increment
</button>
</section>
`Mount it from boot.js:
import App from './app.jcr'
const root = document.getElementById('app')
const dispose = App(root)
// later: dispose() to clean upEach .jcr module compiles to three functions:
| Function | Purpose |
|----------|---------|
| mount(target, props?) | Client-side DOM mount |
| render(props?) | SSR HTML string |
| resume(target, state, props?) | Hydrate SSR output |
Core concepts
Pulses (signals)
State is stored in pulses. Reading a pulse inside a template or effect subscribes to it. Writing triggers only the dependents.
const count = pulse(0)
count() // read → 0
count.set(5) // write
count.update(n => n + 1) // functional updateDerived values
Computed values re-run only when their dependencies change:
const first = pulse(2)
const second = pulse(3)
const total = derive(() => first() + second())Effects
Run side effects when tracked pulses change:
import { watch } from '@jacare/core'
watch(() => {
console.log('count is', count())
})No Virtual DOM
Jacaré does not diff a virtual tree. The compiler emits precise DOM operations — text updates, attribute bindings, keyed list reconciliation — wired directly to the pulse graph.
Reactivity API
Canonical names
| API | Alias | Description |
|-----|-------|-------------|
| signal(initial) | pulse(initial) | Reactive value |
| computed(fn) | derive(fn) | Derived value — dispose() for dynamic lifetimes |
| effect(fn) | watch(fn) | Side effect |
| untrack(fn) | — | Run without tracking |
| batch(fn) | — | Batch multiple writes |
| flushSync() | — | Drain pending updates now |
| enablePatience() / disablePatience() | — | Opt-in microtask coalesce + internal lanes (default stays sync) |
| runAsLane(lane, fn) | — | Runtime/tooling write origin (input / default / idle) |
| runUntracked(fn) | — | Untracked execution (used by DOM bindings) |
Untracked reads
| API | Use when |
|-----|----------|
| signal.peek / computed.peek | Read current value without subscribing |
| untrack(fn) | Temporarily disable tracking inside an effect |
| runUntracked(fn) | Same as untrack — used internally by bindText, bindModel, etc. |
Pulse graph internals
Each signal and computed owns a DependencyCell:
- Subscribers stored in an array for fast
notify() - A
Setprovides O(1) duplicate checks during dependency tracking - Unsubscribe uses swap-remove — O(1) amortized
Effects and computeds use an OwnerNode tree. Disposing an owner clears dependencies and runs cleanups automatically.
import { pulse, derive, view } from '@jacare/core'
const items = pulse([
{ id: '1', label: 'Learn Jacaré', done: false },
])
const filter = pulse('')
const filtered = derive(() => {
const q = filter().trim().toLowerCase()
if (!q) return items()
return items().filter((item) => item.label.toLowerCase().includes(q))
})
function addItem(label) {
items.update((list) => [
...list,
{ id: String(Date.now()), label, done: false },
])
}Template bindings
Use these inside view\...``:
Text
view`<p>${greeting}</p>`Reactive pulses are read automatically. For mixed text:
view`<p>Total: ${total()}</p>`Events
view`<button on-click=${save}>Save</button>`
view`<input on-input=${(e) => filter.set(e.target.value)} />`Alias: @click=${fn}
Attributes
view`<a bind-href=${url}>Link</a>`Alias: :href=${url}
Two-way form bindings
view`
<input bind-value=${text} />
<input type="checkbox" bind-checked=${done} />
`bind-value and bind-checked compile to bindModel — DOM and signal stay in sync.
Conditional classes
view`<li class-done=${() => item.done}>...</li>`Alias: class:done=${() => item.done}
Conditionals
view`
#if show()
<p>Visible</p>
#elif loading()
<p>Loading…</p>
#else
<p>Hidden</p>
#end
`Alias: @if / @elseif / @else / @end
Multiple siblings inside a branch mount in source order (branch / showIf use an insertion cursor after the comment anchor).
Lists
view`
#for items() as item (item.id)
<li>${item.label}</li>
#end
`item.idis the key for efficient DOM reconciliation- Alias:
@each items() as item (item.id)/@end
Components
Import another .jcr module and use it as a self-closing tag:
import Field from './Field.jcr'
export default view`
<Field :label=${'Email'} :field=${form.fields.email} :type=${'email'} />
`Props are passed with :propName=${value}. Inside the child component, props appear as variables in scope.
Navigation
Client-side routing with lazy-loaded screens:
import { createNav, lazy, screen } from '@jacare/core'
import Shell from './shell.jcr'
import Home from './pages/home.jcr'
import NotFound from './pages/not-found.jcr'
export const nav = createNav({
base: '/',
layout: Shell,
screens: {
'/': screen(Home),
'/about': lazy(() => import('./pages/about.jcr')),
'/users/:id': lazy(() => import('./pages/user.jcr')),
},
missing: NotFound,
})
// in boot.js
nav.attach(document.getElementById('app'))Layout shell
export default view`
<header>...</header>
<main jacare-frame></main>
<footer>...</footer>
`Active screen content mounts inside jacare-frame.
Declarative links
<a jacare-go="/about" href="/about">About</a>Jacaré intercepts clicks and syncs the jacare-here class on the active link.
Programmatic navigation
await nav.go('/about')
await nav.go('/settings', { focus: true }) // focus [data-jacare-focus] after mount
await nav.go('/settings', { focus: '#main-title' })
await nav.swap('/settings') // replace history entry
nav.undo() // history.back()
await nav.warm('/about') // prefetch lazy screennav.where is a Signal<NavPlace>:
nav.where() // reactive — re-runs dependents on navigation
nav.where.peek // untracked — current place without subscribingRoute helpers
import { createRoute, getRouteParam, getRouteSearch, routeParam, routeSearch, routeHref } from '@jacare/core'
// Preferred in screens — reactive helpers from nav.where
export const route = createRoute(nav.where)
route.param('id')()
route.search('q')()
// Template sugar ${@route/id} / ${@search/q} uses the same active nav:
const id = getRouteParam('id')
const q = getRouteSearch('q')
id()
q()
// One-shot from a NavContext (title / mount), not a subscription:
title: (ctx) => `Item · ${routeParam(ctx, 'id') ?? '…'}`
const link = routeHref('/item/:id', { id: '7' }, { tab: 'specs' })
// '/item/7?tab=specs'
routeHref('/a/:idea/:id', { idea: 'x', id: '1' }) // '/a/x/1' — token replace, no prefix collisionIn templates:
<p>${@route/id}</p>
<p>${@search/q}</p>
<h1 data-jacare-focus tabindex="-1">Settings</h1>Forms
import { createForm } from '@jacare/core'
const form = createForm({
email: {
value: '',
validate: (value) => (value.includes('@') ? 'Invalid email' : null),
},
message: {
value: '',
validate: (value) => (!value.trim() ? 'Required' : null),
},
})
export <view>
<input bind-value=${form.fields.email} on-blur=${() => form.fields.email.blur()} />
#if form.fields.email.error()
<span>${form.fields.email.error()}</span>
#end
</view>Each form.fields.* is a field signal with error(), touched(), dirty(), and blur().
Lifecycle hooks
Per-screen lifecycle for mount, activate, deactivate, and unmount:
import { createLifecycle } from '@jacare/core'
export const lifecycle = createLifecycle({
onMount() {
console.log('screen mounted')
},
onActivate(ctx) {
console.log('screen visible', ctx.path)
return () => {}
},
onDeactivate() {
console.log('screen hidden')
},
onUnmount() {
console.log('screen destroyed')
},
})Export lifecycle from lazy-loaded .jcr screens. Nav wraps it automatically via screen().
Scope debugging
Register values for live inspection (used by @jacare/devtools):
import { registerScope, pulse } from '@jacare/core'
const draft = pulse('')
registerScope('draft', () => draft())Server-side rendering
import { renderToString, renderToStream } from '@jacare/core'
import { render, resume } from './app.jcr'
// server
const html = renderToString(() => render())
// client hydration
const dispose = resume(root, state)SSR output includes data-jacare-bind markers for precise hydration targets.
Island mount kit
Embed a .jcr widget into a host page without a Jacaré SPA shell. Use the thin subpath:
import { mountIsland } from '@jacare/core/island'
import Widget from './Widget.jcr'
const dispose = mountIsland('#slot', Widget, {
props: { start: 2 },
shadow: true,
})| Option | Role |
|--------|------|
| props | Passed to compiled mount |
| shadow | Mount inside a shadow root (CSS isolation) |
| clear | Clear host children before mount (default true) |
| mark | Host attribute after mount (default data-jacare-island) |
Docs: island.md · demo: examples/jacare-island
API reference
Reactivity
signal, pulse, computed, derive, effect, watch, untrack, batch, flushSync, enablePatience, disablePatience, isPatienceEnabled, runAsLane, runUntracked, isTracking
DOM
view, bindText, bindPropText, bindAttribute, bindProperty, bindClass, bindModel, safeUrl, branch, reconcileKeyedList, showIf, ensureScopedStyle, mountSlot
Nav
createNav, lazy, screen, adaptScreen, createRoute, getRouteParam, getRouteSearch, routeHref, routeParam, routeSearch, screenProps · type NavGoOptions
Forms
createForm
Lifecycle
createLifecycle
Scope
registerScope, clearScope, getScopeSnapshot, subscribeScope, startScopePulse
SSR
renderToString, renderToStream, resumeBindings, escapeHtml, safeUrl
Island
mountIsland — import from @jacare/core/island
DevTools hooks
enableDevtools, getPulseGraph, subscribePulseGraph
Links
- npm — @jacare/core
- Repository
- API reference
- Syntax guide
- Live demo
- Jacaré Lab — full API tutorial
- Example app
- Related: @jacare/compiler · @jacare/vite-plugin · @jacare/cli · @jacare/devtools · @jacare/meta
License
MIT
