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