Skip to content

Commit e7b063f

Browse files
committed
release: v0.6.2
1 parent bed3bcc commit e7b063f

32 files changed

+2637
-429
lines changed

README.md

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ 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+
- **Server State Updates** - Update named client state from server actions without swapping DOM
3637
- **Cloak** - Hide elements until reactivity initializes (no flash of unprocessed content)
3738
- **Auto-Reconnect** - Automatically reconnects on WebSocket disconnect with configurable interval
3839
- **Multi-Render** - Update multiple targets in a single action response
@@ -273,7 +274,7 @@ export function refreshDashboard(ctx: BeamContext<Env>) {
273274

274275
```tsx
275276
export function refreshDashboard(ctx: BeamContext<Env>) {
276-
// Client automatically finds elements by beam-id or beam-item-id
277+
// Client automatically finds elements by stable identity
277278
return ctx.render([
278279
<div beam-id="stats">Visits: {visits}</div>,
279280
<div beam-id="users">Users: {users}</div>,
@@ -306,7 +307,9 @@ export function updateDashboard(ctx: BeamContext<Env>) {
306307
Notes:
307308

308309
- `beam-target` accepts any valid CSS selector (e.g. `#id`, `.class`, `[attr=value]`). Using `#id` targets is still fully supported.
309-
- Auto-targeting (step 2) intentionally does **not** use plain `id="..."` anymore; it uses only `beam-id` / `beam-item-id`.
310+
- Auto-targeting (step 2) uses stable element identity from the returned HTML root: `beam-id`, `beam-item-id`, or `id`.
311+
- Auto-targeting intentionally does **not** use input `name` values, because they are often not unique enough for safe DOM replacement.
312+
- Prefer `beam-id` for named UI regions and `beam-item-id` for repeated/list items. Use plain `id` when that already matches your markup structure.
310313
- When an explicit target is used and the server returns a single root element that has the same `beam-id`/`beam-item-id` as the target, Beam unwraps it and swaps only the target’s inner content. This prevents accidentally nesting the component inside itself.
311314

312315
**Exclusion:** Use `!selector` to explicitly skip an item:
@@ -940,6 +943,21 @@ Use `beam-keep` to prevent an element from being replaced during updates. This k
940943

941944
Since the input isn't replaced, focus and cursor position are naturally preserved.
942945

946+
Beam matches kept elements by stable identity in this order:
947+
948+
- `beam-id`
949+
- `beam-item-id`
950+
- `id`
951+
- unique form control `name` as a best-effort fallback
952+
953+
For the most reliable behavior, give kept elements a `beam-id`, `beam-item-id`, or `id`. If multiple inputs share the same `name`, Beam will not preserve by `name` alone.
954+
955+
Use cases:
956+
957+
- `beam-id`: shared UI regions like badges, panels, counters, and named state scopes
958+
- `beam-item-id`: repeated records in feeds, tables, carts, and paginated lists
959+
- `id`: one-off DOM anchors when the element already has a stable page-level ID
960+
943961
### Auto-Save on Blur
944962

945963
Trigger action when the user leaves the field:
@@ -1158,13 +1176,13 @@ Polling automatically stops when the element is removed from the DOM.
11581176

11591177
## Hungry Elements
11601178

1161-
Elements marked with `beam-hungry` automatically update whenever any action returns HTML with a matching ID:
1179+
Elements marked with `beam-hungry` automatically update whenever any action returns HTML with matching stable identity:
11621180

11631181
```html
1164-
<!-- This badge updates when any action returns #cart-count -->
1165-
<span id="cart-count" beam-hungry>0</span>
1182+
<!-- This badge updates when any action returns beam-id="cart-count" -->
1183+
<span beam-id="cart-count" beam-hungry>0</span>
11661184

1167-
<!-- Clicking this updates both #cart-result AND #cart-count -->
1185+
<!-- Clicking this updates both #cart-result AND the hungry badge -->
11681186
<button beam-action="addToCart" beam-target="#cart-result">Add to Cart</button>
11691187
```
11701188

@@ -1175,12 +1193,20 @@ export function addToCart(c) {
11751193
<>
11761194
<div>Item added to cart!</div>
11771195
{/* This updates the hungry element */}
1178-
<span id="cart-count">{cartCount}</span>
1196+
<span beam-id="cart-count">{cartCount}</span>
11791197
</>
11801198
);
11811199
}
11821200
```
11831201

1202+
Hungry matching checks identity in this order:
1203+
1204+
- `beam-id`
1205+
- `beam-item-id`
1206+
- `id`
1207+
1208+
For shared UI regions, prefer `beam-id`. For repeated records, prefer `beam-item-id`.
1209+
11841210
---
11851211

11861212
## Out-of-Band Updates
@@ -1708,6 +1734,54 @@ Share state between different parts of the page. Named states (with `beam-id`) p
17081734

17091735
**Note:** Named states persist when the DOM is updated by `beam-action`. This means reactive state is preserved even when server actions update the page.
17101736

1737+
#### Server-Driven Named State Updates
1738+
1739+
Server actions can update existing named state directly, without returning HTML. This is useful when the UI already has a `beam-state` scope and you only want to change its data.
1740+
1741+
Use `ctx.state(id, value)` for a single state update:
1742+
1743+
```tsx
1744+
export function refreshCart(ctx: BeamContext<Env>) {
1745+
return ctx.state("cart", {
1746+
count: 3,
1747+
total: 29.99,
1748+
});
1749+
}
1750+
```
1751+
1752+
Use `ctx.state({ ... })` to update multiple named states in one response:
1753+
1754+
```tsx
1755+
export function syncDashboard(ctx: BeamContext<Env>) {
1756+
return ctx.state({
1757+
cart: { count: 3, total: 29.99 },
1758+
notifications: 7,
1759+
});
1760+
}
1761+
```
1762+
1763+
```html
1764+
<div beam-state="count: 0; total: 0" beam-id="cart">
1765+
Cart: <span beam-text="count"></span> items
1766+
Total: $<span beam-text="total.toFixed(2)"></span>
1767+
</div>
1768+
1769+
<div beam-state="0" beam-id="notifications">
1770+
Notifications: <span beam-text="notifications"></span>
1771+
</div>
1772+
1773+
<button beam-action="refreshCart">Refresh Cart</button>
1774+
<button beam-action="syncDashboard">Sync Everything</button>
1775+
```
1776+
1777+
Behavior:
1778+
1779+
- Updates target existing named states by `beam-id`
1780+
- Object payloads shallow-merge into object state
1781+
- Primitive payloads replace simple named values
1782+
- Elements using `beam-state-ref` update automatically
1783+
- Missing state IDs are ignored with a console warning
1784+
17111785
#### JavaScript API
17121786

17131787
Access reactive state programmatically:
@@ -1726,6 +1800,9 @@ beam.batch(() => {
17261800
state.a = 1;
17271801
state.b = 2;
17281802
});
1803+
1804+
// Apply a named state update programmatically
1805+
beam.updateState("cart", { count: 4, total: 39.99 });
17291806
```
17301807

