Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
699e734
feat(server): treat async render() components as interactive in elision
Jun 10, 2026
a8fa854
feat(core): isolate a failed component render at SSR (#469)
Jun 11, 2026
2c487f9
feat(core): await async render() on the client (#469)
Jun 11, 2026
52a7e07
docs: document async render() across AGENTS + agent-docs (#469)
Jun 11, 2026
033c2a1
docs: async render() in the docs site (lifecycle, components, ssr, er…
Jun 11, 2026
03d3210
docs(cli): carry the async-render data-fetching rule into scaffold co…
Jun 11, 2026
42f24fe
fix(core): drop a stale async render superseded by a sync render (#469)
Jun 11, 2026
2d17b7a
fix(core): two async-render edge cases (updateComplete + shadow error…
Jun 11, 2026
2f78ba1
feat(core): <webjs-suspense> element-level streaming boundary (#471)
Jun 11, 2026
a416405
feat(core): progressive soft-nav streaming in the client router (#473)
Jun 11, 2026
be42c6d
fix(core): delimit the streamed shell at </html>, not the boundary ma…
Jun 11, 2026
37ca80f
fix(server): flush a shell-ready sentinel so soft-nav streaming is pr…
Jun 11, 2026
65de620
docs: document <webjs-suspense> streaming + progressive soft-nav (#47…
Jun 11, 2026
20f0c74
docs(site): add the data-fetching decision guide + streaming pages (#…
Jun 11, 2026
97052b0
docs: streaming in agent-docs (advanced/recipes/testing) + website co…
Jun 11, 2026
e170e8b
docs: add the async-data MCP prompt + renderFallback type declaration…
Jun 11, 2026
2a89287
test(mcp): include fetch_data_in_component in the prompt-set assertion
Jun 11, 2026
efc891c
test: dogfood async render + <webjs-suspense> on a real blog route (#…
Jun 11, 2026
e92760e
test(e2e): real-app coverage for async render + streaming + soft-nav …
Jun 11, 2026
4f135cc
fix(core): a soft-nav streamed boundary settles to the same DOM as in…
Jun 11, 2026
78a9f70
fix(core): converge the core stream swap to replaceWith; fix two stal…
Jun 11, 2026
dcd2309
docs: correct three innerHTML->replaceWith comment drifts from the sw…
Jun 11, 2026
2e5748f
test(core): guard updateComplete resolves on a throwing supersede
Jun 12, 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
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,8 @@ The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server
| `notFound()` / `redirect(url[, status])` | Throw to return 404, or a redirect. No-status default is convention-picked at the catching site: 302 for a GET page-render gate, 307 (method-preserving) for a server-action redirect. Override with `redirect(url, 308)` or `redirect(url, { status })`. |
| `expose(p, fn)` | Tag a server action ALSO reachable at a REST path. Server-side only. |
| `validateInput(fn, validate)` | Attach an input validator running on BOTH the RPC and `expose()` REST paths (#245). Server-side only. See Server actions. |
| `Suspense({fallback, children})` | Streaming boundary. `repeat` keyed-list directive is also re-exported. |
| `Suspense({fallback, children})` | Page/region-level streaming boundary (a value in a hole). `repeat` keyed-list directive is also re-exported. |
| `<webjs-suspense .fallback=${html\`…\`}>` | Component-level streaming boundary element (#471): wraps one or more components, flushes `.fallback` on the first byte, streams the resolved content in (concurrently across boundaries, progressively on soft nav). The renderer-recognized opt-in for SLOW async-render data. |
| `connectWS(url, handlers)` / `richFetch<T>` | Client WebSocket (auto-reconnect, queued sends); content-negotiated rich-type fetch. |
| `navigate(url, opts?)` / `revalidate(url?)` | Programmatic client-router nav; evict the BROWSER snapshot cache. |
| `optimistic(signal, value, action)` | Set `signal` immediately, run `action`, roll back on error or `{ success: false }`. |
Expand Down Expand Up @@ -196,6 +197,8 @@ MyThing.register('my-thing');

**Lifecycle (lit-aligned), in order:** `shouldUpdate`, `willUpdate`, controllers' `hostUpdate()`, `update` (calls `render()` + commits), controllers' `hostUpdated()`, `firstUpdated`, `updated`, `updateComplete`, each receiving a `changedProperties` Map. **SSR runs only the constructor, attribute application, the pre-render hooks (`willUpdate` / `hostUpdate`), `reflect: true` reflection, and `render()`; it does NOT call `connectedCallback`, `firstUpdated`, `updated`, or any browser-only hook.** So defaults for first paint go in the constructor; browser-only data (localStorage, viewport, `navigator.*`) goes in `connectedCallback` writing a signal; server-known data arrives via the page function. Never ship a placeholder first paint that fetches in `connectedCallback`. A browser-only global in the constructor/`render()` throws at SSR (flagged by `no-browser-globals-in-render`; attribute methods and `closest()` are shimmed).

**Async render (`async render()`), bare-await data fetch (#469).** A component may write `async render() { const u = await getUser(this.id); return html\`<h3>${u.name}</h3>\`; }`. Writing `await` makes the function async by JS rule, and every render path awaits a promise-returning `render()` automatically (no flag). This co-locates the fetch in the leaf component (no prop-drilling). The model is decoupled into three separate concerns. (1) **SSR always blocks**, so the resolved DATA is in the first paint with no fallback markup (PE-safe, JS-off reads it). (2) **The client re-fetch default is stale-while-revalidate**: when a prop / dependency change re-runs `async render()`, the current content stays until the new render resolves (no blank, no flash). (3) **`renderFallback()` is the OPTIONAL re-fetch loading UI**, a prop-aware method shown ONLY during a client re-fetch, NEVER on the first paint, and it does NOT trigger SSR streaming. **Errors are isolated per component by default** (no user code): a thrown `await getData()` renders a component-scoped error state while siblings render, and `renderError()` optionally customizes it (dev surfaces the message, prod stays silent). `getData()` is already isomorphic (a `'use server'` action is the real function during SSR and an RPC stub on the client), so the same line works both sides. Use `async render()` for request-time-known SERVER data that should be in the first paint; keep `Task` / signals for genuinely client-only data (a `Task` shows its pending state at SSR, losing first-paint data). An async-render component is always shipped (never elided). **For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`…\`}>` to STREAM it** (the fallback flushes on the first byte, the data streams in; multiple boundaries fetch concurrently). This is the only way to show a first-paint fallback, a deliberate choice for slow regions, and it streams progressively on soft navigation too. A throwing component inside a boundary is isolated (renders its error state, siblings stream). (A hydration-seed optimization that would also skip the redundant client re-fetch on load is deferred, #472; stale-while-revalidate already hides it.)

**Light DOM (default) vs Shadow DOM.** Light DOM applies global CSS and Tailwind directly (default; for Tailwind/global CSS + simple composition). Shadow DOM (`static shadow = true`) is for `static styles` scoped CSS and third-party isolation; `<slot>` works in either. A light-DOM component authoring custom CSS MUST prefix every class selector with its tag name (invariant 7); prefer Tailwind. Install the `webjs` VSCode extension (`packages/editors/vscode`, VS Marketplace + Open VSX; also covers Cursor / Antigravity / Windsurf) or `webjs.nvim` (`packages/editors/nvim`, via lazy.nvim) for template highlighting + editor intelligence with no Lit plugin, or add the standalone `@webjsdev/intellisense` to `tsconfig.json` `plugins` manually (JetBrains). Full deep-dive in `agent-docs/components.md` + `agent-docs/lit-muscle-memory-gotchas.md`.

---
Expand Down
19 changes: 19 additions & 0 deletions agent-docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@ fallback flushes immediately, and the resolved content streams in as a
`<template>` + inline `__webjsResolve('id')` script when the promise
lands. Nested Suspense supported.

### Component-level streaming: `<webjs-suspense>` (#471)

`Suspense({ fallback, children })` above is the page/region-level primitive (a promise passed as `children`). With **async render** (`agent-docs/components.md`), a COMPONENT is the suspending unit: a component doing `async render() { const u = await getUser(this.uid); … }` BLOCKS the first byte by default (real data in the first paint). To STREAM a slow component, wrap it in the renderer-recognized `<webjs-suspense>` element:

```js
html`
<webjs-suspense .fallback=${html`<p>Loading section…</p>`}>
<user-profile uid="42"></user-profile>
<user-activity uid="42"></user-activity>
</webjs-suspense>
`;
```

`.fallback` is read at SSR as the inline placeholder (`render-server.js`'s `processSuspenseElements` carries it via `data-webjs-fallback`, since a `TemplateResult` is not serializer-safe) and flushed on the first byte; the children push to `ctx.pending` and stream in via the same `<template data-webjs-resolve>` engine. Multiple boundaries resolve via `Promise.all`, so they fetch concurrently (no server waterfall). One boundary groups several components under one fallback (the boundary `.fallback` wins over a contained component's `renderFallback()`), and a throwing component inside is isolated to its own error state while siblings stream. Without a streaming context (`renderToString`) the children render inline (blocking).

### Progressive soft-nav streaming (#473)

On a client-router navigation to a streamed page, the router applies the response PROGRESSIVELY: the SSR stream flushes the shell (with fallbacks) plus a `<!--wj-stream-shell-->` sentinel, the router's `readStreamedShell` swaps the shell in immediately and advances the URL, then `streamBoundariesProgressively` applies each resolved boundary into the live DOM as it streams (fast-before-slow), upgrading the custom elements inside. So a soft nav matches the initial-load experience instead of buffering the whole response. A non-streaming page is read to completion and applied once; a navigation superseded mid-stream stops and cancels the reader; a mid-stream transport failure leaves the applied boundaries in place (non-destructive).

## First-paint performance without a build step

Five stacked zero-build optimizations:
Expand Down
86 changes: 86 additions & 0 deletions agent-docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,92 @@ For component-local state, create an instance signal in the constructor and call

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

## Async render: bare-await data fetch (#469)

A component can fetch its own server data into the first paint. `render()` may be `async`, so you write the natural line directly:

```ts
class UserProfile extends WebComponent {
static properties = { uid: { type: String } };
declare uid: string;
async render() {
const u = await getUser(this.uid); // a 'use server' action: real fn at SSR, RPC stub on the client
return html`<h3>${u.name}</h3>`;
}
}
UserProfile.register('user-profile');
```

Writing `await` makes the function async by the JS rule, and every render path awaits a promise-returning `render()` automatically. There is no flag. Plain sync `render()` stays the zero-cost default.

**Three concerns, decoupled. Do not conflate them.**

1. **SSR always blocks by default.** The server awaits `async render()`, so the resolved DATA is baked into the first paint. There is no fallback on first paint, ever. JS-off reads the data (a progressive-enhancement UPGRADE over a client-fetched `Task`, which shows nothing without JS).
2. **The client re-fetch default is stale-while-revalidate.** When a prop / dependency change re-runs `async render()`, the previously rendered content stays until the new render resolves. No blank, no flash, no user code.
3. **`renderFallback()` is the OPTIONAL re-fetch loading UI.** Define it to OVERRIDE the stale-while-revalidate default with a loading state (skeleton / spinner) shown DURING a client re-fetch. It is shown ONLY on a re-fetch, NEVER on the first paint, and it does NOT create a server-streaming boundary. It is a prop-aware method (not a static field), so it can branch on the component's current state.

```ts
class UserActivity extends WebComponent {
static properties = { uid: { type: String } };
declare uid: string;
renderFallback() { return html`<div class="skeleton h-24"></div>`; } // shown only while a re-fetch is in flight
async render() {
const items = await getActivity(this.uid);
return html`<ul>${items.map((i) => html`<li>${i.label}</li>`)}</ul>`;
}
}
```

**Errors are isolated per component by default, no user code.** A thrown `await getData()` (or any render throw) renders a component-scoped error state while its siblings render normally; it never bubbles to the route `error.js`. The default surfaces the tag and message in dev and renders a silent empty element in prod (no leak). Override `renderError(error)` only to customize the error UI:

```ts
class Report extends WebComponent {
async render() { return html`<pre>${await getReport()}</pre>`; }
renderError(error) { return html`<p class="error">Could not load the report.</p>`; } // optional
}
```

`Task` and a signal cannot replace this: a `Task` renders its pending state at SSR (it loses the first-paint data), and you cannot wrap a signal around your own `await` inside `render()`. So `async render()` plus `renderFallback()` is the only way to get SSR-first-paint data AND a custom re-fetch loading state.

**Decision rules (which tool to reach for):**

1. Server data knowable at request time: fetch it IN the component with `async render()`. Co-located, no prop-drilling, data in the first paint. The default, simplest case.
2. Client re-fetch where stale content would mislead: add `renderFallback()` for a loading state during the re-fetch.
3. Genuinely CLIENT-ONLY data (depends on a click, viewport, localStorage, or live updates, and does NOT need to be in the first paint): use `Task` / signals plus an RPC action.
4. Slow server data where blocking the first byte hurts: stream it. Wrap the component in `<webjs-suspense .fallback=${html\`…\`}>` to flush the fallback on the first byte and stream the data in (the only way to show a first-paint fallback; concurrent across boundaries; progressive on soft nav). Do it deliberately for slow regions, not by default.

**Anti-patterns (likely footguns):**

- Do NOT prop-drill server data through layers when the leaf component can fetch it itself.
- Do NOT put `await getData()` in a page / layout function if it can live in a component (page / layout fetches run sequentially, a route-level waterfall).
- Do NOT fetch in `connectedCallback` / `Task` for data that is knowable server-side (that yields a fallback-then-RPC, not first-paint data).
- Do NOT expect `renderFallback()` to affect the first paint (it is the CLIENT re-fetch loading state).
- Do NOT add `renderError()` on every component (isolation is automatic).

**How it works.** On the server the SSR walker already awaits a promise-returning `render()` and bakes the data in; a throw is caught per component and rendered as the error state. On the client, `update()` detects a thenable from `render()` and routes to a stale-while-revalidate commit: the current DOM stays until the promise resolves, a monotonic render token drops a superseded resolution (an out-of-order fetch never commits stale DOM), and a rejection routes to `renderError()`. `firstUpdated` / `updated` / `updateComplete` fire after the async commit lands. Only signal reads BEFORE the first `await` establish reactive dependencies. A component with an `async render()` is always shipped to the browser (never elided as display-only).

### Streaming a slow region with `<webjs-suspense>` (#471)

`async render()` BLOCKS the first byte by default (the SSR HTML waits for the data). For a SLOW region where that wait hurts time-to-first-byte, wrap it in `<webjs-suspense>` to stream it: the fallback flushes immediately, the resolved content streams in.

```ts
html`
<webjs-suspense .fallback=${html`<p>Loading section…</p>`}>
<user-profile uid="42"></user-profile>
<user-activity uid="42"></user-activity>
</webjs-suspense>
`;
```

- **The fallback is on the first byte, the content streams in.** This is the ONLY way a fallback appears on the first paint. It is a deliberate choice for slow data (fast TTFB, accepting a fallback-then-content swap), exactly like page-level `Suspense`. PE-critical content stays unwrapped (blocking) so it is in the first paint with no JS.
- **Grouping + override.** One boundary wraps several components under ONE fallback. The boundary `.fallback` wins over a contained component's `renderFallback()`.
- **Concurrent.** Multiple `<webjs-suspense>` boundaries on a page fetch their data in parallel (each is a non-blocking boundary resolved together), so they stream fast-before-slow with no server waterfall.
- **Error-isolated.** A throwing component inside a boundary renders its component-scoped error state (via `renderError()` or the default) while its siblings stream normally.
- **Progressive on soft navigation (#473).** A client-router navigation to a streamed page applies the shell (with fallbacks) immediately, advances the URL, then streams each boundary in, matching the initial-load experience instead of buffering the whole response.
- **`.fallback` is special-cased at SSR:** the renderer reads it inline as the placeholder, never through the `data-webjs-prop-*` path (a `TemplateResult` is not serializer-safe). It must be an UNQUOTED property hole (`.fallback=${...}`, invariant 4).

Decision: a bare `async render()` for request-time data that should be in the first paint (the default); `<webjs-suspense>` ONLY when the data is slow enough that blocking the first byte is the worse tradeoff.

## Display-only components are elided from the browser

A component that does no client-side work renders the same SSR'd HTML
Expand Down
27 changes: 26 additions & 1 deletion agent-docs/lit-muscle-memory-gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,32 @@ client then renders the resolved state, causing a flash.

`Task` is still useful for client-time async (interaction-triggered
mutations, polling, websocket reactions). For initial-paint data, fetch
in the page function instead.
in the page function OR in the component itself with an `async render()`
(see the next entry), which Lit does not have.

### 2b. Lit has no async render; webjs does (#469)

Lit keeps `render()` synchronous (its async-SSR work signals a promise the
renderer awaits BEFORE a still-sync render). webjs lets `render()` itself be
`async`, so you write `const u = await getUser(this.id)` directly in the
component and SSR bakes the resolved data into the first paint. Three things
trip up Lit muscle memory:

- **SSR blocks by default. Streaming is NOT automatic.** A bare `async
render()` (no wrapper) renders real data in the first paint with no
fallback. There is no skeleton flash. To STREAM slow data (fallback on
first byte), wrap the region in `<webjs-suspense .fallback=${html`…`}>`.
Do not reach for a fallback expecting it to show on first load; the
unwrapped default is block-real-data, and streaming is the deliberate
opt-in for slow regions.
- **`renderFallback()` is NOT a first-paint concern.** It is the OPTIONAL
client re-fetch loading UI, shown only when a prop / dependency change
re-runs `async render()`, never on the first paint. The first-paint and
re-fetch defaults are both no-flash: SSR has the data, and a re-fetch keeps
the stale content (stale-while-revalidate) until the new render resolves.
- **You do not need an error boundary.** A thrown `await getData()` is
isolated to that component automatically (siblings render, the page does
not blank). Add `renderError()` only to customize the error UI.

### 3. Browser-only APIs in the constructor or `render()`

Expand Down
42 changes: 42 additions & 0 deletions agent-docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,48 @@ export class HelloWorld extends WebComponent {
HelloWorld.register('hello-world');
```

## Fetch data in a component (async render, #469)

A leaf component fetches its own server data into the first paint, co-located, with no prop-drilling. Make `render()` async and call a `'use server'` action directly (real fn at SSR, RPC stub on the client). SSR awaits it, so the data is in the first paint.

```ts
// (a) blocking async render: real data in the first paint, the common case
class UserProfile extends WebComponent {
static properties = { uid: { type: String } };
declare uid: string;
async render() {
const u = await getUser(this.uid);
return html`<h3>${u.name}</h3>`;
}
}
UserProfile.register('user-profile');

// (b) stream a SLOW region (fallback on the first byte, content streams in)
html`
<webjs-suspense .fallback=${html`<p>Loading…</p>`}>
<user-profile uid="42"></user-profile>
</webjs-suspense>
`;

// (c) renderFallback() = the CLIENT re-fetch loading state (never the first paint)
class UserActivity extends WebComponent {
static properties = { uid: { type: String } };
declare uid: string;
renderFallback() { return html`<div class="skeleton h-24"></div>`; }
async render() {
const items = await getActivity(this.uid);
return html`<ul>${items.map((i) => html`<li>${i.label}</li>`)}</ul>`;
}
}

// (d) errors are isolated by default: NO renderError() needed (add it only to customize)
class Report extends WebComponent {
async render() { return html`<pre>${await getReport()}</pre>`; }
}
```

The client re-fetch default is stale-while-revalidate (the current content stays until the new render resolves). Use `<webjs-suspense>` only for genuinely slow data; keep `Task` / signals for client-only data that should NOT be in the first paint. Full decision guide in `agent-docs/components.md` and the `data-fetching` doc page.

## Form mutation with server-side validation (no JS required)

This is webjs's progressive-enhancement write-path. A `<form method="POST">`
Expand Down
Loading
Loading