Skip to content

Commit 0511a34

Browse files
committed
v0.6.0 - Streaming actions, beam-cloak, auto-reconnect
- Streaming actions: async generator handlers push incremental updates over WebSocket (skeleton→content, progress, AI-style text, modals/drawers) - beam-cloak: hide elements until reactivity initializes (prevents flash) - Auto-reconnect: indefinite retry with stepped backoff (1s, 3s, 5s+) configurable via <meta name="beam-reconnect-interval"> - beam-disconnected: show element while WebSocket is disconnected - Upgrade capnweb to 0.6.1 for native ReadableStream support - beam-init: support expressions on child elements (not just beam-state root) - Fix Hono generic type constraint in beam.init() (Hono<E extends HonoEnv>) - README: document streaming, beam-cloak, auto-reconnect
1 parent dea2381 commit 0511a34

32 files changed

+3995
-1708
lines changed

README.md

Lines changed: 189 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,11 @@ A lightweight, declarative UI framework for building interactive web application
3333
- **Collapse** - Expand/collapse with text swap (no server)
3434
- **Class Toggle** - Toggle CSS classes on elements (no server)
3535
- **Reactive State** - Fine-grained reactivity for UI components (tabs, accordions, carousels)
36+
- **Cloak** - Hide elements until reactivity initializes (no flash of unprocessed content)
37+
- **Auto-Reconnect** - Automatically reconnects on WebSocket disconnect with configurable interval
3638
- **Multi-Render** - Update multiple targets in a single action response
3739
- **Async Components** - Full support for HonoX async components in `ctx.render()`
40+
- **Streaming Actions** - Async generator handlers push incremental updates over WebSocket (skeleton → content, live progress, AI-style text)
3841

3942
## Installation
4043

@@ -361,6 +364,105 @@ Async components are awaited automatically - no manual `Promise.resolve()` or he
361364

362365
---
363366

367+
### Streaming Actions
368+
369+
Turn any action into a streaming action by making it an **async generator** (`async function*`). Each `yield` pushes an update to the browser immediately — no waiting for the full response.
370+
371+
```tsx
372+
export async function* loadProfile(ctx: BeamContext<Env>, { id }: Record<string, unknown>) {
373+
// First yield: show skeleton immediately
374+
yield ctx.render(<div id="profile">Loading…</div>)
375+
376+
// Simulate slow API call
377+
await delay(1800)
378+
379+
const user = await db.getUser(id as string)
380+
381+
// Second yield: replace with real content
382+
yield ctx.render(
383+
<div id="profile">
384+
<h3>{user.name}</h3>
385+
<p>{user.role}</p>
386+
</div>
387+
)
388+
}
389+
```
390+
391+
Use it in HTML exactly like a regular action — no special attribute needed:
392+
393+
```html
394+
<button beam-action="loadProfile" beam-include="id">Load Profile</button>
395+
<div id="profile"></div>
396+
```
397+
398+
#### Patterns
399+
400+
**Skeleton → Content** — yield a skeleton immediately so the user sees feedback, then yield the real content once the data is ready:
401+
402+
```tsx
403+
export async function* loadProfile(ctx: BeamContext<Env>, { id }: Record<string, unknown>) {
404+
yield ctx.render(<SkeletonCard id="result" />)
405+
const user = await fetchUser(id)
406+
yield ctx.render(<UserCard id="result" user={user} />)
407+
}
408+
```
409+
410+
**Multi-step Progress** — yield after each step completes to show a live progress list:
411+
412+
```tsx
413+
export async function* runPipeline(ctx: BeamContext<Env>, _: Record<string, unknown>) {
414+
const steps = ['Validating', 'Fetching', 'Processing', 'Saving']
415+
const done: string[] = []
416+
417+
for (const step of steps) {
418+
yield ctx.render(<Pipeline done={done} current={step} pending={steps.slice(done.length + 1)} id="pipeline" />)
419+
await runStep(step)
420+
done.push(step)
421+
}
422+
423+
yield ctx.render(<Pipeline done={done} complete id="pipeline" />)
424+
}
425+
```
426+
427+
**AI-style Text Streaming** — accumulate text and re-render on each chunk for a typewriter effect:
428+
429+
```tsx
430+
export async function* streamText(ctx: BeamContext<Env>, _: Record<string, unknown>) {
431+
let text = ''
432+
for (const word of words) {
433+
text += (text ? ' ' : '') + word
434+
await delay(120)
435+
yield ctx.render(<p id="output">{text}<span class="cursor">▌</span></p>)
436+
}
437+
yield ctx.render(<p id="output">{text}</p>) // remove cursor
438+
}
439+
```
440+
441+
**Streaming into Modals and Drawers** — yield `ctx.modal()` or `ctx.drawer()` calls. The first yield opens the overlay; subsequent yields update its content:
442+
443+
```tsx
444+
export async function* openProfileModal(ctx: BeamContext<Env>, { id }: Record<string, unknown>) {
445+
yield ctx.modal(<SkeletonProfile />, { size: 'md' }) // opens modal with skeleton
446+
447+
const user = await fetchUser(id)
448+
449+
yield ctx.modal(<UserProfile user={user} />, { size: 'md' }) // swaps in real content
450+
}
451+
```
452+
453+
```html
454+
<button beam-modal="openProfileModal" beam-include="id">View Profile</button>
455+
```
456+
457+
#### How It Works
458+
459+
- A regular action handler returns a single value → one DOM update.
460+
- An async generator handler yields multiple values → one DOM update per yield, streamed over the WebSocket as each chunk is ready.
461+
- The client processes chunks in order; each chunk is a full `ActionResponse` (the same object a regular action returns).
462+
- No special HTML attributes are needed — the server detects async generators automatically.
463+
464+
---
465+
364466
## Attribute Reference
365467

