Skip to content

Commit 8800780

Browse files
committed
feat(strategy): add signal-based island mounting
1 parent 64cd2a9 commit 8800780

File tree

9 files changed

+285
-29
lines changed

9 files changed

+285
-29
lines changed

README.md

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ Control when each island mounts on the client:
195195
| `'visible'` | Mount when the host element enters the viewport (`IntersectionObserver` with configurable `rootMargin`, default `200px`) |
196196
| `'hover'` | Mount on first `mouseover` or `focusin` on the host |
197197
| `'event'` | Mount on configured host events (`event` option or `data-fict-react-event`; defaults to `click`) |
198+
| `'signal'` | Mount when a provided reactive accessor (`signal` option) becomes `true` |
198199
| `'only'` | Client-only rendering — no SSR, no hydration |
199200

200201
When `ssr` is `true` (the default), the React subtree is rendered to HTML on the server. On the client, the island hydrates (`hydrateRoot`) if SSR content is present, otherwise it creates a fresh root (`createRoot`).
@@ -207,15 +208,16 @@ Wraps a React component as a Fict component. Props flow reactively from the Fict
207208

208209
**Options** (`ReactInteropOptions`):
209210

210-
| Option | Type | Default | Description |
211-
| ------------------- | -------------------- | --------- | --------------------------------------------- |
212-
| `ssr` | `boolean` | `true` | Server-side render the React subtree |
213-
| `client` | `ClientDirective` | `'load'` | Client mount strategy |
214-
| `event` | `string \| string[]` || Event names for `client: 'event'` mounts |
215-
| `visibleRootMargin` | `string` | `'200px'` | Margin for `'visible'` strategy |
216-
| `identifierPrefix` | `string` | `''` | React `useId` prefix for multi-root pages |
217-
| `tagName` | `string` | `'div'` | Host element tag used by the island wrapper |
218-
| `actionProps` | `string[]` | `[]` | Additional callback prop names to materialize |
211+
| Option | Type | Default | Description |
212+
| ------------------- | -------------------------- | --------- | --------------------------------------------- |
213+
| `ssr` | `boolean` | `true` | Server-side render the React subtree |
214+
| `client` | `ClientDirective` | `'load'` | Client mount strategy |
215+
| `event` | `string \| string[]` || Event names for `client: 'event'` mounts |
216+
| `signal` | `boolean \| () => boolean` || Mount gate for `client: 'signal'` |
217+
| `visibleRootMargin` | `string` | `'200px'` | Margin for `'visible'` strategy |
218+
| `identifierPrefix` | `string` | `''` | React `useId` prefix for multi-root pages |
219+
| `tagName` | `string` | `'div'` | Host element tag used by the island wrapper |
220+
| `actionProps` | `string[]` | `[]` | Additional callback prop names to materialize |
219221

220222
### `ReactIsland<P>(props)`
221223

@@ -272,21 +274,22 @@ Returns Vite plugins that scope the React JSX transform to a directory.
272274

273275
When using the loader or resumable mode, the following data attributes control island behavior:
274276

275-
| Attribute | Mutable | Purpose |
276-
| ------------------------------ | ------- | -------------------------------------------------------------------------- |
277-
| `data-fict-react` | `*` | QRL pointing to the React component module |
278-
| `data-fict-react-props` | yes | URL-encoded serialized props |
279-
| `data-fict-react-action-props` | yes | URL-encoded JSON array of custom action prop names |
280-
| `data-fict-react-client` | no | Client strategy (`load` / `idle` / `visible` / `hover` / `event` / `only`) |
281-
| `data-fict-react-event` | no | Comma-separated mount events for `client="event"` |
282-
| `data-fict-react-ssr` | no | `'1'` if SSR content is present |
283-
| `data-fict-react-prefix` | no | React `useId` identifier prefix |
284-
| `data-fict-react-host` || Marks element as a React island host |
285-
| `data-fict-react-mounted` || Set to `'1'` after the island mounts |
277+
| Attribute | Mutable | Purpose |
278+
| ------------------------------ | ------- | ------------------------------------------------------------------------------------- |
279+
| `data-fict-react` | `*` | QRL pointing to the React component module |
280+
| `data-fict-react-props` | yes | URL-encoded serialized props |
281+
| `data-fict-react-action-props` | yes | URL-encoded JSON array of custom action prop names |
282+
| `data-fict-react-client` | no | Client strategy (`load` / `idle` / `visible` / `hover` / `event` / `signal` / `only`) |
283+
| `data-fict-react-event` | no | Comma-separated mount events for `client="event"` |
284+
| `data-fict-react-ssr` | no | `'1'` if SSR content is present |
285+
| `data-fict-react-prefix` | no | React `useId` identifier prefix |
286+
| `data-fict-react-host` || Marks element as a React island host |
287+
| `data-fict-react-mounted` || Set to `'1'` after the island mounts |
286288

