Skip to content

Commit 27c7fc3

Browse files
committed
Auto-target by beam-id; normalize server HTML
Switch auto-targeting to use beam-id / beam-item-id (remove plain id auto-detection) and update docs/examples accordingly. Add normalizeHtmlForTarget to unwrap a single-server-root that matches the target's beam-id/beam-item-id to avoid nesting, and propagate that normalization into swap/append/prepend/replace/morph flows. Extend morph to accept a MorphStyle option (innerHTML|outerHTML) and use outerHTML when morphing deduped items. Update TypeScript types and README examples to reflect beam-id usage and clarify target resolution behavior.
1 parent 27a223d commit 27c7fc3

File tree

3 files changed

+54
-20
lines changed

3 files changed

+54
-20
lines changed

README.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ Use `beam-include` to collect values from input elements and include them in act
144144
beam-data-source="form"
145145
beam-target="#result"
146146
>Save</button>
147+
148+
<div id="result"></div>
147149
```
148150

149151
The action receives merged params with proper type conversion:
@@ -251,15 +253,15 @@ export function refreshDashboard(ctx: BeamContext<Env>) {
251253
}
252254
```
253255

254-
**2. Auto-detect by ID (no targets needed)**
256+
**2. Auto-detect by beam-id / beam-item-id (no targets needed)**
255257

