Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7737912
feat(core): rename ReactiveController hooks to lit names
vivek7405 May 20, 2026
b413311
docs: explain why webjs aligns its API with lit
vivek7405 May 20, 2026
da33567
feat(core): add lit-aligned lifecycle hooks
vivek7405 May 20, 2026
9efdd76
docs: full lit-aligned lifecycle hooks documentation
vivek7405 May 20, 2026
5c33197
feat(core): Tier-1 lit-html directives (keyed, guard, templateContent…
vivek7405 May 20, 2026
7193e20
feat(core): Tier-2 directives (cache, until, asyncAppend, asyncReplace)
vivek7405 May 20, 2026
a65f73f
docs+test: full lit-html directive parity (tests + AGENTS.md + docs p…
vivek7405 May 20, 2026
785b815
test(browser): cover Phase 2 lifecycle + Phase 3 directives in real b…
vivek7405 May 20, 2026
cd48297
test: integration smoke for lit-API parity (lifecycle + directives)
vivek7405 May 20, 2026
689e235
feat(core): full implementation of cache, until, asyncAppend, asyncRe…
vivek7405 May 20, 2026
be98987
docs(directives): describe full cache / until / asyncAppend / asyncRe…
vivek7405 May 20, 2026
dbcb509
fix(core): code-review blocking bugs in Phase 2 + Phase 3
vivek7405 May 20, 2026
b26ee84
fix(core): element-position ref + lifecycle-error recovery + strength…
vivek7405 May 20, 2026
a1adfb2
test: port lit ref/keyed/guard directive tests
vivek7405 May 20, 2026
0f43954
test: port lit templateContent tests; wire all four ports into WTR
vivek7405 May 20, 2026
7dbe5df
test: fix keyed top-level wrap and templateContent identity assertion
vivek7405 May 20, 2026
f115686
test: document webjs ref re-bind divergence; skip guard(undefined) bug
vivek7405 May 20, 2026
d452506
test: align alternating-ref-callbacks divCalls with webjs no-cleanup-…
vivek7405 May 20, 2026
9b78940
fix(core): bugs surfaced by lit-test ports (cache, async-stream, guar…
vivek7405 May 20, 2026
471b9da
fix(core): ref cleanup on dispose + until edge cases (lit-test port f…
vivek7405 May 20, 2026
2b1171b
test(browser): port lit lifecycle + ReactiveController tests (Wave 2)
vivek7405 May 20, 2026
73f3ada
fix(core): align three lit divergences (until thenable, ref rebind, d…
vivek7405 May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 26 additions & 13 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ See `agent-docs/framework-dev.md` for monorepo commands, workspace layout, refer

An **AI-first, web-components-first** framework inspired by NextJs, Lit, and Rails.

**Why lit-style web components specifically?** AI coding agents have substantial training data on lit. Aligning webjs's component runtime API (reactive properties via `static properties`, lifecycle hooks like `shouldUpdate` / `willUpdate` / `updated` / `firstUpdated` / `updateComplete`, ReactiveController hooks `hostConnected` / `hostDisconnected` / `hostUpdate` / `hostUpdated`, the full `lit-html` directive set, `html` / `css` tagged templates) lets agents emit idiomatic webjs code without framework-specific translation. Webjs ships its own implementation under `packages/core/src/` (clean JSDoc-typed JS, no-build), but the public API surface matches lit so the ecosystem's collective lit knowledge transfers directly. Decorators are the one exception (banned by invariant 10, non-erasable TS); the `declare` + `static properties` pattern replaces them.

- **Sensible defaults, overridable.** Memory store in dev, Redis when configured. HTTP caching via standard `Cache-Control`.
- **Built-in essentials.** Auth, sessions, caching, cache store, rate limiting, all with pluggable adapters.
- **No build step.** Source files are served as native ES modules.
Expand Down Expand Up @@ -206,17 +208,22 @@ import { html, css, WebComponent, render, renderToString } from '@webjskit/core'

### Directives, from `import { … } from '@webjskit/core/directives'`

**"Less is more":** only directives that solve problems with no native alternative.
lit-html parity. AI agents writing lit-shaped directive code land on familiar names.

| Directive | Purpose | Example |
|---|---|---|
| `repeat(items, keyFn, templateFn)` | Keyed reconciliation | `${repeat(items, i => i.id, i => html\`…\`)}` |
| `repeat(items, keyFn, templateFn)` | Keyed list reconciliation | `${repeat(items, i => i.id, i => html\`…\`)}` |
| `unsafeHTML(str)` | Render trusted raw HTML. **NEVER use with user input.** | `${unsafeHTML(markdownToHtml(md))}` |
| `live(value)` | Input value sync against live DOM | `.value=${live(inputVal)}` |
| `keyed(key, template)` | Force remount on key change | `${keyed(this.userId, html\`<form>…</form>\`)}` |
| `guard(deps, fn)` | Memoize sub-template; client skips re-eval when deps unchanged | `${guard([this.title], () => html\`<h1>\${this.title}</h1>\`)}` |
| `templateContent(tpl)` | Render content of a `<template>` element | `${templateContent(this.shadowRoot.getElementById('tpl'))}` |
| `ref(refOrCallback)` + `createRef()` | Bind a Ref or callback to the element | `<input ${ref(this._inputRef)}>` |
| `cache(value)` | Retain detached DOM when toggling sub-templates (preserves input state, scroll, focus) | `${cache(this.active ? viewA : viewB)}` |
| `until(...args)` | Render highest-priority resolved candidate; higher-priority Promises that later resolve replace lower-priority output | `${until(this.dataPromise, html\`<p>Loading…</p>\`)}` |
| `asyncAppend(iter, mapper?)`, `asyncReplace(iter, mapper?)` | Stream values from an AsyncIterable. Iteration aborts on teardown. | `${asyncAppend(stream, (v, i) => html\`<li>\${v}</li>\`)}` |

Everything else uses native patterns: conditional classes via filter+join,
conditional render via ternary, async data via `Task` (component) or async
page functions (server).
For component-scoped async data with full pending/error states, `Task` is usually a better fit than `until`. For page-level streaming, `Suspense` is the structural primitive. Everything else (`classMap`, `styleMap`, `ifDefined`, `when`, `choose`, `map`, `join`, `range`) uses native patterns: conditional classes via filter+join, conditional render via ternary, etc.

### Context & Task

Expand Down Expand Up @@ -296,16 +303,22 @@ class StudentCard extends WebComponent {
| `hasChanged` | strict `!==` | Custom change detection |
| `converter` | type-based | Custom attribute ↔ property serialization |

### Lifecycle (less-is-more)
### Lifecycle (lit-aligned)

| Hook | When | Use for |
|---|---|---|
| controllers' `beforeRender()` | Before render | Pre-render logic |
| `render()` | Render phase | Return `TemplateResult` |
| controllers' `afterRender()` | After render | Post-render logic |
| `firstUpdated()` | After first render only | One-time DOM setup |
Every update cycle runs these hooks in order. All receive a `changedProperties` Map: keys are property names (or `'state'` for setState patches), values are the previous value before the change.

| # | Hook | When | Use for |
|---|---|---|---|
| 1 | `shouldUpdate(changedProperties)` | Update queued | Return `false` to skip this update. Default `true`. |
| 2 | `willUpdate(changedProperties)` | Pre-render | Compute derived values. Property assignments fold into THIS cycle without re-triggering. |
| 3 | controllers' `hostUpdate()` | Pre-render | Controller pre-render logic |
| 4 | `update(changedProperties)` | Render phase | Default calls `render()` + commits. Override to wrap or short-circuit (rare). |
| 5 | controllers' `hostUpdated()` | Post-render | Controller post-render logic |
| 6 | `firstUpdated(changedProperties)` | After first render only | One-time DOM setup |
| 7 | `updated(changedProperties)` | After every render | Post-render DOM work conditional on what changed |
| 8 | `updateComplete` Promise | Resolves last | `await el.updateComplete` after triggering an update |

No `shouldUpdate`/`willUpdate`/`updated`/`changedProperties`. Compute inputs at top of `render()`. Use `queueMicrotask()` after `setState()` for post-render side effects.
The `update()` body has an error boundary that calls `renderError(error)` if `render()` throws. All hooks are **client-only**; SSR doesn't call them (SSR walker calls `instance.render()` directly).

**ReactiveControllers** are composable lifecycle logic via `host.addController(this)`. Built-in `Task`, `ContextProvider`, `ContextConsumer` are all controllers. See `agent-docs/components.md`.

Expand Down
27 changes: 25 additions & 2 deletions agent-docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,29 @@ The `.d.ts` overlay shipped with the framework makes every other class
member fully typed, so only the reactive properties need the `declare`
line, and only in TypeScript files.

## Lifecycle hooks (lit-aligned)

`WebComponent` ships lit's full reactive lifecycle. Every update cycle runs these hooks in order; each receives a `changedProperties` Map (`Map<string, oldValue>`, where keys are property names or `'state'` for setState patches).

| # | Hook | When |
|---|---|---|
| 1 | `shouldUpdate(changedProperties)` | Return `false` to skip the update. Default `true`. |
| 2 | `willUpdate(changedProperties)` | Pre-render. Property assignments here fold into THIS cycle. |
| 3 | controllers' `hostUpdate()` | Pre-render controller hook |
| 4 | `update(changedProperties)` | Default calls `render()` + commits. Override to wrap or short-circuit (rare). |
| 5 | controllers' `hostUpdated()` | Post-render controller hook |
| 6 | `firstUpdated(changedProperties)` | Once, on the first render only |
| 7 | `updated(changedProperties)` | Every render commit. Right place for ad-hoc post-render DOM work. |
| 8 | `updateComplete` Promise resolves | `await el.updateComplete` to read post-render DOM in tests |

Assignments during `willUpdate` fold into the current cycle (no new render scheduled); assignments during `updated` or `firstUpdated` queue a fresh cycle. The framework gates this via an internal flag, so authors don't manage it.

All hooks are **client-only**. The SSR pipeline calls `instance.render()` directly and does not invoke `shouldUpdate` / `willUpdate` / `update` / `updated` / `firstUpdated` / `connectedCallback` / `disconnectedCallback`. Set SSR-meaningful defaults in the constructor; use lifecycle hooks for browser-only work.

`setState(patch)` still works and routes through the same machinery: the `changedProperties` Map gets a `'state'` entry whose old value is the previous state bag.

See [`/docs/lifecycle`](https://docs.webjs.com/docs/lifecycle) for per-hook usage examples.

## ReactiveControllers: composable lifecycle

```js
Expand All @@ -37,11 +60,11 @@ class FetchController {
this.data = null;
host.addController(this); // ← register
}
async onMount() {
async hostConnected() {
this.data = await (await fetch(this.url)).json();
this.host.requestUpdate();
}
onUnmount() { /* cleanup */ }
hostDisconnected() { /* cleanup */ }
}

class MyEl extends WebComponent {
Expand Down
18 changes: 10 additions & 8 deletions docs/app/docs/controllers/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export default function Controllers() {
<h1>Reactive Controllers</h1>
<p>Reactive controllers are a composition pattern for sharing lifecycle-bound logic across components without using inheritance. Instead of building mixin chains or base class hierarchies, you create standalone controller objects that hook into any component's lifecycle.</p>

<p><strong>Why the lit-shaped hook names?</strong> webjs adopts lit's <code>hostConnected</code> / <code>hostDisconnected</code> / <code>hostUpdate</code> / <code>hostUpdated</code> protocol verbatim because AI coding agents have substantial training data on lit. Matching lit's API names means agents emit idiomatic webjs code without framework-specific translation, and any lit ReactiveController found in the wild is drop-in compatible here.</p>

<h2>What Controllers Solve</h2>
<p>Consider a scenario where three different components all need to fetch data on connect, poll on an interval, and clean up on disconnect. Without controllers, your options are:</p>

Expand All @@ -22,10 +24,10 @@ export default function Controllers() {
<p>A controller is any object that implements some or all of these methods:</p>

<ul>
<li><strong>onMount()</strong>: called when the host component's <code>connectedCallback</code> fires. Set up subscriptions, timers, and event listeners here.</li>
<li><strong>onUnmount()</strong>: called when the host component's <code>disconnectedCallback</code> fires. Clean up resources.</li>
<li><strong>beforeRender()</strong>: called before the host's <code>render()</code> method. Pre-render controller logic.</li>
<li><strong>afterRender()</strong>: called after the host's <code>render()</code> method, before <code>firstUpdated()</code>. Post-render controller logic.</li>
<li><strong>hostConnected()</strong>: called when the host component's <code>connectedCallback</code> fires. Set up subscriptions, timers, and event listeners here.</li>
<li><strong>hostDisconnected()</strong>: called when the host component's <code>disconnectedCallback</code> fires. Clean up resources.</li>
<li><strong>hostUpdate()</strong>: called before the host's <code>render()</code> method. Pre-render controller logic.</li>
<li><strong>hostUpdated()</strong>: called after the host's <code>render()</code> method, before <code>firstUpdated()</code>. Post-render controller logic.</li>
</ul>

<p>All methods are optional. Implement only the ones your controller needs.</p>
Expand All @@ -42,7 +44,7 @@ export default function Controllers() {
host.addController(this); // register with the host
}

onMount() {
hostConnected() {
this._observer = new IntersectionObserver(
([entry]) =&gt; {
this.isVisible = entry.isIntersecting;
Expand All @@ -53,7 +55,7 @@ export default function Controllers() {
this._observer.observe(this.host);
}

onUnmount() {
hostDisconnected() {
this._observer?.disconnect();
this._observer = null;
}
Expand Down Expand Up @@ -97,7 +99,7 @@ LazyImage.register('lazy-image');</pre>
host.addController(this);
}

async onMount() {
async hostConnected() {
this.loading = true;
this.host.requestUpdate();

Expand All @@ -115,7 +117,7 @@ LazyImage.register('lazy-image');</pre>
}
}

onUnmount() {
hostDisconnected() {
// Could abort an in-flight request here if using AbortController
}
}</pre>
Expand Down
Loading