@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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/dist/IndividualIslandRenderer.d.ts +4 -0
  3. package/dist/IndividualIslandRenderer.d.ts.map +1 -0
  4. package/dist/IslandRenderer.d.ts +11 -0
  5. package/dist/IslandRenderer.d.ts.map +1 -0
  6. package/dist/PortalIslandsRenderer.d.ts +4 -0
  7. package/dist/PortalIslandsRenderer.d.ts.map +1 -0
  8. package/dist/PortalIslandsRenderer.test.d.ts +2 -0
  9. package/dist/PortalIslandsRenderer.test.d.ts.map +1 -0
  10. package/dist/client/IndividualIslandRenderer.d.ts +14 -0
  11. package/dist/client/IndividualIslandRenderer.d.ts.map +1 -0
  12. package/dist/client/PortalIslandsRenderer.d.ts +4 -0
  13. package/dist/client/PortalIslandsRenderer.d.ts.map +1 -0
  14. package/dist/client/islandsManager.d.ts +23 -0
  15. package/dist/client/islandsManager.d.ts.map +1 -0
  16. package/dist/client.d.ts +23 -0
  17. package/dist/client.d.ts.map +1 -0
  18. package/dist/context.d.ts +7 -0
  19. package/dist/context.d.ts.map +1 -0
  20. package/dist/createHooks.d.ts +25 -0
  21. package/dist/createHooks.d.ts.map +1 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.esm.js +680 -0
  25. package/dist/index.esm.js.map +1 -0
  26. package/dist/index.js +684 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/island-manager.d.ts +59 -0
  29. package/dist/island-manager.d.ts.map +1 -0
  30. package/dist/islandsManager.d.ts +32 -0
  31. package/dist/islandsManager.d.ts.map +1 -0
  32. package/dist/islandsManager.test.d.ts +2 -0
  33. package/dist/islandsManager.test.d.ts.map +1 -0
  34. package/dist/server.d.ts +18 -0
  35. package/dist/server.d.ts.map +1 -0
  36. package/dist/setupTests.d.ts +2 -0
  37. package/dist/setupTests.d.ts.map +1 -0
  38. package/dist/ssr.d.ts +15 -0
  39. package/dist/ssr.d.ts.map +1 -0
  40. package/dist/ssr.esm.js +92 -0
  41. package/dist/ssr.esm.js.map +1 -0
  42. package/dist/ssr.js +94 -0
  43. package/dist/ssr.js.map +1 -0
  44. package/dist/types.d.ts +65 -0
  45. package/dist/types.d.ts.map +1 -0
  46. package/dist/useForm.d.ts +60 -0
  47. package/dist/useForm.d.ts.map +1 -0
  48. package/dist/useIslandStream.d.ts +15 -0
  49. package/dist/useIslandStream.d.ts.map +1 -0
  50. package/dist/useStream.d.ts +9 -0
  51. package/dist/useStream.d.ts.map +1 -0
  52. package/package.json +66 -0
  53. package/src/IndividualIslandRenderer.tsx +80 -0
  54. package/src/PortalIslandsRenderer.test.tsx +304 -0
  55. package/src/PortalIslandsRenderer.tsx +118 -0
  56. package/src/context.ts +9 -0
  57. package/src/createHooks.ts +341 -0
  58. package/src/index.ts +4 -0
  59. package/src/islandsManager.test.ts +454 -0
  60. package/src/islandsManager.ts +356 -0
  61. package/src/setupTests.ts +1 -0
  62. package/src/ssr.ts +118 -0
  63. package/src/types.ts +91 -0
  64. package/src/useForm.ts +253 -0
  65. 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";