@live-react-islands/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/IndividualIslandRenderer.d.ts +4 -0
- package/dist/IndividualIslandRenderer.d.ts.map +1 -0
- package/dist/IslandRenderer.d.ts +11 -0
- package/dist/IslandRenderer.d.ts.map +1 -0
- package/dist/PortalIslandsRenderer.d.ts +4 -0
- package/dist/PortalIslandsRenderer.d.ts.map +1 -0
- package/dist/PortalIslandsRenderer.test.d.ts +2 -0
- package/dist/PortalIslandsRenderer.test.d.ts.map +1 -0
- package/dist/client/IndividualIslandRenderer.d.ts +14 -0
- package/dist/client/IndividualIslandRenderer.d.ts.map +1 -0
- package/dist/client/PortalIslandsRenderer.d.ts +4 -0
- package/dist/client/PortalIslandsRenderer.d.ts.map +1 -0
- package/dist/client/islandsManager.d.ts +23 -0
- package/dist/client/islandsManager.d.ts.map +1 -0
- package/dist/client.d.ts +23 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/context.d.ts +7 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/createHooks.d.ts +25 -0
- package/dist/createHooks.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.esm.js +680 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +684 -0
- package/dist/index.js.map +1 -0
- package/dist/island-manager.d.ts +59 -0
- package/dist/island-manager.d.ts.map +1 -0
- package/dist/islandsManager.d.ts +32 -0
- package/dist/islandsManager.d.ts.map +1 -0
- package/dist/islandsManager.test.d.ts +2 -0
- package/dist/islandsManager.test.d.ts.map +1 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/setupTests.d.ts +2 -0
- package/dist/setupTests.d.ts.map +1 -0
- package/dist/ssr.d.ts +15 -0
- package/dist/ssr.d.ts.map +1 -0
- package/dist/ssr.esm.js +92 -0
- package/dist/ssr.esm.js.map +1 -0
- package/dist/ssr.js +94 -0
- package/dist/ssr.js.map +1 -0
- package/dist/types.d.ts +65 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/useForm.d.ts +60 -0
- package/dist/useForm.d.ts.map +1 -0
- package/dist/useIslandStream.d.ts +15 -0
- package/dist/useIslandStream.d.ts.map +1 -0
- package/dist/useStream.d.ts +9 -0
- package/dist/useStream.d.ts.map +1 -0
- package/package.json +66 -0
- package/src/IndividualIslandRenderer.tsx +80 -0
- package/src/PortalIslandsRenderer.test.tsx +304 -0
- package/src/PortalIslandsRenderer.tsx +118 -0
- package/src/context.ts +9 -0
- package/src/createHooks.ts +341 -0
- package/src/index.ts +4 -0
- package/src/islandsManager.test.ts +454 -0
- package/src/islandsManager.ts +356 -0
- package/src/setupTests.ts +1 -0
- package/src/ssr.ts +118 -0
- package/src/types.ts +91 -0
- package/src/useForm.ts +253 -0
- package/src/useStream.ts +36 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import IslandsManager, { Manager } from "./islandsManager";
|
|
2
|
+
import {
|
|
3
|
+
type IslandsMap,
|
|
4
|
+
type SSRStrategy,
|
|
5
|
+
type PushEventFn,
|
|
6
|
+
type ContextProviderComponent,
|
|
7
|
+
type IslandComponent,
|
|
8
|
+
type LazyIslandFactory,
|
|
9
|
+
type StreamHandle,
|
|
10
|
+
NullContextProvider,
|
|
11
|
+
StreamAction,
|
|
12
|
+
} from "./types";
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Types
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
interface LiveViewHook {
|
|
19
|
+
el: HTMLElement;
|
|
20
|
+
liveSocket: {
|
|
21
|
+
on: (event: string, callback: (e: Event) => void) => void;
|
|
22
|
+
};
|
|
23
|
+
pushEvent: (
|
|
24
|
+
event: string,
|
|
25
|
+
payload: any,
|
|
26
|
+
callback?: (reply: any, ref: any) => void
|
|
27
|
+
) => void;
|
|
28
|
+
pushEventTo: (target: string, event: string, payload: any) => void;
|
|
29
|
+
handleEvent: (event: string, callback: (data: any) => void) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CreateHooksOptions {
|
|
33
|
+
islands: IslandsMap;
|
|
34
|
+
SharedContextProvider?: ContextProviderComponent;
|
|
35
|
+
manager?: Manager;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Event Handler Adapter
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
function createPushEventFn(hook: LiveViewHook): PushEventFn {
|
|
43
|
+
return (eventName: string, payload: any) => {
|
|
44
|
+
hook.pushEventTo(`#${hook.el.id}`, eventName, payload);
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Helper
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
const resolvedComponentsCache = new Map<string, IslandComponent>();
|
|
53
|
+
|
|
54
|
+
const isLazyFactory = (value: unknown): value is LazyIslandFactory => {
|
|
55
|
+
if (typeof value !== "function") return false;
|
|
56
|
+
// Check if it's a React component (has prototype.isReactComponent for class components)
|
|
57
|
+
if ((value as any).prototype?.isReactComponent) return false;
|
|
58
|
+
// Check if it's a config object getter (would have Component property)
|
|
59
|
+
if ((value as any).Component) return false;
|
|
60
|
+
// Assume zero-arg functions that aren't class components are lazy factories
|
|
61
|
+
return value.length === 0;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const resolveIslandConfig = async (
|
|
65
|
+
islandsMap: IslandsMap,
|
|
66
|
+
componentName: string,
|
|
67
|
+
defaultContextProvider: ContextProviderComponent
|
|
68
|
+
): Promise<{
|
|
69
|
+
Component: IslandComponent;
|
|
70
|
+
ContextProvider: ContextProviderComponent;
|
|
71
|
+
} | null> => {
|
|
72
|
+
const config = islandsMap[componentName];
|
|
73
|
+
|
|
74
|
+
if (!config) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check cache first for lazy-loaded components
|
|
79
|
+
const cached = resolvedComponentsCache.get(componentName);
|
|
80
|
+
if (cached) {
|
|
81
|
+
return { Component: cached, ContextProvider: defaultContextProvider };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Handle lazy factory: () => import("./Component")
|
|
85
|
+
if (isLazyFactory(config)) {
|
|
86
|
+
const mod = await config();
|
|
87
|
+
const Component = mod.default;
|
|
88
|
+
resolvedComponentsCache.set(componentName, Component);
|
|
89
|
+
return { Component, ContextProvider: defaultContextProvider };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Handle direct component
|
|
93
|
+
if (typeof config === "function") {
|
|
94
|
+
return { Component: config, ContextProvider: defaultContextProvider };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Handle config object
|
|
98
|
+
return {
|
|
99
|
+
Component: config.Component,
|
|
100
|
+
ContextProvider: config.ContextProvider || defaultContextProvider,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// ============================================================================
|
|
105
|
+
// Main Hook Factory (LiveView Adapter Layer)
|
|
106
|
+
// ============================================================================
|
|
107
|
+
|
|
108
|
+
export function createHooks({
|
|
109
|
+
islands: islandsMap = {},
|
|
110
|
+
SharedContextProvider = NullContextProvider,
|
|
111
|
+
manager = IslandsManager,
|
|
112
|
+
}: CreateHooksOptions) {
|
|
113
|
+
console.log(`[LiveReactIslands] Creating hooks`);
|
|
114
|
+
let managerState = manager.initialize({ SharedContextProvider });
|
|
115
|
+
|
|
116
|
+
let globalsRequested = false;
|
|
117
|
+
const pendingMounts = new Set<string>();
|
|
118
|
+
let navigationListenerAttached = false;
|
|
119
|
+
|
|
120
|
+
const Hook: Partial<LiveViewHook> & {
|
|
121
|
+
mounted: (this: LiveViewHook) => void;
|
|
122
|
+
updated?: (this: LiveViewHook) => void;
|
|
123
|
+
destroyed: (this: LiveViewHook) => void;
|
|
124
|
+
} = {
|
|
125
|
+
mounted(this: LiveViewHook) {
|
|
126
|
+
console.log("[Hook] Mount:", this.el.id);
|
|
127
|
+
if (!this.el.id) {
|
|
128
|
+
console.error(`[LiveReactIslands] Island element missing unique id`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!navigationListenerAttached) {
|
|
133
|
+
navigationListenerAttached = true;
|
|
134
|
+
this.liveSocket.on("phx:page-loading-start", () => {
|
|
135
|
+
globalsRequested = false;
|
|
136
|
+
managerState = manager.resetGlobals(managerState);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Pull globals once on first island mount
|
|
141
|
+
if (!globalsRequested) {
|
|
142
|
+
globalsRequested = true;
|
|
143
|
+
this.handleEvent("lri-g", (data) => {
|
|
144
|
+
managerState = manager.updateGlobals(managerState, data);
|
|
145
|
+
});
|
|
146
|
+
this.pushEvent("lri-g", {}, (reply) => {
|
|
147
|
+
managerState = manager.updateGlobals(managerState, reply);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const {
|
|
152
|
+
comp: componentName,
|
|
153
|
+
ssr: ssrData,
|
|
154
|
+
props: propsData,
|
|
155
|
+
schema: schemaData,
|
|
156
|
+
globals: globalsData,
|
|
157
|
+
globalsVersion: globalsVersionData,
|
|
158
|
+
} = this.el.dataset;
|
|
159
|
+
if (!componentName) {
|
|
160
|
+
console.error(
|
|
161
|
+
`[LiveReactIslands] Element '${this.el.id}' missing data-comp attribute`
|
|
162
|
+
);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let schema: { p: string[]; g: string[]; i: number } = {
|
|
167
|
+
p: [],
|
|
168
|
+
g: [],
|
|
169
|
+
i: 0,
|
|
170
|
+
};
|
|
171
|
+
if (schemaData) {
|
|
172
|
+
try {
|
|
173
|
+
schema = JSON.parse(schemaData);
|
|
174
|
+
} catch (e) {
|
|
175
|
+
console.error(
|
|
176
|
+
`[Island(${this.el.id})] Failed to parse data-schema:`,
|
|
177
|
+
e
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let initialProps: Record<string, any> = {};
|
|
183
|
+
if (propsData) {
|
|
184
|
+
try {
|
|
185
|
+
const propsArray = JSON.parse(propsData);
|
|
186
|
+
schema.p.forEach((propName, idx) => {
|
|
187
|
+
const value = propsArray[idx];
|
|
188
|
+
if (value?.__s !== undefined) {
|
|
189
|
+
const streamName = schema.p[value.__s];
|
|
190
|
+
managerState = manager.initializeStream(
|
|
191
|
+
managerState,
|
|
192
|
+
this.el.id,
|
|
193
|
+
streamName,
|
|
194
|
+
value.i
|
|
195
|
+
);
|
|
196
|
+
const handle: StreamHandle = {
|
|
197
|
+
name: streamName,
|
|
198
|
+
initial: value.i,
|
|
199
|
+
};
|
|
200
|
+
initialProps[propName] = handle;
|
|
201
|
+
} else {
|
|
202
|
+
initialProps[propName] = value;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
} catch (e) {
|
|
206
|
+
console.error(
|
|
207
|
+
`[Island(${this.el.id})] Failed to parse data-props:`,
|
|
208
|
+
e
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Only for hydrate_root strategy
|
|
214
|
+
let initialGlobals: Record<string, any> = {};
|
|
215
|
+
if (globalsData) {
|
|
216
|
+
try {
|
|
217
|
+
const globalsArray = JSON.parse(globalsData);
|
|
218
|
+
schema.g.forEach((globalName, idx) => {
|
|
219
|
+
initialGlobals[globalName] = globalsArray[idx];
|
|
220
|
+
});
|
|
221
|
+
if (!globalsVersionData)
|
|
222
|
+
throw "Missing data-globals-version attribute";
|
|
223
|
+
initialGlobals.__version = parseInt(globalsVersionData, 10);
|
|
224
|
+
} catch (e) {
|
|
225
|
+
console.error(
|
|
226
|
+
`[Island(${this.el.id})] Failed to parse data-globals:`,
|
|
227
|
+
e
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let ssrStrategy: SSRStrategy;
|
|
233
|
+
switch (ssrData) {
|
|
234
|
+
case "overwrite":
|
|
235
|
+
ssrStrategy = "overwrite";
|
|
236
|
+
break;
|
|
237
|
+
case "hydrate_root":
|
|
238
|
+
ssrStrategy = "hydrate_root";
|
|
239
|
+
break;
|
|
240
|
+
default:
|
|
241
|
+
ssrStrategy = "none";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const pushEvent = createPushEventFn(this);
|
|
245
|
+
const elId = this.el.id;
|
|
246
|
+
const el = this.el;
|
|
247
|
+
|
|
248
|
+
pendingMounts.add(elId);
|
|
249
|
+
|
|
250
|
+
(async () => {
|
|
251
|
+
const islandConfig = await resolveIslandConfig(
|
|
252
|
+
islandsMap,
|
|
253
|
+
componentName,
|
|
254
|
+
SharedContextProvider
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
if (!pendingMounts.has(elId)) return;
|
|
258
|
+
pendingMounts.delete(elId);
|
|
259
|
+
|
|
260
|
+
if (!islandConfig) {
|
|
261
|
+
console.error(
|
|
262
|
+
`[LiveReactIslands] React island '${componentName}' not found`
|
|
263
|
+
);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
managerState = manager.updateIslandProps(
|
|
268
|
+
managerState,
|
|
269
|
+
elId,
|
|
270
|
+
initialProps
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
managerState = manager.mountIsland(managerState, {
|
|
274
|
+
id: elId,
|
|
275
|
+
el,
|
|
276
|
+
Component: islandConfig.Component,
|
|
277
|
+
ContextProvider: islandConfig.ContextProvider,
|
|
278
|
+
ssrStrategy,
|
|
279
|
+
pushEvent,
|
|
280
|
+
hydrationData:
|
|
281
|
+
ssrStrategy === "hydrate_root"
|
|
282
|
+
? { props: initialProps, globals: initialGlobals }
|
|
283
|
+
: null,
|
|
284
|
+
globalKeys: schema.g,
|
|
285
|
+
});
|
|
286
|
+
})();
|
|
287
|
+
|
|
288
|
+
this.handleEvent(
|
|
289
|
+
"lri-p",
|
|
290
|
+
(payload: { i: number; [key: number]: any }) => {
|
|
291
|
+
if (payload.i !== schema.i) return;
|
|
292
|
+
|
|
293
|
+
const props: Record<string, any> = {};
|
|
294
|
+
Object.entries(payload).forEach(([key, value]) => {
|
|
295
|
+
if (key === "i") return;
|
|
296
|
+
const idx = parseInt(key, 10);
|
|
297
|
+
if (!isNaN(idx) && schema.p[idx]) {
|
|
298
|
+
props[schema.p[idx]] = value;
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
managerState = manager.updateIslandProps(
|
|
303
|
+
managerState,
|
|
304
|
+
this.el.id,
|
|
305
|
+
props
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
this.handleEvent(
|
|
311
|
+
"lri-s",
|
|
312
|
+
(payload: { s: number; a: StreamAction; d: any; i: number }) => {
|
|
313
|
+
if (payload.i !== schema.i) return;
|
|
314
|
+
|
|
315
|
+
const streamName = schema.p[payload.s];
|
|
316
|
+
if (!streamName) return;
|
|
317
|
+
|
|
318
|
+
managerState = manager.updateStream(
|
|
319
|
+
managerState,
|
|
320
|
+
this.el.id,
|
|
321
|
+
streamName,
|
|
322
|
+
payload.a,
|
|
323
|
+
payload.d
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
);
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
updated(this: LiveViewHook) {
|
|
330
|
+
console.log("[Hook] Updated:", this.el.id);
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
destroyed(this: LiveViewHook) {
|
|
334
|
+
console.log("[Hook] Destroyed:", this.el.id);
|
|
335
|
+
pendingMounts.delete(this.el.id);
|
|
336
|
+
managerState = manager.unmountIsland(managerState, this.el.id);
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
return { LiveReactIslands: Hook };
|
|
341
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type { IslandsMap, IslandConfig, StreamHandle } from "./types";
|
|
2
|
+
export { createHooks, type CreateHooksOptions } from "./createHooks";
|
|
3
|
+
export { useStream, type UseStreamOptions } from "./useStream";
|
|
4
|
+
export { useForm, type FormProps, type FieldProps } from "./useForm";
|