@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
package/dist/index.js ADDED
@@ -0,0 +1,684 @@
1
+ 'use strict';
2
+
3
+ var React = require('react');
4
+ var ReactDOM = require('react-dom/client');
5
+ var reactDom = require('react-dom');
6
+
7
+ var StreamAction;
8
+ (function (StreamAction) {
9
+ StreamAction["Insert"] = "i";
10
+ StreamAction["Update"] = "u";
11
+ StreamAction["Delete"] = "d";
12
+ StreamAction["Reset"] = "r";
13
+ })(StreamAction || (StreamAction = {}));
14
+ const NullContextProvider$1 = ({ children, }) => children;
15
+
16
+ const IslandContextProvider = React.createContext(null);
17
+
18
+ const PortalIsland = React.memo(({ data, storeAccess, }) => {
19
+ const { Component, ContextProvider, id, pushEvent, ssrStrategy, el, globalKeys, } = data;
20
+ const MemoizedComponent = React.useMemo(() => React.memo(Component), [Component]);
21
+ const MemoizedContextProvider = React.useMemo(() => (ContextProvider ? React.memo(ContextProvider) : null), [ContextProvider]);
22
+ const contextValue = React.useMemo(() => ({ id, storeAccess }), [id, storeAccess]);
23
+ const currentProps = React.useSyncExternalStore(storeAccess.subscribeToProps, () => storeAccess.getProps(id), () => null);
24
+ const allGlobals = React.useSyncExternalStore(storeAccess.subscribeToGlobals, storeAccess.getGlobals, () => null);
25
+ const didClearRef = React.useRef(false);
26
+ if (ssrStrategy === "overwrite" && !didClearRef.current) {
27
+ el.innerHTML = "";
28
+ didClearRef.current = true;
29
+ }
30
+ if (!allGlobals) {
31
+ console.log("[PortalIsland] Waiting for globals", id);
32
+ return null;
33
+ }
34
+ console.log("[PortalIsland] Rendering", { id, allGlobals });
35
+ const relevantGlobals = {};
36
+ if (allGlobals) {
37
+ globalKeys.forEach((key) => {
38
+ if (key in allGlobals) {
39
+ relevantGlobals[key] = allGlobals[key];
40
+ }
41
+ });
42
+ }
43
+ const mergedProps = { ...relevantGlobals, ...currentProps };
44
+ const islandNode = (React.createElement(MemoizedComponent, { ...mergedProps, id: id, pushEvent: pushEvent }));
45
+ const withContext = (React.createElement(IslandContextProvider.Provider, { value: contextValue }, islandNode));
46
+ const content = MemoizedContextProvider ? (React.createElement(MemoizedContextProvider, null, withContext)) : (withContext);
47
+ return reactDom.createPortal(content, el);
48
+ });
49
+ PortalIsland.displayName = "[ReactLiveIslands]PortalIsland";
50
+ const PortalIslandsRenderer = ({ storeAccess, }) => {
51
+ const sharedIslandsObj = React.useSyncExternalStore(storeAccess.subscribeToSharedIslands, storeAccess.getSharedIslands);
52
+ const islands = Object.values(sharedIslandsObj);
53
+ console.log("[PortalIslandsRenderer] Rendering islands:", islands.map((d) => d.id));
54
+ return (React.createElement(React.Fragment, null, islands.map((data) => (React.createElement(PortalIsland, { key: data.id, data: data, storeAccess: storeAccess })))));
55
+ };
56
+ PortalIslandsRenderer.displayName = "[ReactLiveIslands]PortalIslandsRenderer";
57
+
58
+ const IndividualIslandRenderer = ({ data, storeAccess }) => {
59
+ const { Component, ContextProvider, id, pushEvent, globalKeys } = data;
60
+ const MemoizedComponent = React.useMemo(() => React.memo(Component), [Component]);
61
+ const MemoizedContextProvider = React.useMemo(() => (ContextProvider ? React.memo(ContextProvider) : null), [ContextProvider]);
62
+ const currentProps = React.useSyncExternalStore(storeAccess.subscribeToProps, () => storeAccess.getProps(id), () => data.hydrationData?.props);
63
+ // Check once if hydration globals version matches store version
64
+ const serverGlobalsSnapshot = React.useMemo(() => {
65
+ const hydrationGlobals = data.hydrationData?.globals;
66
+ if (!hydrationGlobals)
67
+ return () => hydrationGlobals;
68
+ let comparedVersions = false;
69
+ let useStoreReference = false;
70
+ return () => {
71
+ if (!comparedVersions) {
72
+ comparedVersions = true;
73
+ const storeGlobals = storeAccess.getGlobals();
74
+ const hydrationVersion = hydrationGlobals.__version ?? -1;
75
+ const storeVersion = storeGlobals?.__version ?? -1;
76
+ useStoreReference = storeVersion === hydrationVersion;
77
+ }
78
+ return useStoreReference ? storeAccess.getGlobals() : hydrationGlobals;
79
+ };
80
+ }, [data.hydrationData?.globals, storeAccess]);
81
+ const globals = React.useSyncExternalStore(storeAccess.subscribeToGlobals, storeAccess.getGlobals, serverGlobalsSnapshot);
82
+ const filteredGlobals = {};
83
+ if (globals) {
84
+ globalKeys.forEach((key) => {
85
+ if (key in globals) {
86
+ filteredGlobals[key] = globals[key];
87
+ }
88
+ });
89
+ }
90
+ const contextValue = React.useMemo(() => ({ id, storeAccess }), [id, storeAccess]);
91
+ const islandNode = (React.createElement(MemoizedComponent, { ...filteredGlobals, ...currentProps, id: id, pushEvent: pushEvent }));
92
+ const withContext = (React.createElement(IslandContextProvider.Provider, { value: contextValue }, islandNode));
93
+ return MemoizedContextProvider ? (React.createElement(MemoizedContextProvider, null, withContext)) : (withContext);
94
+ };
95
+ IndividualIslandRenderer.displayName =
96
+ "[ReactLiveIslands] IndividualIslandRenderer";
97
+
98
+ // Constant root ID - one shared root per application
99
+ const SHARED_ROOT_ID = "__live_react_islands_shared_root__";
100
+ // ============================================================================
101
+ // Helper
102
+ // ============================================================================
103
+ const NullContextProvider = ({ children, }) => children;
104
+ const withSharedRootName = (ContextProvider) => {
105
+ const originalName = ContextProvider.displayName || ContextProvider.name || "ContextProvider";
106
+ function NamedWrapper({ children }) {
107
+ return React.createElement(ContextProvider, null, children);
108
+ }
109
+ NamedWrapper.displayName = `[LiveReactIslands] SharedRoot(${originalName})`;
110
+ return NamedWrapper;
111
+ };
112
+ const withIndividualRootName = (islandId, IslandRenderer) => {
113
+ IslandRenderer.displayName;
114
+ function NamedWrapper(props) {
115
+ return React.createElement(IslandRenderer, props);
116
+ }
117
+ NamedWrapper.displayName = `[LiveReactIslands] HydratedRoot#${islandId}`;
118
+ return NamedWrapper;
119
+ };
120
+ const getOrCreateSharedRootElement = () => {
121
+ let rootElement = document.getElementById(SHARED_ROOT_ID);
122
+ if (!rootElement) {
123
+ rootElement = document.createElement("div");
124
+ rootElement.id = SHARED_ROOT_ID;
125
+ rootElement.style.display = "none";
126
+ document.body.appendChild(rootElement);
127
+ }
128
+ return rootElement;
129
+ };
130
+ const mountSharedIsland = (state, data) => {
131
+ state.sharedIslandsRef.current = {
132
+ ...state.sharedIslandsRef.current,
133
+ [data.id]: data,
134
+ };
135
+ state.sharedIslandsListeners.forEach((cb) => cb());
136
+ return state;
137
+ };
138
+ const mountIndividualIsland = (state, data, IslandRenderer) => {
139
+ const NamedIslandRenderer = withIndividualRootName(data.id, IslandRenderer);
140
+ const root = ReactDOM.hydrateRoot(data.el, React.createElement(React.StrictMode, null, React.createElement(NamedIslandRenderer, {
141
+ data,
142
+ storeAccess: state.storeAccess,
143
+ })));
144
+ return {
145
+ ...state,
146
+ roots: { ...state.roots, [data.id]: root },
147
+ };
148
+ };
149
+ // ============================================================================
150
+ // Island Manager (Bridge between LiveView hook lifecycles and React rendering)
151
+ // ============================================================================
152
+ const ManagerObj = {
153
+ initialize: ({ SharedContextProvider = NullContextProvider, SharedIslandsRenderer = PortalIslandsRenderer, }) => {
154
+ const rootElement = getOrCreateSharedRootElement();
155
+ const root = ReactDOM.createRoot(rootElement);
156
+ const propsStore = new Map();
157
+ const propsListeners = new Set();
158
+ const globalsRef = { current: null };
159
+ const globalsListeners = new Set();
160
+ const sharedIslandsRef = { current: {} };
161
+ const sharedIslandsListeners = new Set();
162
+ const streamsStore = new Map();
163
+ const streamsListeners = new Map();
164
+ const streamConfigs = new Map();
165
+ const getStreamKey = (islandId, streamName) => `${islandId}:${streamName}`;
166
+ const storeAccess = {
167
+ getProps: (islandId) => propsStore.get(islandId) || null,
168
+ subscribeToProps: (cb) => {
169
+ propsListeners.add(cb);
170
+ return () => propsListeners.delete(cb);
171
+ },
172
+ getGlobals: () => globalsRef.current,
173
+ subscribeToGlobals: (cb) => {
174
+ globalsListeners.add(cb);
175
+ return () => globalsListeners.delete(cb);
176
+ },
177
+ getSharedIslands: () => sharedIslandsRef.current,
178
+ subscribeToSharedIslands: (cb) => {
179
+ sharedIslandsListeners.add(cb);
180
+ return () => sharedIslandsListeners.delete(cb);
181
+ },
182
+ getStreamItems: (islandId, streamName) => {
183
+ const key = getStreamKey(islandId, streamName);
184
+ return streamsStore.get(key) || [];
185
+ },
186
+ subscribeToStream: (islandId, streamName, cb, config) => {
187
+ const key = getStreamKey(islandId, streamName);
188
+ if (config) {
189
+ streamConfigs.set(key, config);
190
+ }
191
+ if (!streamsListeners.has(key)) {
192
+ streamsListeners.set(key, new Set());
193
+ }
194
+ streamsListeners.get(key).add(cb);
195
+ return () => {
196
+ streamsListeners.get(key)?.delete(cb);
197
+ if (streamsListeners.get(key)?.size === 0) {
198
+ streamConfigs.delete(key);
199
+ }
200
+ };
201
+ },
202
+ };
203
+ const state = {
204
+ propsStore,
205
+ globalsRef,
206
+ propsListeners,
207
+ globalsListeners,
208
+ sharedIslandsRef,
209
+ sharedIslandsListeners,
210
+ streamsStore,
211
+ streamsListeners,
212
+ streamConfigs,
213
+ roots: { [SHARED_ROOT_ID]: root },
214
+ storeAccess,
215
+ };
216
+ const NamedSharedContextProvider = withSharedRootName(SharedContextProvider);
217
+ root.render(React.createElement(React.StrictMode, null, React.createElement(NamedSharedContextProvider, null, React.createElement(SharedIslandsRenderer, {
218
+ storeAccess: state.storeAccess,
219
+ }))));
220
+ return state;
221
+ },
222
+ mountIsland: (state, data, IslandRenderer = IndividualIslandRenderer) => {
223
+ switch (data.ssrStrategy) {
224
+ case "hydrate_root":
225
+ return mountIndividualIsland(state, data, IslandRenderer);
226
+ default:
227
+ return mountSharedIsland(state, data);
228
+ }
229
+ },
230
+ unmountIsland: (state, id) => {
231
+ state.propsStore.delete(id);
232
+ const streamPrefix = `${id}:`;
233
+ for (const key of state.streamsStore.keys()) {
234
+ if (key.startsWith(streamPrefix)) {
235
+ state.streamsStore.delete(key);
236
+ state.streamsListeners.delete(key);
237
+ state.streamConfigs.delete(key);
238
+ }
239
+ }
240
+ if (state.roots[id]) {
241
+ state.roots[id].unmount();
242
+ const { [id]: removedRoot, ...remainingRoots } = state.roots;
243
+ return {
244
+ ...state,
245
+ roots: remainingRoots,
246
+ };
247
+ }
248
+ else {
249
+ const { [id]: removed, ...remaining } = state.sharedIslandsRef.current;
250
+ state.sharedIslandsRef.current = remaining;
251
+ state.sharedIslandsListeners.forEach((cb) => cb());
252
+ return state;
253
+ }
254
+ },
255
+ updateIslandProps: (state, islandId, props) => {
256
+ const existing = state.propsStore.get(islandId) || {};
257
+ state.propsStore.set(islandId, { ...existing, ...props });
258
+ state.propsListeners.forEach((cb) => cb());
259
+ return state;
260
+ },
261
+ updateGlobals: (state, globals) => {
262
+ const incomingVersion = globals.__version ?? -1;
263
+ const storeVersion = state.globalsRef.current?.__version ?? -1;
264
+ if (incomingVersion > storeVersion) {
265
+ state.globalsRef.current = {
266
+ ...(state.globalsRef.current || {}),
267
+ ...globals,
268
+ };
269
+ state.globalsListeners.forEach((cb) => cb());
270
+ }
271
+ return state;
272
+ },
273
+ resetGlobals: (state) => {
274
+ state.globalsRef.current = null;
275
+ return state;
276
+ },
277
+ initializeStream: (state, islandId, streamName, initialItems) => {
278
+ const key = `${islandId}:${streamName}`;
279
+ state.streamsStore.set(key, [...initialItems]);
280
+ return state;
281
+ },
282
+ updateStream: (state, islandId, streamName, action, data) => {
283
+ const key = `${islandId}:${streamName}`;
284
+ let items = state.streamsStore.get(key) || [];
285
+ const config = state.streamConfigs.get(key);
286
+ switch (action) {
287
+ case StreamAction.Insert:
288
+ items = [data, ...items];
289
+ break;
290
+ case StreamAction.Update:
291
+ const idx = items.findIndex((item) => item.id === data.id);
292
+ if (idx !== -1) {
293
+ items = [...items];
294
+ items[idx] = { ...items[idx], ...data };
295
+ }
296
+ break;
297
+ case StreamAction.Delete:
298
+ items = items.filter((item) => item.id !== data);
299
+ break;
300
+ case StreamAction.Reset:
301
+ items = [];
302
+ break;
303
+ }
304
+ if (config?.capper) {
305
+ items = config.capper(items);
306
+ }
307
+ else if (config?.limit !== undefined && items.length > config.limit) {
308
+ items = items.slice(0, config.limit);
309
+ }
310
+ state.streamsStore.set(key, items);
311
+ state.streamsListeners.get(key)?.forEach((cb) => cb());
312
+ return state;
313
+ },
314
+ };
315
+
316
+ // ============================================================================
317
+ // Event Handler Adapter
318
+ // ============================================================================
319
+ function createPushEventFn(hook) {
320
+ return (eventName, payload) => {
321
+ hook.pushEventTo(`#${hook.el.id}`, eventName, payload);
322
+ };
323
+ }
324
+ // ============================================================================
325
+ // Helper
326
+ // ============================================================================
327
+ const resolvedComponentsCache = new Map();
328
+ const isLazyFactory = (value) => {
329
+ if (typeof value !== "function")
330
+ return false;
331
+ // Check if it's a React component (has prototype.isReactComponent for class components)
332
+ if (value.prototype?.isReactComponent)
333
+ return false;
334
+ // Check if it's a config object getter (would have Component property)
335
+ if (value.Component)
336
+ return false;
337
+ // Assume zero-arg functions that aren't class components are lazy factories
338
+ return value.length === 0;
339
+ };
340
+ const resolveIslandConfig = async (islandsMap, componentName, defaultContextProvider) => {
341
+ const config = islandsMap[componentName];
342
+ if (!config) {
343
+ return null;
344
+ }
345
+ // Check cache first for lazy-loaded components
346
+ const cached = resolvedComponentsCache.get(componentName);
347
+ if (cached) {
348
+ return { Component: cached, ContextProvider: defaultContextProvider };
349
+ }
350
+ // Handle lazy factory: () => import("./Component")
351
+ if (isLazyFactory(config)) {
352
+ const mod = await config();
353
+ const Component = mod.default;
354
+ resolvedComponentsCache.set(componentName, Component);
355
+ return { Component, ContextProvider: defaultContextProvider };
356
+ }
357
+ // Handle direct component
358
+ if (typeof config === "function") {
359
+ return { Component: config, ContextProvider: defaultContextProvider };
360
+ }
361
+ // Handle config object
362
+ return {
363
+ Component: config.Component,
364
+ ContextProvider: config.ContextProvider || defaultContextProvider,
365
+ };
366
+ };
367
+ // ============================================================================
368
+ // Main Hook Factory (LiveView Adapter Layer)
369
+ // ============================================================================
370
+ function createHooks({ islands: islandsMap = {}, SharedContextProvider = NullContextProvider$1, manager = ManagerObj, }) {
371
+ console.log(`[LiveReactIslands] Creating hooks`);
372
+ let managerState = manager.initialize({ SharedContextProvider });
373
+ let globalsRequested = false;
374
+ const pendingMounts = new Set();
375
+ let navigationListenerAttached = false;
376
+ const Hook = {
377
+ mounted() {
378
+ console.log("[Hook] Mount:", this.el.id);
379
+ if (!this.el.id) {
380
+ console.error(`[LiveReactIslands] Island element missing unique id`);
381
+ return;
382
+ }
383
+ if (!navigationListenerAttached) {
384
+ navigationListenerAttached = true;
385
+ this.liveSocket.on("phx:page-loading-start", () => {
386
+ globalsRequested = false;
387
+ managerState = manager.resetGlobals(managerState);
388
+ });
389
+ }
390
+ // Pull globals once on first island mount
391
+ if (!globalsRequested) {
392
+ globalsRequested = true;
393
+ this.handleEvent("lri-g", (data) => {
394
+ managerState = manager.updateGlobals(managerState, data);
395
+ });
396
+ this.pushEvent("lri-g", {}, (reply) => {
397
+ managerState = manager.updateGlobals(managerState, reply);
398
+ });
399
+ }
400
+ const { comp: componentName, ssr: ssrData, props: propsData, schema: schemaData, globals: globalsData, globalsVersion: globalsVersionData, } = this.el.dataset;
401
+ if (!componentName) {
402
+ console.error(`[LiveReactIslands] Element '${this.el.id}' missing data-comp attribute`);
403
+ return;
404
+ }
405
+ let schema = {
406
+ p: [],
407
+ g: [],
408
+ i: 0,
409
+ };
410
+ if (schemaData) {
411
+ try {
412
+ schema = JSON.parse(schemaData);
413
+ }
414
+ catch (e) {
415
+ console.error(`[Island(${this.el.id})] Failed to parse data-schema:`, e);
416
+ }
417
+ }
418
+ let initialProps = {};
419
+ if (propsData) {
420
+ try {
421
+ const propsArray = JSON.parse(propsData);
422
+ schema.p.forEach((propName, idx) => {
423
+ const value = propsArray[idx];
424
+ if (value?.__s !== undefined) {
425
+ const streamName = schema.p[value.__s];
426
+ managerState = manager.initializeStream(managerState, this.el.id, streamName, value.i);
427
+ const handle = {
428
+ name: streamName,
429
+ initial: value.i,
430
+ };
431
+ initialProps[propName] = handle;
432
+ }
433
+ else {
434
+ initialProps[propName] = value;
435
+ }
436
+ });
437
+ }
438
+ catch (e) {
439
+ console.error(`[Island(${this.el.id})] Failed to parse data-props:`, e);
440
+ }
441
+ }
442
+ // Only for hydrate_root strategy
443
+ let initialGlobals = {};
444
+ if (globalsData) {
445
+ try {
446
+ const globalsArray = JSON.parse(globalsData);
447
+ schema.g.forEach((globalName, idx) => {
448
+ initialGlobals[globalName] = globalsArray[idx];
449
+ });
450
+ if (!globalsVersionData)
451
+ throw "Missing data-globals-version attribute";
452
+ initialGlobals.__version = parseInt(globalsVersionData, 10);
453
+ }
454
+ catch (e) {
455
+ console.error(`[Island(${this.el.id})] Failed to parse data-globals:`, e);
456
+ }
457
+ }
458
+ let ssrStrategy;
459
+ switch (ssrData) {
460
+ case "overwrite":
461
+ ssrStrategy = "overwrite";
462
+ break;
463
+ case "hydrate_root":
464
+ ssrStrategy = "hydrate_root";
465
+ break;
466
+ default:
467
+ ssrStrategy = "none";
468
+ }
469
+ const pushEvent = createPushEventFn(this);
470
+ const elId = this.el.id;
471
+ const el = this.el;
472
+ pendingMounts.add(elId);
473
+ (async () => {
474
+ const islandConfig = await resolveIslandConfig(islandsMap, componentName, SharedContextProvider);
475
+ if (!pendingMounts.has(elId))
476
+ return;
477
+ pendingMounts.delete(elId);
478
+ if (!islandConfig) {
479
+ console.error(`[LiveReactIslands] React island '${componentName}' not found`);
480
+ return;
481
+ }
482
+ managerState = manager.updateIslandProps(managerState, elId, initialProps);
483
+ managerState = manager.mountIsland(managerState, {
484
+ id: elId,
485
+ el,
486
+ Component: islandConfig.Component,
487
+ ContextProvider: islandConfig.ContextProvider,
488
+ ssrStrategy,
489
+ pushEvent,
490
+ hydrationData: ssrStrategy === "hydrate_root"
491
+ ? { props: initialProps, globals: initialGlobals }
492
+ : null,
493
+ globalKeys: schema.g,
494
+ });
495
+ })();
496
+ this.handleEvent("lri-p", (payload) => {
497
+ if (payload.i !== schema.i)
498
+ return;
499
+ const props = {};
500
+ Object.entries(payload).forEach(([key, value]) => {
501
+ if (key === "i")
502
+ return;
503
+ const idx = parseInt(key, 10);
504
+ if (!isNaN(idx) && schema.p[idx]) {
505
+ props[schema.p[idx]] = value;
506
+ }
507
+ });
508
+ managerState = manager.updateIslandProps(managerState, this.el.id, props);
509
+ });
510
+ this.handleEvent("lri-s", (payload) => {
511
+ if (payload.i !== schema.i)
512
+ return;
513
+ const streamName = schema.p[payload.s];
514
+ if (!streamName)
515
+ return;
516
+ managerState = manager.updateStream(managerState, this.el.id, streamName, payload.a, payload.d);
517
+ });
518
+ },
519
+ updated() {
520
+ console.log("[Hook] Updated:", this.el.id);
521
+ },
522
+ destroyed() {
523
+ console.log("[Hook] Destroyed:", this.el.id);
524
+ pendingMounts.delete(this.el.id);
525
+ managerState = manager.unmountIsland(managerState, this.el.id);
526
+ },
527
+ };
528
+ return { LiveReactIslands: Hook };
529
+ }
530
+
531
+ function useStream(handle, options) {
532
+ const context = React.useContext(IslandContextProvider);
533
+ if (!context) {
534
+ throw new Error("useStream must be used within an Island component");
535
+ }
536
+ const { id: islandId, storeAccess } = context;
537
+ const subscribe = React.useMemo(() => (cb) => storeAccess.subscribeToStream(islandId, handle.name, cb, {
538
+ limit: options?.limit,
539
+ capper: options?.capper,
540
+ }), [storeAccess, islandId, handle.name, options?.limit, options?.capper]);
541
+ const getSnapshot = React.useMemo(() => () => storeAccess.getStreamItems(islandId, handle.name), [storeAccess, islandId, handle.name]);
542
+ return React.useSyncExternalStore(subscribe, getSnapshot, () => handle.initial);
543
+ }
544
+
545
+ function useForm(form, pushEvent) {
546
+ // 1. Version Tracking
547
+ // Local version increments on each field change
548
+ // Server version comes from form.version prop
549
+ const localVersionRef = React.useRef(form.version ?? 0);
550
+ const lastSentVersionRef = React.useRef(0); // Prevents duplicate sends in StrictMode
551
+ const [localVersion, setLocalVersion] = React.useState(form.version ?? 0);
552
+ const serverVersion = form.version ?? 0;
553
+ // Prop name comes from server (embedded in form data)
554
+ const propName = form.__prop;
555
+ // Reset local version when server sends a reset (version 0 with new schema)
556
+ React.useEffect(() => {
557
+ if (form.version === 0 && form.types) {
558
+ localVersionRef.current = 0;
559
+ setLocalVersion(0);
560
+ }
561
+ }, [form.version, form.types]);
562
+ // 3. Persistent Schema
563
+ // We only update these when the server explicitly sends them (usually on init)
564
+ const [schema, setSchema] = React.useState({
565
+ types: form.types ?? {},
566
+ required: form.required ?? [],
567
+ });
568
+ if (form.types && form.types !== schema.types) {
569
+ setSchema((prev) => ({ ...prev, types: form.types }));
570
+ }
571
+ if (form.required && form.required !== schema.required) {
572
+ setSchema((prev) => ({ ...prev, required: form.required }));
573
+ }
574
+ // 4. Values Management
575
+ const [localValues, setLocalValues] = React.useState(form.values ?? {});
576
+ // 5. Touched Tracking (client-side only)
577
+ const [touched, setTouched] = React.useState([]);
578
+ // Sync from server: Update local values when server sends updates
579
+ React.useEffect(() => {
580
+ setLocalValues((prev) => ({ ...prev, ...form.values }));
581
+ }, [form.values]);
582
+ // 6. Versioned Change Handler
583
+ const setField = React.useCallback((name, value) => {
584
+ // Mark field as touched
585
+ setTouched((prev) => (prev.includes(name) ? prev : [...prev, name]));
586
+ // Increment version BEFORE state update to avoid StrictMode double-increment
587
+ localVersionRef.current += 1;
588
+ const newVersion = localVersionRef.current;
589
+ setLocalValues((prev) => {
590
+ const next = { ...prev, [name]: value };
591
+ // Update version state (will be batched with values update)
592
+ setLocalVersion(newVersion);
593
+ // Only send event once per version (prevents duplicate sends in StrictMode)
594
+ if (lastSentVersionRef.current < newVersion) {
595
+ lastSentVersionRef.current = newVersion;
596
+ pushEvent?.("lri_form", {
597
+ event: "validate",
598
+ prop: propName,
599
+ attrs: next,
600
+ _v: newVersion,
601
+ });
602
+ }
603
+ return next;
604
+ });
605
+ }, [propName]);
606
+ // 6. Identity-Stable Field Props
607
+ const getFieldProps = React.useCallback((name) => {
608
+ const type = schema.types[name];
609
+ const value = localValues[name];
610
+ const base = { name };
611
+ if (type === "boolean" || type === "bool") {
612
+ return {
613
+ ...base,
614
+ checked: !!value,
615
+ value: undefined,
616
+ onChange: (e) => setField(name, e.target.checked),
617
+ };
618
+ }
619
+ return {
620
+ ...base,
621
+ value: value ?? "",
622
+ onChange: (e) => setField(name, e.target.value),
623
+ };
624
+ }, [localValues, schema.types, setField]);
625
+ const getError = React.useCallback((name) => form.errors?.[name]?.[0], [form.errors]);
626
+ const isRequired = React.useCallback((name) => schema.required.includes(name), [schema.required]);
627
+ const isTouched = React.useCallback((name) => touched.includes(name), [touched]);
628
+ // 7. Versioned Submit Handler
629
+ const handleSubmit = React.useCallback((e) => {
630
+ e?.preventDefault();
631
+ // Increment version for submit as well
632
+ localVersionRef.current += 1;
633
+ const newVersion = localVersionRef.current;
634
+ setLocalVersion(newVersion);
635
+ // Only send once (prevents duplicate sends if called twice)
636
+ if (lastSentVersionRef.current < newVersion) {
637
+ lastSentVersionRef.current = newVersion;
638
+ pushEvent?.("lri_form", {
639
+ event: "submit",
640
+ prop: propName,
641
+ attrs: localValues,
642
+ _v: newVersion,
643
+ });
644
+ }
645
+ }, [localValues, propName]);
646
+ const reset = React.useCallback(() => {
647
+ setLocalValues(form.values ?? {});
648
+ setTouched([]);
649
+ // Reset local version to match server
650
+ localVersionRef.current = form.version ?? 0;
651
+ setLocalVersion(form.version ?? 0);
652
+ }, [form.values, form.version]);
653
+ // 8. Derived States - The "Validation Lock"
654
+ // isSyncing: True when client has sent changes that server hasn't acknowledged
655
+ const isSyncing = localVersion > serverVersion;
656
+ // isValid: True only when:
657
+ // 1. Form is in sync (server has seen all changes)
658
+ // 2. Server says the form is valid
659
+ // This ensures users cannot submit until server validates the latest data
660
+ const isValid = !isSyncing && (form.isValid ?? false);
661
+ return {
662
+ values: localValues,
663
+ errors: form.errors ?? {},
664
+ types: schema.types,
665
+ required: schema.required,
666
+ touched,
667
+ getFieldProps,
668
+ getError,
669
+ isRequired,
670
+ isTouched,
671
+ setField,
672
+ handleSubmit,
673
+ reset,
674
+ isSyncing,
675
+ isValid,
676
+ localVersion,
677
+ serverVersion,
678
+ };
679
+ }
680
+
681
+ exports.createHooks = createHooks;
682
+ exports.useForm = useForm;
683
+ exports.useStream = useStream;
684
+ //# sourceMappingURL=index.js.map