366468
### Actions
@@ -541,6 +643,13 @@ return ctx.drawer(render(<MyDrawer />), { position: "left", size: "medium" });
541643
| `beam-offline-class` | Toggle class instead of visibility | `beam-offline-class="offline-warning"` |
542644
| `beam-offline-disable` | Disable element when offline | `beam-offline-disable` |
543645

646+
### Auto-Reconnect
647+
648+
| Element / Attribute | Description |
649+
| ---------------------------------------------------------------- | ------------------------------------------------- |
650+
| `<meta name="beam-reconnect-interval" content="5000">` | Fixed retry interval (ms) after initial backoff |
651+
| `beam-disconnected` | Show element while disconnected, hide on reconnect |
652+
544653
### Conditional Show/Hide
545654

546655
| Attribute | Description | Example |
@@ -585,6 +694,7 @@ Fine-grained reactivity for UI components (carousels, tabs, accordions) without
585694
| `beam-id` | Name the state for cross-component access | `beam-id="cart"` |
586695
| `beam-state-ref` | Reference a named state from elsewhere | `beam-state-ref="cart"` |
587696
| `beam-init` | Run JS expression once after state is initialized | `beam-init="setInterval(() => { index = (index+1) % total }, 3000)"` |
697+
| `beam-cloak` | Hide element until its reactive scope is ready | `<div beam-state="open: false" beam-cloak>` |
588698
| `beam-text` | Bind text content to expression | `beam-text="count"` |
589699
| `beam-attr-*` | Bind any attribute to expression | `beam-attr-disabled="count === 0"` |
590700
| `beam-show` | Show/hide element based on expression | `beam-show="open"` |
@@ -1215,6 +1325,59 @@ The `body` also gets `.beam-offline` class when disconnected.
12151325

12161326
---
12171327

