Skip to content

Commit d3edcdf

Browse files
committed
feat(strategy): add hover and event-based island mounting
1 parent 27cc879 commit d3edcdf

12 files changed

Lines changed: 315 additions & 22 deletions

README.md

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ Control when each island mounts on the client:
193193
| `'load'` | Mount immediately (via microtask). **Default.** |
194194
| `'idle'` | Mount during idle time (`requestIdleCallback`, falls back to `setTimeout(…, 1)`) |
195195
| `'visible'` | Mount when the host element enters the viewport (`IntersectionObserver` with configurable `rootMargin`, default `200px`) |
196+
| `'hover'` | Mount on first `mouseover` or `focusin` on the host |
197+
| `'event'` | Mount on configured host events (`event` option or `data-fict-react-event`; defaults to `click`) |
196198
| `'only'` | Client-only rendering — no SSR, no hydration |
197199

198200
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`).
@@ -205,13 +207,14 @@ Wraps a React component as a Fict component. Props flow reactively from the Fict
205207

206208
**Options** (`ReactInteropOptions`):
207209

208-
| Option | Type | Default | Description |
209-
| ------------------- | ----------------- | --------- | --------------------------------------------- |
210-
| `ssr` | `boolean` | `true` | Server-side render the React subtree |
211-
| `client` | `ClientDirective` | `'load'` | Client mount strategy |
212-
| `visibleRootMargin` | `string` | `'200px'` | Margin for `'visible'` strategy |
213-
| `identifierPrefix` | `string` | `''` | React `useId` prefix for multi-root pages |
214-
| `actionProps` | `string[]` | `[]` | Additional callback prop names to materialize |
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+
| `actionProps` | `string[]` | `[]` | Additional callback prop names to materialize |
215218

216219
### `ReactIsland<P>(props)`
217220

@@ -265,20 +268,21 @@ Returns Vite plugins that scope the React JSX transform to a directory.
265268

266269
When using the loader or resumable mode, the following data attributes control island behavior:
267270

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

279283
`*` Changing the QRL disposes the current root and creates a new one.
280284

281-
Immutable attributes (`data-fict-react-client`, `data-fict-react-ssr`, `data-fict-react-prefix`) emit a warning in development if mutated at runtime. To change them, recreate the host element.
285+
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.
282286

283287
## Package Exports
284288

src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const DATA_FICT_REACT_PROPS = 'data-fict-react-props'
66
export const DATA_FICT_REACT_ACTION_PROPS = 'data-fict-react-action-props'
77
export const DATA_FICT_REACT_SSR = 'data-fict-react-ssr'
88
export const DATA_FICT_REACT_CLIENT = 'data-fict-react-client'
9+
export const DATA_FICT_REACT_EVENT = 'data-fict-react-event'
910
export const DATA_FICT_REACT_MOUNTED = 'data-fict-react-mounted'
1011
export const DATA_FICT_REACT_PREFIX = 'data-fict-react-prefix'
1112

src/eager.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,22 @@ import { renderToString } from 'react-dom/server'
66
import { materializeReactProps } from './action'
77
import {
88
DATA_FICT_REACT_CLIENT,
9+
DATA_FICT_REACT_EVENT,
910
DATA_FICT_REACT_HOST,
1011
DATA_FICT_REACT_MOUNTED,
1112
DATA_FICT_REACT_PREFIX,
1213
DATA_FICT_REACT_SSR,
1314
DEFAULT_CLIENT_DIRECTIVE,
1415
} from './constants'
16+
import { normalizeMountEvents } from './mount-events'
1517
import { mountReactRoot, type MountedReactRoot } from './react-root'
1618
import { scheduleByClientDirective } from './strategy'
1719
import type { ReactIslandProps, ReactInteropOptions } from './types'
1820

1921
interface NormalizedReactInteropOptions {
2022
client: NonNullable<ReactInteropOptions['client']>
2123
ssr: boolean
24+
events: string[]
2225
visibleRootMargin: string
2326
identifierPrefix: string
2427
actionProps: string[]
@@ -29,10 +32,12 @@ function normalizeOptions(options?: ReactInteropOptions): NormalizedReactInterop
2932
const actionProps = Array.from(
3033
new Set((options?.actionProps ?? []).map((name) => name.trim()).filter(Boolean)),
3134
)
35+
const events = normalizeMountEvents(options?.event)
3236

3337
return {
3438
client,
3539
ssr: client === 'only' ? false : options?.ssr !== false,
40+
events,
3641
visibleRootMargin: options?.visibleRootMargin ?? '200px',
3742
identifierPrefix: options?.identifierPrefix ?? '',
3843
actionProps,
@@ -93,6 +98,9 @@ function createReactHost<P extends Record<string, unknown>>(runtime: ReactHostRu
9398
if (normalized.identifierPrefix) {
9499
hostProps[DATA_FICT_REACT_PREFIX] = normalized.identifierPrefix
95100
}
101+
if (normalized.events.length > 0) {
102+
hostProps[DATA_FICT_REACT_EVENT] = normalized.events.join(',')
103+
}
96104

97105
if (isSSR && normalized.ssr) {
98106
const ssrNode = createReactElement(
@@ -139,6 +147,7 @@ function createReactHost<P extends Record<string, unknown>>(runtime: ReactHostRu
139147
}
140148

141149
mountCleanup = scheduleByClientDirective(normalized.client, host, mount, {
150+
events: normalized.events,
142151
visibleRootMargin: normalized.visibleRootMargin,
143152
})
144153
})
@@ -191,6 +200,9 @@ export function ReactIsland<P extends Record<string, unknown>>(props: ReactIslan
191200
if (props.actionProps !== undefined) {
192201
islandOptions.actionProps = props.actionProps
193202
}
203+
if (props.event !== undefined) {
204+
islandOptions.event = props.event
205+
}
194206

195207
return createReactHost({
196208
component: props.component,

src/loader.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import { loadLoaderComponentModule } from './component-module-loader'
55
import {
66
DATA_FICT_REACT_ACTION_PROPS,
77
DATA_FICT_REACT_CLIENT,
8+
DATA_FICT_REACT_EVENT,
89
DATA_FICT_REACT_MOUNTED,
910
DATA_FICT_REACT_PREFIX,
1011
DATA_FICT_REACT_PROPS,
1112
DATA_FICT_REACT_QRL,
1213
DATA_FICT_REACT_SSR,
1314
DEFAULT_CLIENT_DIRECTIVE,
1415
} from './constants'
16+
import { normalizeMountEvents } from './mount-events'
1517
import { parseQrl, resolveModuleUrl } from './qrl'
1618
import { mountReactRoot, type MountedReactRoot } from './react-root'
1719
import { decodePropsFromAttribute } from './serialization'
@@ -98,7 +100,14 @@ function warnImmutableAttrMutation(host: HTMLElement, attrName: string): void {
98100
}
99101

100102
function isClientDirective(value: string | null | undefined): value is ClientDirective {
101-
return value === 'load' || value === 'idle' || value === 'visible' || value === 'only'
103+
return (
104+
value === 'load' ||
105+
value === 'idle' ||
106+
value === 'visible' ||
107+
value === 'hover' ||
108+
value === 'event' ||
109+
value === 'only'
110+
)
102111
}
103112

104113
function pickClientDirective(host: HTMLElement, fallback: ClientDirective): ClientDirective {
@@ -162,6 +171,10 @@ function readIdentifierPrefix(host: HTMLElement): string | undefined {
162171
return value ?? undefined
163172
}
164173

174+
function readMountEvents(host: HTMLElement): string[] {
175+
return normalizeMountEvents(host.getAttribute(DATA_FICT_REACT_EVENT))
176+
}
177+
165178
function createIslandRuntime(
166179
host: HTMLElement,
167180
options: Required<ReactIslandsLoaderOptions>,
@@ -177,6 +190,7 @@ function createIslandRuntime(
177190
const client = pickClientDirective(host, options.defaultClient)
178191
const canHydrate = host.getAttribute(DATA_FICT_REACT_SSR) === '1' && client !== 'only'
179192
const identifierPrefix = readIdentifierPrefix(host)
193+
const mountEvents = readMountEvents(host)
180194

181195
let disposed = false
182196
let root: MountedReactRoot | null = null
@@ -274,8 +288,10 @@ function createIslandRuntime(
274288
document?: Document
275289
window?: Window
276290
visibleRootMargin?: string
291+
events?: string[]
277292
} = {
278293
document: host.ownerDocument,
294+
events: mountEvents,
279295
visibleRootMargin: options.visibleRootMargin,
280296
}
281297
const ownerWindow = host.ownerDocument.defaultView
@@ -378,7 +394,8 @@ export function installReactIslands(rawOptions: ReactIslandsLoaderOptions = {}):
378394
if (
379395
mutation.attributeName === DATA_FICT_REACT_CLIENT ||
380396
mutation.attributeName === DATA_FICT_REACT_SSR ||
381-
mutation.attributeName === DATA_FICT_REACT_PREFIX
397+
mutation.attributeName === DATA_FICT_REACT_PREFIX ||
398+
mutation.attributeName === DATA_FICT_REACT_EVENT
382399
) {
383400
if (runtimes.has(target)) {
384401
warnImmutableAttrMutation(target, mutation.attributeName)
@@ -415,6 +432,7 @@ export function installReactIslands(rawOptions: ReactIslandsLoaderOptions = {}):
415432
DATA_FICT_REACT_CLIENT,
416433
DATA_FICT_REACT_SSR,
417434
DATA_FICT_REACT_PREFIX,
435+
DATA_FICT_REACT_EVENT,
418436
],
419437
})
420438
}

src/mount-events.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
function pushEventNames(raw: string, out: string[]): void {
2+
const input = raw.trim()
3+
if (!input) return
4+
5+
if (input.startsWith('[')) {
6+
try {
7+
const parsed = JSON.parse(input) as unknown
8+
if (Array.isArray(parsed)) {
9+
for (const name of parsed) {
10+
if (typeof name !== 'string') continue
11+
const trimmed = name.trim()
12+
if (trimmed) out.push(trimmed)
13+
}
14+
return
15+
}
16+
} catch {
17+
// Fall through to comma parsing.
18+
}
19+
}
20+
21+
for (const part of input.split(',')) {
22+
const trimmed = part.trim()
23+
if (trimmed) out.push(trimmed)
24+
}
25+
}
26+
27+
export function normalizeMountEvents(value: string | string[] | null | undefined): string[] {
28+
if (value == null) return []
29+
30+
const values: string[] = []
31+
if (Array.isArray(value)) {
32+
for (const item of value) {
33+
if (typeof item !== 'string') continue
34+
pushEventNames(item, values)
35+
}
36+
} else if (typeof value === 'string') {
37+
pushEventNames(value, values)
38+
}
39+
40+
return Array.from(new Set(values))
41+
}

src/resumable.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { loadResumableComponentModule } from './component-module-loader'
88
import {
99
DATA_FICT_REACT_ACTION_PROPS,
1010
DATA_FICT_REACT_CLIENT,
11+
DATA_FICT_REACT_EVENT,
1112
DATA_FICT_REACT_HOST,
1213
DATA_FICT_REACT_MOUNTED,
1314
DATA_FICT_REACT_PREFIX,
@@ -16,6 +17,7 @@ import {
1617
DATA_FICT_REACT_SSR,
1718
DEFAULT_CLIENT_DIRECTIVE,
1819
} from './constants'
20+
import { normalizeMountEvents } from './mount-events'
1921
import { parseQrl, resolveModuleUrl } from './qrl'
2022
import { mountReactRoot, type MountedReactRoot } from './react-root'
2123
import { encodePropsForAttribute } from './serialization'
@@ -29,6 +31,7 @@ const COMPONENT_LOAD_RETRY_MAX_FAILURES = 5
2931
interface NormalizedReactInteropOptions {
3032
client: NonNullable<ReactInteropOptions['client']>
3133
ssr: boolean
34+
events: string[]
3235
visibleRootMargin: string
3336
identifierPrefix: string
3437
actionProps: string[]
@@ -41,10 +44,12 @@ function normalizeOptions(options?: ReactInteropOptions): NormalizedReactInterop
4144
const actionProps = Array.from(
4245
new Set((options?.actionProps ?? []).map((name) => name.trim()).filter(Boolean)),
4346
)
47+
const events = normalizeMountEvents(options?.event)
4448

4549
return {
4650
client,
4751
ssr: client === 'only' ? false : options?.ssr !== false,
52+
events,
4853
visibleRootMargin: options?.visibleRootMargin ?? '200px',
4954
identifierPrefix: options?.identifierPrefix ?? '',
5055
actionProps,
@@ -178,6 +183,9 @@ export function reactify$<P extends Record<string, unknown>>(
178183
JSON.stringify(normalized.actionProps),
179184
)
180185
}
186+
if (normalized.events.length > 0) {
187+
hostProps[DATA_FICT_REACT_EVENT] = normalized.events.join(',')
188+
}
181189

182190
if (isSSR && normalized.ssr && resolvedComponent) {
183191
const ssrNode = createReactElement(
@@ -258,6 +266,7 @@ export function reactify$<P extends Record<string, unknown>>(
258266
}
259267

260268
mountCleanup = scheduleByClientDirective(normalized.client, host, mount, {
269+
events: normalized.events,
261270
visibleRootMargin: normalized.visibleRootMargin,
262271
})
263272
})

src/strategy.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ interface ClientScheduleOptions {
55
document?: Document
66
window?: Window
77
visibleRootMargin?: string
8+
events?: string[]
89
}
910

1011
export function scheduleByClientDirective(
@@ -24,6 +25,26 @@ export function scheduleByClientDirective(
2425
mount()
2526
}
2627

28+
const bindHostEvents = (eventNames: readonly string[]) => {
29+
const names = eventNames.length > 0 ? eventNames : ['click']
30+
const listeners: { name: string; handler: EventListener }[] = []
31+
32+
for (const name of names) {
33+
const handler: EventListener = () => {
34+
runMount()
35+
}
36+
listeners.push({ name, handler })
37+
host.addEventListener(name, handler, { once: true })
38+
}
39+
40+
return () => {
41+
canceled = true
42+
for (const { name, handler } of listeners) {
43+
host.removeEventListener(name, handler)
44+
}
45+
}
46+
}
47+
2748
const scheduleLoad = () => {
2849
queueMicrotask(runMount)
2950
return () => {
@@ -86,6 +107,14 @@ export function scheduleByClientDirective(
86107
return () => observer.disconnect()
87108
}
88109

110+
if (strategy === 'hover') {
111+
return bindHostEvents(['mouseover', 'focusin'])
112+
}
113+
114+
if (strategy === 'event') {
115+
return bindHostEvents(options.events ?? [])
116+
}
117+
89118
runMount()
90119
return () => {}
91120
}

src/types.ts

Lines changed: 8 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' | 'only'
7+
export type ClientDirective = 'load' | 'idle' | 'visible' | 'hover' | 'event' | 'only'
88

99
export interface ReactInteropOptions {
1010
/**
@@ -17,6 +17,13 @@ export interface ReactInteropOptions {
1717
* @default 'load'
1818
*/
1919
client?: ClientDirective
20+
/**
21+
* Custom DOM event names that trigger mounting when `client: 'event'`.
22+
* String values accept comma-separated names.
23+
* @example 'click'
24+
* @example ['focusin', 'keydown']
25+
*/
26+
event?: string | string[]
2027
/**
2128
* Root margin for visible strategy.
2229
* @default '200px'

0 commit comments

Comments
 (0)