@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,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
|