17311808
#### Standalone Usage (No Beam Server)
@@ -2447,12 +2524,14 @@ Both patterns work the same way:
24472524
When restoring from cache, the server still renders fresh Page 1 data. To sync fresh data with cached items, add `beam-item-id` to your list items:
24482525
24492526
```tsx
2450-
<!-- Any list item with a unique identifier -->
2527+
<!-- Any repeated list item with a stable unique identifier -->
24512528
<div class="product-card" beam-item-id={product.id}>...</div>
24522529
<div class="comment" beam-item-id={comment.id}>...</div>
24532530
<article beam-item-id={post.slug}>...</article>
24542531
```
24552532
2533+
Use `beam-item-id` for repeated records instead of plain `id`. It is purpose-built for deduplication, cache restore, and append/prepend freshness.
2534+
24562535
On back navigation:
24572536
24582537
1. Cache is restored (all loaded items + scroll position)

dist/client.d.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { type RpcStub } from 'capnweb';
22
interface ActionResponse {
33
html?: string | string[];
4+
state?: Record<string, unknown>;
45
script?: string;
56
redirect?: string;
67
target?: string;
@@ -22,10 +23,58 @@ interface BeamServer {
2223
registerCallback(callback: (event: string, data: unknown) => void): Promise<void>;
2324
}
2425
type BeamServerStub = RpcStub<BeamServer>;
26+
type HtmlApplyStyle = 'innerHTML' | 'outerHTML';
27+
declare function applyHtml(target: Element, html: string, options?: {
28+
keepElements?: string[];
29+
style?: HtmlApplyStyle;
30+
}): void;
31+
declare function swap(target: Element, html: string, mode: string, trigger?: HTMLElement): void;
32+
/**
33+
* Handle HTML response - supports both single string and array of HTML strings.
34+
* Target resolution order (server wins, frontend is fallback):
35+
* 1. Server target from comma-separated list (by index)
36+
* - Use "!selector" to exclude that selector (blocks frontend fallback too)
37+
* 2. Frontend target (beam-target) as fallback for remaining items
38+
* 3. ID from HTML fragment's root element
39+
* 4. Skip if none found
40+
*/
41+
declare function handleHtmlResponse(response: ActionResponse, frontendTarget: string | null, frontendSwap: string, trigger?: HTMLElement): void;
42+
declare function parseOobSwaps(html: string): {
43+
main: string;
44+
oob: Array<{
45+
selector: string;
46+
content: string;
47+
swapMode: string;
48+
}>;
49+
};
50+
declare function applyStateResponse(stateUpdates: Record<string, unknown>): void;
51+
/**
52+
* Apply a single ActionResponse chunk to the DOM.
53+
* Returns true if the response was a redirect (caller should stop processing).
54+
*/
55+
declare function applyResponse(response: ActionResponse, frontendTarget: string | null, frontendSwap: string, trigger?: HTMLElement): boolean;
56+
declare function handleHistory(el: HTMLElement): void;
57+
declare function openModal(html: string, size?: string, spacing?: number): void;
2558
declare function closeModal(): void;
59+
declare function openDrawer(html: string, position?: string, size?: string, spacing?: number): void;
2660
declare function closeDrawer(): void;
2761
declare function showToast(message: string, type?: 'success' | 'error'): void;
62+
declare function setupSwitch(el: HTMLElement): void;
63+
declare function setupAutosubmit(form: HTMLFormElement): void;
64+
declare function getScrollStateKey(action: string): string;
65+
declare function saveScrollState(targetSelector: string, action: string): void;
66+
declare function restoreScrollState(): boolean;
2867
declare function clearCache(action?: string): void;
68+
declare function setupValidation(el: HTMLElement): void;
69+
declare function castValue(value: unknown, castType: string | null): unknown;
70+
declare function checkWatchCondition(el: HTMLElement, value: unknown): boolean;
71+
declare function createThrottle(fn: () => void, limit: number): () => void;
72+
declare function setupInputWatcher(el: Element): void;
73+
declare function startPolling(el: HTMLElement): void;
74+
declare function stopPolling(el: HTMLElement): void;
75+
declare function processHungryElements(html: string): void;
76+
declare function isFormDirty(form: HTMLFormElement): boolean;
77+
declare function setupDirtyTracking(form: HTMLFormElement): void;
2978
interface CallOptions {
3079
target?: string;
3180
swap?: string;
@@ -36,7 +85,9 @@ declare function manualReconnect(): Promise<BeamServerStub>;
3685
declare const beamUtils: {
3786
getState: (elOrId: Element | string) => object | undefined;
3887
batch: (fn: () => void) => void;
88+
updateState: (id: string, value: unknown) => boolean;
3989
init: () => void;
90+
scan: (root?: ParentNode) => void;
4091
showToast: typeof showToast;
4192
closeModal: typeof closeModal;
4293
closeDrawer: typeof closeDrawer;
@@ -47,6 +98,39 @@ declare const beamUtils: {
4798
reconnect: typeof manualReconnect;
4899
getSession: () => Promise<BeamServerStub>;
49100
};
101+
export declare const __beamClientInternals: {
102+
api: {
103+
call(action: string, data?: Record<string, unknown>): Promise<ReadableStream<ActionResponse>>;
104+
getSession(): Promise<BeamServerStub>;
105+
};
106+
applyHtml: typeof applyHtml;
107+
swap: typeof swap;
108+
handleHtmlResponse: typeof handleHtmlResponse;
109+
parseOobSwaps: typeof parseOobSwaps;
110+
applyStateResponse: typeof applyStateResponse;
111+
applyResponse: typeof applyResponse;
112+
handleHistory: typeof handleHistory;
113+
openModal: typeof openModal;
114+
closeModal: typeof closeModal;
115+
openDrawer: typeof openDrawer;
116+
closeDrawer: typeof closeDrawer;
117+
setupSwitch: typeof setupSwitch;
118+
setupAutosubmit: typeof setupAutosubmit;
119+
getScrollStateKey: typeof getScrollStateKey;
120+
saveScrollState: typeof saveScrollState;
121+
restoreScrollState: typeof restoreScrollState;
122+
clearCache: typeof clearCache;
123+
processHungryElements: typeof processHungryElements;
124+
castValue: typeof castValue;
125+
checkWatchCondition: typeof checkWatchCondition;
126+
createThrottle: typeof createThrottle;
127+
setupInputWatcher: typeof setupInputWatcher;
128+
startPolling: typeof startPolling;
129+
stopPolling: typeof stopPolling;
130+
setupValidation: typeof setupValidation;
131+
setupDirtyTracking: typeof setupDirtyTracking;
132+
isFormDirty: typeof isFormDirty;
133+
};
50134
type ActionCaller = (data?: Record<string, unknown>, options?: string | CallOptions) => Promise<ActionResponse>;
51135
declare global {
52136
interface Window {

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)