@jdlanglois/oeil
v0.2.0
Published
A tiny frontend framework with interchangeable view syntaxes
Maintainers
Readme
@jdlanglois/oeil
npm install @jdlanglois/oeilA tiny frontend framework with plain mutable state and interchangeable view syntaxes. Hyperscript, Hiccup, and cached HTML templates all produce the same fixed-shape canonical VNodes and use one renderer.
import { mount, redraw, h } from "@jdlanglois/oeil/h"
const state = { count: 0 }
function Counter() {
let local = 0
return () => h("main", null,
h("p", null, `Global: ${state.count}`),
h("p", null, `Local: ${local}`),
h("button", { onclick: () => state.count++ }, "Global +"),
h("button", { onclick: () => local++ }, "Local +"),
)
}
mount(document.querySelector("#app")!, Counter)DOM events schedule a coalesced redraw. Call redraw() after mutations from
promises, timers, network callbacks, or other external sources.
View syntaxes
Hyperscript
Hyperscript constructs canonical VNodes directly and is the recommended performance-oriented API.
import { mount, redraw, route, list, h } from "@jdlanglois/oeil/h"
h("div", { class: "greeting" }, h("strong", "Hello"), " world")Hyperscript and Hiccup both support Emmet-style element selectors. The tag
defaults to div; selector classes are prepended to explicit classes, while
an explicit id overrides the selector id.
h("h1.red.large#heading", "Hello")
h(".card#primary", { class: "wide" }, "Content")Hiccup
import { mount, redraw, route, list, hiccup } from "@jdlanglois/oeil/hiccup"
hiccup(["div", { class: "greeting" }, ["strong", "Hello"], " world"])
hiccup(["h1.red#heading", "Hello"])
hiccup([".card#primary", { className: "wide" }, "Content"])Raw Hiccup returned from a component remains supported for compatibility.
HTML templates
Template strings are parsed once per call site and instantiate canonical VNodes on each redraw.
import { mount, redraw, route, list, html } from "@jdlanglois/oeil/html"
html`<button class="primary" onclick=${increment}>Count: ${count}</button>`Each syntax-specific entry point reexports mount, redraw, route, and
list, so an application can use one import while excluding the other syntax
frontends. The root @jdlanglois/oeil entry exports all strategies and relies
on standard ESM tree shaking.
The syntaxes can still be mixed because they share the Renderable and
canonical VNode representations. Local state uses closure components. Add
key to list children when identity must follow data through reorders.
Fragments
Components can return arrays of VNodes; oeil normalizes them as implicit fragments:
const Layout = () => [
h("aside", "Navigation"),
h("main", "Content"),
]An array beginning with a string, component, or Fragment remains Hiccup
syntax. Use an explicit fragment when a key or deliberate range identity is
needed.
HTML templates support multiple root nodes directly:
const Layout = () => html`
<aside>Navigation</aside>
<main>Content</main>
`Hyperscript and Hiccup can use the shared Fragment token, while fragment()
works with every frontend:
import { Fragment, fragment, h } from "@jdlanglois/oeil/h"
import { hiccup } from "@jdlanglois/oeil/hiccup"
h(Fragment, null,
h("aside", "Navigation"),
h("main", "Content"),
)
hiccup([Fragment,
["aside", "Navigation"],
["main", "Content"],
])
fragment(h("aside", "Navigation"), h("main", "Content"))Fragments retain a DOM range, so keyed fragment components move all of their nodes together and preserve DOM/component identity. Empty fragments and transitions between empty and populated fragments are supported.
Specialized keyed lists
A specialized keyed list can skip item view construction when an explicit revision remains unchanged:
import { h, list } from "@jdlanglois/oeil/h"
h("ul", null, list(rows, {
key: row => row.id,
version: row => row.version,
view: row => h("li", null, row.label),
}))The revision must include every value used by view, including external
props. Omitting version preserves mutable-state correctness by reevaluating
every item.
Router
import { route } from "@jdlanglois/oeil"
route(root, "/", {
"/": Home,
"/companies/:id": Company,
})
route.set("/companies/42")
route.params()
route.query()Browser benchmark
Chromium 151 on Linux ARM64, 1,000 keyed rows. Values are median milliseconds; the benchmark also prints p95 latency and validates initial rendering and keyed reorder correctness.
| Framework/frontend | Initial | Unchanged | Update one | Append/remove | Reorder | Event update | |---|---:|---:|---:|---:|---:|---:| | oeil specialized list | 8.40 | 0.13 | 0.98 | 1.21 | 9.80 | 0.55 | | oeil hyperscript baseline | 9.60 | 1.24 | 1.51 | 1.69 | 9.97 | 1.76 | | oeil Hiccup | 9.40 | 0.93 | 0.89 | 1.56 | 11.03 | 1.38 | | oeil HTML | 9.60 | 1.71 | 1.08 | 1.73 | 10.27 | 2.04 | | Mithril | 9.40 | 0.62 | 0.78 | 1.72 | 13.37 | 1.62 | | Preact | 9.10 | 1.49 | 1.16 | 2.01 | 12.03 | 1.21 | | React | 10.40 | 1.07 | 1.74 | 1.75 | 12.27 | 2.09 |
Findings
- The specialized list is a clear win for unchanged data:
0.13 msversus1.24 msfor ordinary hyperscript, because item views and generic keyed reconciliation are skipped. - It produced the fastest initial, append/remove, reorder, and event-update medians in this run.
- Its one-row update remained slower than Mithril and Hiccup, so an explicit revision check is not a universal win for every mutation pattern.
- Oeil's keyed reconciliation remains strong, but sub-millisecond differences vary substantially with JIT, GC, and browser scheduling.
- Keeping only specialized lists increased core size from 2,676 to 3,087 bytes gzip and the full framework from 3,678 to 4,115 bytes gzip. The discarded local-invalidation and text-specialization experiments did not justify their additional complexity and size.
These are microbenchmarks, not universal application rankings. Run them on the target hardware before making framework decisions.
npm run benchmark:install # once
npm run benchmarkTrying oeil in Flems
Run npm run build, then copy the complete contents of exactly one browser
bundle into the top of the Flems JavaScript panel:
| View syntax | Copy-paste bundle | Global |
|---|---|---|
| Hyperscript | dist/oeil-hyperscript.min.js | oeil |
| Hiccup | dist/oeil-hiccup.min.js | oeil |
| HTML template | dist/oeil-html.min.js | oeil |
Use this HTML panel:
<div id="app"></div>After the pasted bundle, add one of these examples.
Hyperscript bundle
const { mount, h } = oeil
let count = 0
function App() {
return h("main.card",
h("h1#title", `Count: ${count}`),
h("button.primary", { onclick: () => count++ }, "Increment"),
)
}
mount(document.querySelector("#app"), App)Hiccup bundle
const { mount } = oeil
let count = 0
function App() {
return ["main.card",
["h1#title", `Count: ${count}`],
["button.primary", { onclick: () => count++ }, "Increment"],
]
}
mount(document.querySelector("#app"), App)HTML-template bundle
const { mount, html } = oeil
let count = 0
function App() {
return html`<main class="card">
<h1 id="title">Count: ${count}</h1>
<button class="primary" onclick=${() => count++}>Increment</button>
</main>`
}
mount(document.querySelector("#app"), App)All three standalone bundles include mount, redraw, route, and list.
Only the selected view-construction frontend is added.
Development
npm test
npm run build
npm run benchmark:jsdomThe build reports raw, gzip, and Brotli sizes and enforces 6 KB gzip for core and 8 KB gzip with the router. The renderer is informed by techniques from Mithril; see THIRD_PARTY_NOTICES.md.