1328+
## Auto-Reconnect
1329+
1330+
Beam automatically reconnects when the WebSocket drops (e.g. server restart, network blip). No configuration required — it works out of the box.
1331+
1332+
### Reconnect Schedule
1333+
1334+
| Attempt | Delay |
1335+
| ------- | ----- |
1336+
| 1st | 1s |
1337+
| 2nd | 3s |
1338+
| 3rd | 5s |
1339+
| 4th+ | 5s (configurable) |
1340+
1341+
After the first three stepped attempts, Beam retries indefinitely at a fixed interval until the connection is restored.
1342+
1343+
### Configuring the Interval
1344+
1345+
Override the fixed interval with a `<meta>` tag in your `<head>`:
1346+
1347+
```html
1348+
<!-- Retry every 10 seconds after the initial backoff steps -->
1349+
<meta name="beam-reconnect-interval" content="10000">
1350+
```
1351+
1352+
The value is in milliseconds. Values below 1000ms are ignored (minimum 1s).
1353+
1354+
### Disconnect Indicator
1355+
1356+
Show UI while disconnected using `beam-disconnected`:
1357+
1358+
```html
1359+
<div beam-disconnected style="display:none" class="banner">
1360+
Reconnecting...
1361+
</div>
1362+
```
1363+
1364+
The element is shown automatically on disconnect and hidden on reconnect. The `body` also receives the `.beam-disconnected` class while offline.
1365+
1366+
### Events
1367+
1368+
| Event | Fires when |
1369+
| ---------------------- | ----------------------------------- |
1370+
| `beam:disconnected` | WebSocket connection drops |
1371+
| `beam:reconnected` | Connection successfully restored |
1372+
1373+
```javascript
1374+
window.addEventListener('beam:reconnected', () => {
1375+
console.log('Back online!')
1376+
})
1377+
```
1378+
1379+
---
1380+
12181381
## Conditional Show/Hide
12191382

12201383
Toggle element visibility based on form field values (no server round-trip):
@@ -1506,6 +1669,29 @@ Fine-grained reactivity for UI components like carousels, tabs, accordions, and
15061669

15071670
**Note:** `beam-init` only works inside a `beam-state` scope — it has no effect on elements without a state ancestor.
15081671

1672+
#### beam-cloak — Prevent Flash of Unprocessed Content
1673+
1674+
`beam-cloak` hides an element until its reactive scope has fully initialized. Without it, elements with `beam-show` may briefly appear before Beam processes them.
1675+
1676+
Add it to the same element as `beam-state`. Beam removes the attribute automatically after setup.
1677+
1678+
```html
1679+
<!-- Without beam-cloak: the "Dropdown" content may flash visible on load -->
1680+
<!-- With beam-cloak: hidden until reactivity is ready, then shown correctly -->
1681+
<div beam-state="open: false" beam-cloak>
1682+
<button beam-state-toggle="open">Toggle</button>
1683+
<div beam-show="open">Dropdown content</div>
1684+
</div>
1685+
```
1686+
1687+
Beam ships a CSS rule that does the hiding:
1688+
1689+
```css
1690+
[beam-cloak] { display: none !important; }
1691+
```
1692+
1693+
This rule is included in `@benqoder/beam/styles`. If you are not importing the beam stylesheet, add it yourself.
1694+
15091695
#### Named State (Cross-Component)
15101696

15111697
Share state between different parts of the page. Named states (with `beam-id`) persist across server-driven updates:
@@ -1589,12 +1775,14 @@ import "@benqoder/beam/beam.css";
15891775
**After (Beam):**
15901776

15911777
```html
1592-
<div beam-state='{"open": false}'>
1778+
<div beam-state='{"open": false}' beam-cloak>
15931779
<button type="button" beam-state-toggle="open">Menu</button>
15941780
<div beam-show="open">Content</div>
15951781
</div>
15961782
```
15971783

1784+
> `beam-cloak` is the Beam equivalent of `x-cloak` — it hides the element until reactivity has initialized, preventing a flash of visible content.
1785+
15981786
**Before (Alpine.js dropdown):**
15991787

16001788
```html

dist/client.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ interface ActionResponse {
1818
};
1919
}
2020
interface BeamServer {
21-
call(action: string, data?: Record<string, unknown>): Promise<ActionResponse>;
21+
call(action: string, data?: Record<string, unknown>): ReadableStream<ActionResponse>;
2222
registerCallback(callback: (event: string, data: unknown) => void): Promise<void>;
2323
}
2424
type BeamServerStub = RpcStub<BeamServer>;

dist/client.d.ts.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)