256258
```tsx
257259
export function refreshDashboard(ctx: BeamContext<Env>) {
258-
// Client automatically finds elements by id, beam-id, or beam-item-id
260+
// Client automatically finds elements by beam-id or beam-item-id
259261
return ctx.render([
260-
<div id="stats">Visits: {visits}</div>,
261-
<div id="users">Users: {users}</div>,
262-
<div id="revenue">Revenue: ${revenue}</div>,
262+
<div beam-id="stats">Visits: {visits}</div>,
263+
<div beam-id="users">Users: {users}</div>,
264+
<div beam-id="revenue">Revenue: ${revenue}</div>,
263265
])
264266
}
265267
```
@@ -271,7 +273,7 @@ export function updateDashboard(ctx: BeamContext<Env>) {
271273
return ctx.render(
272274
[
273275
<div>Header content</div>, // Uses explicit target
274-
<div id="content">Main content</div>, // Auto-detected by ID
276+
<div beam-id="content">Main content</div>, // Auto-detected by beam-id
275277
],
276278
{ target: '#header' } // Only first item gets explicit target
277279
)
@@ -280,10 +282,15 @@ export function updateDashboard(ctx: BeamContext<Env>) {
280282

281283
**Target Resolution Order:**
282284
1. Explicit target from comma-separated list (by index)
283-
2. ID from the HTML fragment's root element (`id`, `beam-id`, or `beam-item-id`)
285+
2. Identity from the HTML fragment's root element (`beam-id` or `beam-item-id`)
284286
3. Frontend fallback (`beam-target` on the triggering element)
285287
4. Skip if no target found
286288

289+
Notes:
290+
- `beam-target` accepts any valid CSS selector (e.g. `#id`, `.class`, `[attr=value]`). Using `#id` targets is still fully supported.
291+
- Auto-targeting (step 2) intentionally does **not** use plain `id="..."` anymore; it uses only `beam-id` / `beam-item-id`.
292+
- 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.
293+
287294
**Exclusion:** Use `!selector` to explicitly skip an item:
288295
```tsx
289296
ctx.render(

src/client.ts

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -221,12 +221,41 @@ function $$(selector: string): NodeListOf<Element> {
221221
return document.querySelectorAll(selector)
222222
}
223223

224-
function morph(target: Element, html: string, options?: { keepElements?: string[] }): void {
224+
type MorphStyle = 'innerHTML' | 'outerHTML'
225+
226+
function normalizeHtmlForTarget(target: Element, html: string): string {
227+
const temp = document.createElement('div')
228+
temp.innerHTML = html.trim()
229+
230+
// If server sent a wrapper that matches the target element, unwrap it.
231+
// This avoids nesting <div beam-id="x"> inside <div beam-id="x"> and matches
232+
// the intuitive expectation: targeting an element updates its *inner* content.
233+
if (temp.childElementCount !== 1) return html
234+
const root = temp.firstElementChild
235+
if (!root) return html
236+
237+
const targetBeamId = target.getAttribute('beam-id')
238+
const rootBeamId = root.getAttribute('beam-id')
239+
if (targetBeamId && rootBeamId && targetBeamId === rootBeamId) return root.innerHTML
240+
241+
const targetBeamItemId = target.getAttribute('beam-item-id')
242+
const rootBeamItemId = root.getAttribute('beam-item-id')
243+
if (targetBeamItemId && rootBeamItemId && targetBeamItemId === rootBeamItemId) return root.innerHTML
244+
245+
return html
246+
}
247+
248+
function morph(
249+
target: Element,
250+
html: string,
251+
options?: { keepElements?: string[]; style?: MorphStyle }
252+
): void {
225253
const keepSelectors = options?.keepElements || []
254+
const morphStyle: MorphStyle = options?.style || 'innerHTML'
226255

227256
// @ts-ignore - idiomorph types
228257
Idiomorph.morph(target, html, {
229-
morphStyle: 'innerHTML',
258+
morphStyle,
230259
callbacks: {
231260
// Skip morphing elements marked with beam-keep (preserves their current value)
232261
// This only applies when both old and new DOM have a matching element
@@ -560,7 +589,7 @@ function dedupeItems(target: Element, html: string): string {
560589
// Morph existing item with fresh data
561590
const existing = target.querySelector(`[beam-item-id="${id}"]`)
562591
if (existing) {
563-
morph(existing, el.outerHTML)
592+
morph(existing, el.outerHTML, { style: 'outerHTML' })
564593
}
565594
// Remove from incoming HTML (already updated in place)
566595
el.remove()
@@ -572,25 +601,26 @@ function dedupeItems(target: Element, html: string): string {
572601

573602
function swap(target: Element, html: string, mode: string, trigger?: HTMLElement): void {
574603
const { main, oob } = parseOobSwaps(html)
604+
const normalizedMain = normalizeHtmlForTarget(target, main)
575605

576606
switch (mode) {
577607
case 'append':
578608
trigger?.remove()
579-
target.insertAdjacentHTML('beforeend', dedupeItems(target, main))
609+
target.insertAdjacentHTML('beforeend', dedupeItems(target, normalizedMain))
580610
break
581611
case 'prepend':
582612
trigger?.remove()
583-
target.insertAdjacentHTML('afterbegin', dedupeItems(target, main))
613+
target.insertAdjacentHTML('afterbegin', dedupeItems(target, normalizedMain))
584614
break
585615
case 'replace':
586-
target.innerHTML = main
616+
target.innerHTML = normalizedMain
587617
break
588618
case 'delete':
589619
target.remove()
590620
break
591621
case 'morph':
592622
default:
593-
morph(target, main)
623+
morph(target, normalizedMain)
594624
break
595625
}
596626

@@ -658,17 +688,14 @@ function handleHtmlResponse(
658688
console.warn(`[beam] Target "${explicitTarget}" not found on page, skipping`)
659689
}
660690
} else {
661-
// Priority 3: id, beam-id, or beam-item-id on root element
691+
// Priority 3: beam-id or beam-item-id on root element
662692
const temp = document.createElement('div')
663693
temp.innerHTML = htmlItem.trim()
664694
const rootEl = temp.firstElementChild
665695

666-
// Check id first, then beam-id, then beam-item-id
667-
const id = rootEl?.id
668696
const beamId = rootEl?.getAttribute('beam-id')
669697
const beamItemId = rootEl?.getAttribute('beam-item-id')
670-
const selector = id ? `#${id}`
671-
: beamId ? `[beam-id="${beamId}"]`
698+
const selector = beamId ? `[beam-id="${beamId}"]`
672699
: beamItemId ? `[beam-item-id="${beamItemId}"]`
673700
: null
674701

src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export interface BeamContext<TEnv = object> {
5353
* Accepts JSX directly (converts to string), single string, or array for multi-target rendering.
5454
* @example ctx.render(<ProductList />, { script: 'playSound("ding")' })
5555
* @example ctx.render([<StatsWidget />, <NotificationList />], { target: '#stats, #notifications' })
56-
* @example ctx.render([<div id="stats">...</div>, <div id="notifications">...</div>]) // auto-detects targets by ID
56+
* @example ctx.render([<div beam-id="stats">...</div>, <div beam-id="notifications">...</div>]) // auto-detects targets by beam-id / beam-item-id
5757
*/
5858
render(
5959
content: string | Promise<string> | (string | Promise<string>)[],

0 commit comments

Comments
 (0)