Skip to content

Commit 64cd2a9

Browse files
committed
feat(api): support configurable island host tagName
1 parent d982fe9 commit 64cd2a9

File tree

6 files changed

+108
-2
lines changed

6 files changed

+108
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ Wraps a React component as a Fict component. Props flow reactively from the Fict
214214
| `event` | `string \| string[]` || Event names for `client: 'event'` mounts |
215215
| `visibleRootMargin` | `string` | `'200px'` | Margin for `'visible'` strategy |
216216
| `identifierPrefix` | `string` | `''` | React `useId` prefix for multi-root pages |
217+
| `tagName` | `string` | `'div'` | Host element tag used by the island wrapper |
217218
| `actionProps` | `string[]` | `[]` | Additional callback prop names to materialize |
218219

219220
### `ReactIsland<P>(props)`

src/eager.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,15 @@ interface NormalizedReactInteropOptions {
2424
events: string[]
2525
visibleRootMargin: string
2626
identifierPrefix: string
27+
tagName: string
2728
actionProps: string[]
2829
}
2930

31+
function normalizeHostTagName(tagName: string | undefined): string {
32+
const normalized = tagName?.trim()
33+
return normalized && normalized.length > 0 ? normalized : 'div'
34+
}
35+
3036
function normalizeOptions(options?: ReactInteropOptions): NormalizedReactInteropOptions {
3137
const client = options?.client ?? DEFAULT_CLIENT_DIRECTIVE
3238
const actionProps = Array.from(
@@ -40,6 +46,7 @@ function normalizeOptions(options?: ReactInteropOptions): NormalizedReactInterop
4046
events,
4147
visibleRootMargin: options?.visibleRootMargin ?? '200px',
4248
identifierPrefix: options?.identifierPrefix ?? '',
49+
tagName: normalizeHostTagName(options?.tagName),
4350
actionProps,
4451
}
4552
}
@@ -165,7 +172,7 @@ function createReactHost<P extends Record<string, unknown>>(runtime: ReactHostRu
165172
}
166173

167174
return {
168-
type: 'div',
175+
type: normalized.tagName,
169176
props: hostProps,
170177
}
171178
}
@@ -197,6 +204,9 @@ export function ReactIsland<P extends Record<string, unknown>>(props: ReactIslan
197204
if (props.identifierPrefix !== undefined) {
198205
islandOptions.identifierPrefix = props.identifierPrefix
199206
}
207+
if (props.tagName !== undefined) {
208+
islandOptions.tagName = props.tagName
209+
}
200210
if (props.actionProps !== undefined) {
201211
islandOptions.actionProps = props.actionProps
202212
}

src/resumable.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,17 @@ interface NormalizedReactInteropOptions {
3434
events: string[]
3535
visibleRootMargin: string
3636
identifierPrefix: string
37+
tagName: string
3738
actionProps: string[]
3839
}
3940

4041
type ReactComponentModule = Record<string, unknown>
4142

43+
function normalizeHostTagName(tagName: string | undefined): string {
44+
const normalized = tagName?.trim()
45+
return normalized && normalized.length > 0 ? normalized : 'div'
46+
}
47+
4248
function normalizeOptions(options?: ReactInteropOptions): NormalizedReactInteropOptions {
4349
const client = options?.client ?? DEFAULT_CLIENT_DIRECTIVE
4450
const actionProps = Array.from(
@@ -52,6 +58,7 @@ function normalizeOptions(options?: ReactInteropOptions): NormalizedReactInterop
5258
events,
5359
visibleRootMargin: options?.visibleRootMargin ?? '200px',
5460
identifierPrefix: options?.identifierPrefix ?? '',
61+
tagName: normalizeHostTagName(options?.tagName),
5562
actionProps,
5663
}
5764
}
@@ -286,7 +293,7 @@ export function reactify$<P extends Record<string, unknown>>(
286293
}
287294

288295
return {
289-
type: 'div',
296+
type: normalized.tagName,
290297
props: hostProps,
291298
}
292299
}

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ export interface ReactInteropOptions {
3333
* Stable React identifier prefix for useId in multi-root pages.
3434
*/
3535
identifierPrefix?: string
36+
/**
37+
* Host element tag used by Fict wrappers (`reactify`, `ReactIsland`, `reactify$`).
38+
* @default 'div'
39+
*/
40+
tagName?: string
3641
/**
3742
* Additional prop names that should be treated as action callbacks.
3843
* By default only /^on[A-Z]/ props are materialized.

test/reactify.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,28 @@ describe('reactify', () => {
221221

222222
dispose()
223223
})
224+
225+
it('uses a custom host tag name when configured', async () => {
226+
const CustomHost = reactify(
227+
({ value }: { value: string }) => React.createElement('span', { id: 'custom-host' }, value),
228+
{
229+
tagName: 'section',
230+
},
231+
)
232+
233+
const container = document.createElement('div')
234+
document.body.appendChild(container)
235+
236+
const dispose = render(() => ({ type: CustomHost, props: { value: 'ok' } }), container)
237+
await tick()
238+
239+
const host = container.querySelector('[data-fict-react-host]') as HTMLElement | null
240+
expect(host).not.toBeNull()
241+
expect(host?.tagName).toBe('SECTION')
242+
expect(container.querySelector('#custom-host')?.textContent).toBe('ok')
243+
244+
dispose()
245+
})
224246
})
225247

226248
describe('ReactIsland', () => {
@@ -270,4 +292,33 @@ describe('ReactIsland', () => {
270292

271293
dispose()
272294
})
295+
296+
it('supports custom host tag name on ReactIsland', async () => {
297+
function Label(props: { text: string }) {
298+
return React.createElement('p', { id: 'island-custom-host' }, props.text)
299+
}
300+
301+
const container = document.createElement('div')
302+
document.body.appendChild(container)
303+
304+
const dispose = render(
305+
() => ({
306+
type: ReactIsland,
307+
props: {
308+
component: Label,
309+
tagName: 'article',
310+
props: { text: 'custom' },
311+
},
312+
}),
313+
container,
314+
)
315+
await tick()
316+
317+
const host = container.querySelector('[data-fict-react-host]') as HTMLElement | null
318+
expect(host).not.toBeNull()
319+
expect(host?.tagName).toBe('ARTICLE')
320+
expect(container.querySelector('#island-custom-host')?.textContent).toBe('custom')
321+
322+
dispose()
323+
})
273324
})

test/resumable.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,38 @@ describe('reactify$', () => {
297297
dispose()
298298
}
299299
})
300+
301+
it('uses a custom host tag name when configured', async () => {
302+
const fixtureModule = new URL('./fixtures/loader-component.ts', import.meta.url).href
303+
const Remote = reactify$<{ label: string; count: number }>({
304+
module: fixtureModule,
305+
export: 'LoaderComponent',
306+
ssr: false,
307+
tagName: 'section',
308+
})
309+
310+
const container = document.createElement('div')
311+
document.body.appendChild(container)
312+
313+
const dispose = render(
314+
() => ({
315+
type: Remote,
316+
props: { label: 'host-tag', count: 8 },
317+
}),
318+
container,
319+
)
320+
try {
321+
const host = container.querySelector('[data-fict-react]') as HTMLElement | null
322+
expect(host).not.toBeNull()
323+
expect(host?.tagName).toBe('SECTION')
324+
325+
await waitForExpectation(() => {
326+
expect(container.textContent).toContain('host-tag:8')
327+
})
328+
} finally {
329+
dispose()
330+
}
331+
})
300332
})
301333

302334
describe('installReactIslands', () => {

0 commit comments

Comments
 (0)