@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,356 @@
1
+ import React from "react";
2
+ import ReactDOM from "react-dom/client";
3
+
4
+ import {
5
+ ContextProviderComponent,
6
+ IslandData,
7
+ SharedIslandsRendererComponent,
8
+ IndividualIslandsRendererComponent,
9
+ IslandStoreAccess,
10
+ IndividualIslandsRendererProps,
11
+ StreamAction,
12
+ StreamConfig,
13
+ } from "./types";
14
+ import { PortalIslandsRenderer } from "./PortalIslandsRenderer";
15
+ import { IndividualIslandRenderer } from "./IndividualIslandRenderer";
16
+
17
+ // Constant root ID - one shared root per application
18
+ export const SHARED_ROOT_ID = "__live_react_islands_shared_root__";
19
+
20
+ // ============================================================================
21
+ // Types
22
+ // ============================================================================
23
+
24
+ interface ManagerState {
25
+ propsStore: Map<string, Record<string, any>>;
26
+ propsListeners: Set<() => void>;
27
+ globalsRef: React.MutableRefObject<Record<string, any> | null>;
28
+ globalsListeners: Set<() => void>;
29
+ sharedIslandsRef: React.MutableRefObject<Record<string, IslandData>>;
30
+ sharedIslandsListeners: Set<() => void>;
31
+ streamsStore: Map<string, any[]>;
32
+ streamsListeners: Map<string, Set<() => void>>;
33
+ streamConfigs: Map<string, StreamConfig>;
34
+ roots: Record<string, any>;
35
+ storeAccess: IslandStoreAccess;
36
+ }
37
+
38
+ export interface Manager {
39
+ initialize: (params: {
40
+ SharedContextProvider?: ContextProviderComponent;
41
+ SharedIslandsRenderer?: SharedIslandsRendererComponent;
42
+ }) => ManagerState;
43
+ mountIsland: (
44
+ state: ManagerState,
45
+ data: IslandData,
46
+ IslandRenderer?: IndividualIslandsRendererComponent
47
+ ) => ManagerState;
48
+ unmountIsland: (state: ManagerState, id: string) => ManagerState;
49
+ updateIslandProps: (
50
+ state: ManagerState,
51
+ islandId: string,
52
+ props: Record<string, any>
53
+ ) => ManagerState;
54
+ updateGlobals: (
55
+ state: ManagerState,
56
+ globals: Record<string, any>
57
+ ) => ManagerState;
58
+ resetGlobals: (state: ManagerState) => ManagerState;
59
+ initializeStream: (
60
+ state: ManagerState,
61
+ islandId: string,
62
+ streamName: string,
63
+ initialItems: any[]
64
+ ) => ManagerState;
65
+ updateStream: (
66
+ state: ManagerState,
67
+ islandId: string,
68
+ streamName: string,
69
+ action: StreamAction,
70
+ data: any
71
+ ) => ManagerState;
72
+ }
73
+
74
+ // ============================================================================
75
+ // Helper
76
+ // ============================================================================
77
+
78
+ const NullContextProvider: React.FC<{ children: React.ReactNode }> = ({
79
+ children,
80
+ }) => children;
81
+
82
+ const withSharedRootName = (
83
+ ContextProvider: ContextProviderComponent
84
+ ): ContextProviderComponent => {
85
+ const originalName =
86
+ ContextProvider.displayName || ContextProvider.name || "ContextProvider";
87
+
88
+ function NamedWrapper({ children }: { children: React.ReactNode }) {
89
+ return React.createElement(ContextProvider, null, children);
90
+ }
91
+
92
+ NamedWrapper.displayName = `[LiveReactIslands] SharedRoot(${originalName})`;
93
+ return NamedWrapper;
94
+ };
95
+
96
+ const withIndividualRootName = (
97
+ islandId: string,
98
+ IslandRenderer: IndividualIslandsRendererComponent
99
+ ): IndividualIslandsRendererComponent => {
100
+ const originalName = IslandRenderer.displayName;
101
+
102
+ function NamedWrapper(props: IndividualIslandsRendererProps) {
103
+ return React.createElement(IslandRenderer, props);
104
+ }
105
+
106
+ NamedWrapper.displayName = `[LiveReactIslands] HydratedRoot#${islandId}`;
107
+ return NamedWrapper;
108
+ };
109
+
110
+ const getOrCreateSharedRootElement = (): HTMLElement => {
111
+ let rootElement = document.getElementById(SHARED_ROOT_ID);
112
+ if (!rootElement) {
113
+ rootElement = document.createElement("div");
114
+ rootElement.id = SHARED_ROOT_ID;
115
+ rootElement.style.display = "none";
116
+ document.body.appendChild(rootElement);
117
+ }
118
+ return rootElement;
119
+ };
120
+
121
+ const mountSharedIsland = (
122
+ state: ManagerState,
123
+ data: IslandData
124
+ ): ManagerState => {
125
+ state.sharedIslandsRef.current = {
126
+ ...state.sharedIslandsRef.current,
127
+ [data.id]: data,
128
+ };
129
+ state.sharedIslandsListeners.forEach((cb) => cb());
130
+ return state;
131
+ };
132
+
133
+ const mountIndividualIsland = (
134
+ state: ManagerState,
135
+ data: IslandData,
136
+ IslandRenderer: IndividualIslandsRendererComponent
137
+ ): ManagerState => {
138
+ const NamedIslandRenderer = withIndividualRootName(data.id, IslandRenderer);
139
+ const root = ReactDOM.hydrateRoot(
140
+ data.el,
141
+ React.createElement(
142
+ React.StrictMode,
143
+ null,
144
+ React.createElement(NamedIslandRenderer, {
145
+ data,
146
+ storeAccess: state.storeAccess,
147
+ })
148
+ )
149
+ );
150
+
151
+ return {
152
+ ...state,
153
+ roots: { ...state.roots, [data.id]: root },
154
+ };
155
+ };
156
+
157
+ // ============================================================================
158
+ // Island Manager (Bridge between LiveView hook lifecycles and React rendering)
159
+ // ============================================================================
160
+
161
+ const ManagerObj: Manager = {
162
+ initialize: ({
163
+ SharedContextProvider = NullContextProvider,
164
+ SharedIslandsRenderer = PortalIslandsRenderer,
165
+ }) => {
166
+ const rootElement = getOrCreateSharedRootElement();
167
+ const root = ReactDOM.createRoot(rootElement);
168
+
169
+ const propsStore = new Map<string, Record<string, any>>();
170
+ const propsListeners = new Set<() => void>();
171
+ const globalsRef = { current: null as Record<string, any> | null };
172
+ const globalsListeners = new Set<() => void>();
173
+ const sharedIslandsRef = { current: {} as Record<string, IslandData> };
174
+ const sharedIslandsListeners = new Set<() => void>();
175
+ const streamsStore = new Map<string, any[]>();
176
+ const streamsListeners = new Map<string, Set<() => void>>();
177
+ const streamConfigs = new Map<string, StreamConfig>();
178
+
179
+ const getStreamKey = (islandId: string, streamName: string) =>
180
+ `${islandId}:${streamName}`;
181
+
182
+ const storeAccess: IslandStoreAccess = {
183
+ getProps: (islandId: string) => propsStore.get(islandId) || null,
184
+ subscribeToProps: (cb: () => void) => {
185
+ propsListeners.add(cb);
186
+ return () => propsListeners.delete(cb);
187
+ },
188
+ getGlobals: () => globalsRef.current,
189
+ subscribeToGlobals: (cb: () => void) => {
190
+ globalsListeners.add(cb);
191
+ return () => globalsListeners.delete(cb);
192
+ },
193
+ getSharedIslands: () => sharedIslandsRef.current,
194
+ subscribeToSharedIslands: (cb: () => void) => {
195
+ sharedIslandsListeners.add(cb);
196
+ return () => sharedIslandsListeners.delete(cb);
197
+ },
198
+ getStreamItems: (islandId: string, streamName: string) => {
199
+ const key = getStreamKey(islandId, streamName);
200
+ return streamsStore.get(key) || [];
201
+ },
202
+ subscribeToStream: (
203
+ islandId: string,
204
+ streamName: string,
205
+ cb: () => void,
206
+ config?: StreamConfig
207
+ ) => {
208
+ const key = getStreamKey(islandId, streamName);
209
+ if (config) {
210
+ streamConfigs.set(key, config);
211
+ }
212
+ if (!streamsListeners.has(key)) {
213
+ streamsListeners.set(key, new Set());
214
+ }
215
+ streamsListeners.get(key)!.add(cb);
216
+ return () => {
217
+ streamsListeners.get(key)?.delete(cb);
218
+ if (streamsListeners.get(key)?.size === 0) {
219
+ streamConfigs.delete(key);
220
+ }
221
+ };
222
+ },
223
+ };
224
+
225
+ const state: ManagerState = {
226
+ propsStore,
227
+ globalsRef,
228
+ propsListeners,
229
+ globalsListeners,
230
+ sharedIslandsRef,
231
+ sharedIslandsListeners,
232
+ streamsStore,
233
+ streamsListeners,
234
+ streamConfigs,
235
+ roots: { [SHARED_ROOT_ID]: root },
236
+ storeAccess,
237
+ };
238
+
239
+ const NamedSharedContextProvider = withSharedRootName(
240
+ SharedContextProvider
241
+ );
242
+
243
+ root.render(
244
+ React.createElement(
245
+ React.StrictMode,
246
+ null,
247
+ React.createElement(
248
+ NamedSharedContextProvider,
249
+ null,
250
+ React.createElement(SharedIslandsRenderer, {
251
+ storeAccess: state.storeAccess,
252
+ })
253
+ )
254
+ )
255
+ );
256
+
257
+ return state;
258
+ },
259
+ mountIsland: (state, data, IslandRenderer = IndividualIslandRenderer) => {
260
+ switch (data.ssrStrategy) {
261
+ case "hydrate_root":
262
+ return mountIndividualIsland(state, data, IslandRenderer);
263
+ default:
264
+ return mountSharedIsland(state, data);
265
+ }
266
+ },
267
+ unmountIsland: (state, id) => {
268
+ state.propsStore.delete(id);
269
+
270
+ const streamPrefix = `${id}:`;
271
+ for (const key of state.streamsStore.keys()) {
272
+ if (key.startsWith(streamPrefix)) {
273
+ state.streamsStore.delete(key);
274
+ state.streamsListeners.delete(key);
275
+ state.streamConfigs.delete(key);
276
+ }
277
+ }
278
+
279
+ if (state.roots[id]) {
280
+ state.roots[id].unmount();
281
+ const { [id]: removedRoot, ...remainingRoots } = state.roots;
282
+ return {
283
+ ...state,
284
+ roots: remainingRoots,
285
+ };
286
+ } else {
287
+ const { [id]: removed, ...remaining } = state.sharedIslandsRef.current;
288
+ state.sharedIslandsRef.current = remaining;
289
+ state.sharedIslandsListeners.forEach((cb) => cb());
290
+ return state;
291
+ }
292
+ },
293
+ updateIslandProps: (state, islandId, props) => {
294
+ const existing = state.propsStore.get(islandId) || {};
295
+ state.propsStore.set(islandId, { ...existing, ...props });
296
+ state.propsListeners.forEach((cb) => cb());
297
+ return state;
298
+ },
299
+ updateGlobals: (state, globals) => {
300
+ const incomingVersion = globals.__version ?? -1;
301
+ const storeVersion = state.globalsRef.current?.__version ?? -1;
302
+ if (incomingVersion > storeVersion) {
303
+ state.globalsRef.current = {
304
+ ...(state.globalsRef.current || {}),
305
+ ...globals,
306
+ };
307
+ state.globalsListeners.forEach((cb) => cb());
308
+ }
309
+
310
+ return state;
311
+ },
312
+ resetGlobals: (state) => {
313
+ state.globalsRef.current = null;
314
+ return state;
315
+ },
316
+ initializeStream: (state, islandId, streamName, initialItems) => {
317
+ const key = `${islandId}:${streamName}`;
318
+ state.streamsStore.set(key, [...initialItems]);
319
+ return state;
320
+ },
321
+ updateStream: (state, islandId, streamName, action, data) => {
322
+ const key = `${islandId}:${streamName}`;
323
+ let items = state.streamsStore.get(key) || [];
324
+ const config = state.streamConfigs.get(key);
325
+
326
+ switch (action) {
327
+ case StreamAction.Insert:
328
+ items = [data, ...items];
329
+ break;
330
+ case StreamAction.Update:
331
+ const idx = items.findIndex((item) => item.id === data.id);
332
+ if (idx !== -1) {
333
+ items = [...items];
334
+ items[idx] = { ...items[idx], ...data };
335
+ }
336
+ break;
337
+ case StreamAction.Delete:
338
+ items = items.filter((item) => item.id !== data);
339
+ break;
340
+ case StreamAction.Reset:
341
+ items = [];
342
+ break;
343
+ }
344
+
345
+ if (config?.capper) {
346
+ items = config.capper(items);
347
+ } else if (config?.limit !== undefined && items.length > config.limit) {
348
+ items = items.slice(0, config.limit);
349
+ }
350
+
351
+ state.streamsStore.set(key, items);
352
+ state.streamsListeners.get(key)?.forEach((cb) => cb());
353
+ return state;
354
+ },
355
+ };
356
+ export default ManagerObj;
@@ -0,0 +1 @@
1
+ import "@testing-library/jest-dom";
package/src/ssr.ts ADDED
@@ -0,0 +1,118 @@
1
+ import React from "react";
2
+ import { renderToString, renderToStaticMarkup } from "react-dom/server";
3
+ import {
4
+ type IslandsMap,
5
+ type IslandComponent,
6
+ type ContextProviderComponent,
7
+ type LazyIslandFactory,
8
+ NullContextProvider,
9
+ } from "./types";
10
+
11
+ export interface ExposeSSROptions {
12
+ islands: IslandsMap;
13
+ SharedContextProvider?: ContextProviderComponent;
14
+ }
15
+
16
+ const isLazyFactory = (value: unknown): value is LazyIslandFactory => {
17
+ if (typeof value !== "function") return false;
18
+ if ((value as any).prototype?.isReactComponent) return false;
19
+ if ((value as any).Component) return false;
20
+ return value.length === 0;
21
+ };
22
+
23
+ /**
24
+ * Server-side rendering function for React islands.
25
+ *
26
+ * This function creates SSR renderers for your island components.
27
+ * It works in any JavaScript runtime: Deno, Node, Bun, etc.
28
+ *
29
+ * Supports lazy-loaded islands: () => import("./Component")
30
+ */
31
+ export async function exposeSSR({
32
+ islands,
33
+ SharedContextProvider = NullContextProvider,
34
+ }: ExposeSSROptions) {
35
+ // Resolve all lazy imports first
36
+ const resolvedIslands: Record<string, IslandComponent> = {};
37
+
38
+ for (const [name, config] of Object.entries(islands)) {
39
+ if (isLazyFactory(config)) {
40
+ const mod = await config();
41
+ resolvedIslands[name] = mod.default;
42
+ } else if (typeof config === "function") {
43
+ resolvedIslands[name] = config;
44
+ } else {
45
+ resolvedIslands[name] = config.Component;
46
+ }
47
+ }
48
+ const makeRenderer =
49
+ (renderFn: typeof renderToString | typeof renderToStaticMarkup) =>
50
+ (IslandComponent: any) =>
51
+ (
52
+ id: string,
53
+ props: Record<string, any> = {},
54
+ globals: Record<string, any> = {}
55
+ ) => {
56
+ return renderFn(
57
+ React.createElement(
58
+ SharedContextProvider,
59
+ null,
60
+ React.createElement(IslandComponent, {
61
+ ...globals,
62
+ ...props,
63
+ id,
64
+ pushEvent: () => {},
65
+ })
66
+ )
67
+ );
68
+ };
69
+
70
+ // Create renderers for both strategies using pre-resolved components
71
+ // - renderToString: For hydrate_root (includes React markers for hydration)
72
+ // - renderToStaticMarkup: For overwrite (clean HTML, smaller size)
73
+ const hydrateRenderers = Object.fromEntries(
74
+ Object.entries(resolvedIslands).map(([key, Component]) => [
75
+ key,
76
+ makeRenderer(renderToString)(Component),
77
+ ])
78
+ );
79
+
80
+ const overwriteRenderers = Object.fromEntries(
81
+ Object.entries(resolvedIslands).map(([key, Component]) => [
82
+ key,
83
+ makeRenderer(renderToStaticMarkup)(Component),
84
+ ])
85
+ );
86
+
87
+ // Main render function that chooses renderer based on strategy
88
+ const renderSSRIsland = (
89
+ key: string,
90
+ id: string,
91
+ props: Record<string, any> = {},
92
+ globalState: any = {},
93
+ strategy: "hydrate_root" | "overwrite" = "overwrite"
94
+ ): string => {
95
+ const renderers =
96
+ strategy === "hydrate_root" ? hydrateRenderers : overwriteRenderers;
97
+ if (!renderers[key]) {
98
+ throw new Error(`No SSR renderer found for Component "${key}"`);
99
+ }
100
+ return renderers[key](id, props, globalState);
101
+ };
102
+
103
+ const getGlobal = () => {
104
+ if (typeof globalThis !== "undefined") return globalThis;
105
+ // @ts-ignore
106
+ if (typeof global !== "undefined") return global;
107
+ if (typeof window !== "undefined") return window;
108
+ return {};
109
+ };
110
+
111
+ const universalGlobal = getGlobal();
112
+ //@ts-ignore
113
+ universalGlobal.SSR_MODULE = {
114
+ renderSSRIsland,
115
+ hydrateRenderers,
116
+ overwriteRenderers,
117
+ };
118
+ }
package/src/types.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { ComponentType } from "react";
2
+
3
+ // ============================================================================
4
+ // Core Types
5
+ // ============================================================================
6
+
7
+ export type IslandComponent = ComponentType<any>;
8
+
9
+ export type LazyIslandFactory = () => Promise<{ default: IslandComponent }>;
10
+
11
+ export type IslandsMap = Record<
12
+ string,
13
+ IslandComponent | IslandConfig | LazyIslandFactory
14
+ >;
15
+
16
+ export type ContextProviderComponent = ComponentType<{
17
+ children: React.ReactNode;
18
+ }>;
19
+
20
+ export interface IslandConfig {
21
+ Component: IslandComponent;
22
+ ContextProvider?: ContextProviderComponent;
23
+ }
24
+
25
+ export type SSRStrategy = "none" | "overwrite" | "hydrate_root";
26
+
27
+ export type PushEventFn = (eventName: string, payload: any) => void;
28
+
29
+ export interface StreamConfig {
30
+ limit?: number;
31
+ capper?: (items: any[]) => any[];
32
+ }
33
+
34
+ export interface IslandStoreAccess {
35
+ getProps: (islandId: string) => Record<string, any> | null;
36
+ subscribeToProps: (callback: () => void) => () => void;
37
+ getGlobals: () => Record<string, any> | null;
38
+ subscribeToGlobals: (callback: () => void) => () => void;
39
+ getSharedIslands: () => Record<string, IslandData>;
40
+ subscribeToSharedIslands: (callback: () => void) => () => void;
41
+ getStreamItems: (islandId: string, streamName: string) => any[];
42
+ subscribeToStream: (
43
+ islandId: string,
44
+ streamName: string,
45
+ callback: () => void,
46
+ config?: StreamConfig
47
+ ) => () => void;
48
+ }
49
+
50
+ export enum StreamAction {
51
+ Insert = "i",
52
+ Update = "u",
53
+ Delete = "d",
54
+ Reset = "r",
55
+ }
56
+
57
+ export interface StreamHandle {
58
+ name: string;
59
+ initial: any[];
60
+ }
61
+
62
+ export interface IslandData {
63
+ id: string;
64
+ el: HTMLElement;
65
+ ssrStrategy: SSRStrategy;
66
+ Component: IslandComponent;
67
+ ContextProvider?: ContextProviderComponent;
68
+ hydrationData: {
69
+ props: Record<string, any>;
70
+ globals: Record<string, any>;
71
+ } | null;
72
+ pushEvent: PushEventFn;
73
+ globalKeys: string[];
74
+ }
75
+
76
+ export interface SharedIslandsRendererProps {
77
+ storeAccess: IslandStoreAccess;
78
+ }
79
+ export type SharedIslandsRendererComponent =
80
+ React.FC<SharedIslandsRendererProps>;
81
+
82
+ export interface IndividualIslandsRendererProps {
83
+ data: IslandData;
84
+ storeAccess: IslandStoreAccess;
85
+ }
86
+ export type IndividualIslandsRendererComponent =
87
+ React.FC<IndividualIslandsRendererProps>;
88
+
89
+ export const NullContextProvider: React.FC<{ children: React.ReactNode }> = ({
90
+ children,
91
+ }) => children;