287289
`*` Changing the QRL disposes the current root and creates a new one.
288290

289291
Immutable attributes (`data-fict-react-client`, `data-fict-react-ssr`, `data-fict-react-prefix`, `data-fict-react-event`) emit a warning in development if mutated at runtime. To change them, recreate the host element.
292+
`client="signal"` requires a runtime signal accessor and is therefore not supported by `installReactIslands` static mounting.
290293

291294
## Package Exports
292295

src/eager.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ interface NormalizedReactInteropOptions {
2222
client: NonNullable<ReactInteropOptions['client']>
2323
ssr: boolean
2424
events: string[]
25+
signal: (() => boolean) | null
2526
visibleRootMargin: string
2627
identifierPrefix: string
2728
tagName: string
@@ -33,6 +34,16 @@ function normalizeHostTagName(tagName: string | undefined): string {
3334
return normalized && normalized.length > 0 ? normalized : 'div'
3435
}
3536

37+
function normalizeSignalAccessor(signal: ReactInteropOptions['signal']): (() => boolean) | null {
38+
if (typeof signal === 'function') {
39+
return signal as () => boolean
40+
}
41+
if (typeof signal === 'boolean') {
42+
return () => signal
43+
}
44+
return null
45+
}
46+
3647
function normalizeOptions(options?: ReactInteropOptions): NormalizedReactInteropOptions {
3748
const client = options?.client ?? DEFAULT_CLIENT_DIRECTIVE
3849
const actionProps = Array.from(
@@ -44,6 +55,7 @@ function normalizeOptions(options?: ReactInteropOptions): NormalizedReactInterop
4455
client,
4556
ssr: client === 'only' ? false : options?.ssr !== false,
4657
events,
58+
signal: normalizeSignalAccessor(options?.signal),
4759
visibleRootMargin: options?.visibleRootMargin ?? '200px',
4860
identifierPrefix: options?.identifierPrefix ?? '',
4961
tagName: normalizeHostTagName(options?.tagName),
@@ -91,6 +103,7 @@ function createReactHost<P extends Record<string, unknown>>(runtime: ReactHostRu
91103
let host: HTMLElement | null = null
92104
let root: MountedReactRoot | null = null
93105
let mountCleanup: (() => void) | null = null
106+
let signalMount: (() => void) | null = null
94107
let latestProps = runtime.readProps()
95108

96109
const hostProps: Record<string, unknown> = {
@@ -120,6 +133,13 @@ function createReactHost<P extends Record<string, unknown>>(runtime: ReactHostRu
120133
if (!isSSR) {
121134
createEffect(() => {
122135
latestProps = runtime.readProps()
136+
137+
const shouldMountFromSignal =
138+
normalized.client === 'signal' ? Boolean(normalized.signal?.()) : false
139+
if (signalMount && shouldMountFromSignal) {
140+
signalMount()
141+
}
142+
123143
if (root) {
124144
root.render(
125145
createReactElement(
@@ -153,13 +173,22 @@ function createReactHost<P extends Record<string, unknown>>(runtime: ReactHostRu
153173
host.setAttribute(DATA_FICT_REACT_MOUNTED, '1')
154174
}
155175

156-
mountCleanup = scheduleByClientDirective(normalized.client, host, mount, {
157-
events: normalized.events,
158-
visibleRootMargin: normalized.visibleRootMargin,
159-
})
176+
signalMount = mount
177+
if (normalized.client === 'signal') {
178+
if (normalized.signal?.()) {
179+
mount()
180+
}
181+
mountCleanup = () => {}
182+
} else {
183+
mountCleanup = scheduleByClientDirective(normalized.client, host, mount, {
184+
events: normalized.events,
185+
visibleRootMargin: normalized.visibleRootMargin,
186+
})
187+
}
160188
})
161189

162190
onCleanup(() => {
191+
signalMount = null
163192
mountCleanup?.()
164193
mountCleanup = null
165194
root?.unmount()
@@ -213,6 +242,9 @@ export function ReactIsland<P extends Record<string, unknown>>(props: ReactIslan
213242
if (props.event !== undefined) {
214243
islandOptions.event = props.event
215244
}
245+
if (props.signal !== undefined) {
246+
islandOptions.signal = props.signal
247+
}
216248

217249
return createReactHost({
218250
component: props.component,

src/loader.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ function isDevRuntime(): boolean {
7979
}
8080

8181
const warnedImmutableAttrs = new WeakMap<HTMLElement, Set<string>>()
82+
const warnedSignalStrategyHosts = new WeakSet<HTMLElement>()
8283

8384
function warnImmutableAttrMutation(host: HTMLElement, attrName: string): void {
8485
if (!isDevRuntime()) return
@@ -99,13 +100,26 @@ function warnImmutableAttrMutation(host: HTMLElement, attrName: string): void {
99100
}
100101
}
101102

103+
function warnUnsupportedSignalStrategy(host: HTMLElement): void {
104+
if (!isDevRuntime()) return
105+
if (warnedSignalStrategyHosts.has(host)) return
106+
warnedSignalStrategyHosts.add(host)
107+
108+
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
109+
console.warn(
110+
'[fict/react] Client strategy "signal" is not supported by installReactIslands(); use runtime islands (reactify/reactify$) instead.',
111+
)
112+
}
113+
}
114+
102115
function isClientDirective(value: string | null | undefined): value is ClientDirective {
103116
return (
104117
value === 'load' ||
105118
value === 'idle' ||
106119
value === 'visible' ||
107120
value === 'hover' ||
108121
value === 'event' ||
122+
value === 'signal' ||
109123
value === 'only'
110124
)
111125
}
@@ -192,6 +206,10 @@ function createIslandRuntime(
192206
const identifierPrefix = readIdentifierPrefix(host)
193207
const mountEvents = readMountEvents(host)
194208

209+
if (client === 'signal') {
210+
warnUnsupportedSignalStrategy(host)
211+
}
212+
195213
let disposed = false
196214
let root: MountedReactRoot | null = null
197215
let component: ComponentType<Record<string, unknown>> | null = null

src/resumable.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ interface NormalizedReactInteropOptions {
3232
client: NonNullable<ReactInteropOptions['client']>
3333
ssr: boolean
3434
events: string[]
35+
signal: (() => boolean) | null
3536
visibleRootMargin: string
3637
identifierPrefix: string
3738
tagName: string
@@ -45,6 +46,16 @@ function normalizeHostTagName(tagName: string | undefined): string {
4546
return normalized && normalized.length > 0 ? normalized : 'div'
4647
}
4748

49+
function normalizeSignalAccessor(signal: ReactInteropOptions['signal']): (() => boolean) | null {
50+
if (typeof signal === 'function') {
51+
return signal as () => boolean
52+
}
53+
if (typeof signal === 'boolean') {
54+
return () => signal
55+
}
56+
return null
57+
}
58+
4859
function normalizeOptions(options?: ReactInteropOptions): NormalizedReactInteropOptions {
4960
const client = options?.client ?? DEFAULT_CLIENT_DIRECTIVE
5061
const actionProps = Array.from(
@@ -56,6 +67,7 @@ function normalizeOptions(options?: ReactInteropOptions): NormalizedReactInterop
5667
client,
5768
ssr: client === 'only' ? false : options?.ssr !== false,
5869
events,
70+
signal: normalizeSignalAccessor(options?.signal),
5971
visibleRootMargin: options?.visibleRootMargin ?? '200px',
6072
identifierPrefix: options?.identifierPrefix ?? '',
6173
tagName: normalizeHostTagName(options?.tagName),
@@ -119,6 +131,7 @@ export function reactify$<P extends Record<string, unknown>>(
119131
let host: HTMLElement | null = null
120132
let root: MountedReactRoot | null = null
121133
let mountCleanup: (() => void) | null = null
134+
let signalMount: (() => void) | null = null
122135
let active = true
123136

124137
let resolvedComponent: ComponentType<P> | null = options.component ?? null
@@ -207,6 +220,12 @@ export function reactify$<P extends Record<string, unknown>>(
207220
latestProps = copyProps(rawProps)
208221
syncSerializedPropsToHost()
209222

223+
const shouldMountFromSignal =
224+
normalized.client === 'signal' ? Boolean(normalized.signal?.()) : false
225+
if (signalMount && shouldMountFromSignal) {
226+
signalMount()
227+
}
228+
210229
if (root && resolvedComponent) {
211230
root.render(
212231
createReactElement(
@@ -272,14 +291,23 @@ export function reactify$<P extends Record<string, unknown>>(
272291
})
273292
}
274293

275-
mountCleanup = scheduleByClientDirective(normalized.client, host, mount, {
276-
events: normalized.events,
277-
visibleRootMargin: normalized.visibleRootMargin,
278-
})
294+
signalMount = mount
295+
if (normalized.client === 'signal') {
296+
if (normalized.signal?.()) {
297+
mount()
298+
}
299+
mountCleanup = () => {}
300+
} else {
301+
mountCleanup = scheduleByClientDirective(normalized.client, host, mount, {
302+
events: normalized.events,
303+
visibleRootMargin: normalized.visibleRootMargin,
304+
})
305+
}
279306
})
280307

281308
onCleanup(() => {
282309
active = false
310+
signalMount = null
283311
mountCleanup?.()
284312
mountCleanup = null
285313
clearRetryTimer()

src/strategy.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ interface ClientScheduleOptions {
66
window?: Window
77
visibleRootMargin?: string
88
events?: string[]
9+
signal?: (() => boolean) | null
910
}
1011

1112
export function scheduleByClientDirective(
@@ -115,6 +116,26 @@ export function scheduleByClientDirective(
115116
return bindHostEvents(options.events ?? [])
116117
}
117118

119+
if (strategy === 'signal') {
120+
const readSignal = options.signal
121+
if (!readSignal) {
122+
return () => {
123+
canceled = true
124+
}
125+
}
126+
127+
const tryMountFromSignal = () => {
128+
if (canceled || mounted) return
129+
if (!readSignal()) return
130+
runMount()
131+
}
132+
133+
tryMountFromSignal()
134+
return () => {
135+
canceled = true
136+
}
137+
}
138+
118139
runMount()
119140
return () => {}
120141
}

src/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { ComponentType } from 'react'
44
/**
55
* Controls when the React island mounts on the client.
66
*/
7-
export type ClientDirective = 'load' | 'idle' | 'visible' | 'hover' | 'event' | 'only'
7+
export type ClientDirective = 'load' | 'idle' | 'visible' | 'hover' | 'event' | 'signal' | 'only'
88

99
export interface ReactInteropOptions {
1010
/**
@@ -24,6 +24,11 @@ export interface ReactInteropOptions {
2424
* @example ['focusin', 'keydown']
2525
*/
2626
event?: string | string[]
27+
/**
28+
* Mount signal used when `client: 'signal'`.
29+
* Supports plain booleans and reactive accessors (for example `createSignal(false)`).
30+
*/
31+
signal?: MaybeAccessor<boolean>
2732
/**
2833
* Root margin for visible strategy.
2934
* @default '200px'

0 commit comments

Comments
 (0)