@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,60 @@
|
|
|
1
|
+
export interface FormProps {
|
|
2
|
+
/** Internal: prop name for event routing (set by server) */
|
|
3
|
+
__prop?: string;
|
|
4
|
+
types?: Record<string, string>;
|
|
5
|
+
required?: string[];
|
|
6
|
+
values: Record<string, any>;
|
|
7
|
+
errors: Record<string, string[]>;
|
|
8
|
+
isValid?: boolean;
|
|
9
|
+
version?: number;
|
|
10
|
+
}
|
|
11
|
+
export type PushEventFn = (event: string, payload: Record<string, any>) => void;
|
|
12
|
+
export interface FieldProps {
|
|
13
|
+
name: string;
|
|
14
|
+
value: any;
|
|
15
|
+
checked?: boolean;
|
|
16
|
+
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
|
|
17
|
+
}
|
|
18
|
+
export interface UseFormReturn {
|
|
19
|
+
/** Current form field values */
|
|
20
|
+
values: Record<string, any>;
|
|
21
|
+
/** Current form errors by field name */
|
|
22
|
+
errors: Record<string, string[]>;
|
|
23
|
+
/** Field types from schema */
|
|
24
|
+
types: Record<string, string>;
|
|
25
|
+
/** Required field names */
|
|
26
|
+
required: string[];
|
|
27
|
+
/** Field names that have been modified by the user */
|
|
28
|
+
touched: string[];
|
|
29
|
+
/** Get props to spread on an input element */
|
|
30
|
+
getFieldProps: (name: string) => FieldProps;
|
|
31
|
+
/** Get the first error message for a field */
|
|
32
|
+
getError: (name: string) => string | undefined;
|
|
33
|
+
/** Check if a field is required */
|
|
34
|
+
isRequired: (name: string) => boolean;
|
|
35
|
+
/** Check if a field has been modified by the user */
|
|
36
|
+
isTouched: (name: string) => boolean;
|
|
37
|
+
/** Set a field value programmatically */
|
|
38
|
+
setField: (name: string, value: any) => void;
|
|
39
|
+
/** Handle form submission */
|
|
40
|
+
handleSubmit: (e?: React.FormEvent) => void;
|
|
41
|
+
/** Reset form to server values */
|
|
42
|
+
reset: () => void;
|
|
43
|
+
/**
|
|
44
|
+
* True when the server hasn't acknowledged the latest client changes.
|
|
45
|
+
* Use this to show a syncing indicator (e.g., spinner, "Validating...").
|
|
46
|
+
*/
|
|
47
|
+
isSyncing: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* True only when form is valid AND fully synced with server.
|
|
50
|
+
* Use this to enable/disable the submit button.
|
|
51
|
+
* This is the "Validation Lock" - users cannot submit until server confirms validity.
|
|
52
|
+
*/
|
|
53
|
+
isValid: boolean;
|
|
54
|
+
/** Current local version counter */
|
|
55
|
+
localVersion: number;
|
|
56
|
+
/** Last acknowledged server version */
|
|
57
|
+
serverVersion: number;
|
|
58
|
+
}
|
|
59
|
+
export declare function useForm(form: FormProps, pushEvent?: PushEventFn): UseFormReturn;
|
|
60
|
+
//# sourceMappingURL=useForm.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useForm.d.ts","sourceRoot":"","sources":["../src/useForm.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,SAAS;IACxB,4DAA4D;IAC5D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAC5B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACjC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,WAAW,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,IAAI,CAAC;AAEhF,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,GAAG,CAAC;IACX,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,CACR,CAAC,EAAE,KAAK,CAAC,WAAW,CAClB,gBAAgB,GAAG,mBAAmB,GAAG,iBAAiB,CAC3D,KACE,IAAI,CAAC;CACX;AAED,MAAM,WAAW,aAAa;IAC5B,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAC5B,wCAAwC;IACxC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACjC,8BAA8B;IAC9B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,2BAA2B;IAC3B,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,sDAAsD;IACtD,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,8CAA8C;IAC9C,aAAa,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,UAAU,CAAC;IAC5C,8CAA8C;IAC9C,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;IAC/C,mCAAmC;IACnC,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IACtC,qDAAqD;IACrD,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IACrC,yCAAyC;IACzC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,KAAK,IAAI,CAAC;IAC7C,6BAA6B;IAC7B,YAAY,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,SAAS,KAAK,IAAI,CAAC;IAC5C,kCAAkC;IAClC,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB;;;OAGG;IACH,SAAS,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,OAAO,EAAE,OAAO,CAAC;IACjB,oCAAoC;IACpC,YAAY,EAAE,MAAM,CAAC;IACrB,uCAAuC;IACvC,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,OAAO,CACrB,IAAI,EAAE,SAAS,EACf,SAAS,CAAC,EAAE,WAAW,GACtB,aAAa,CAqLf"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { IslandStoreAccess, StreamHandle } from "./types";
|
|
2
|
+
interface IslandContext {
|
|
3
|
+
id: string;
|
|
4
|
+
storeAccess: IslandStoreAccess;
|
|
5
|
+
}
|
|
6
|
+
export declare const IslandContextProvider: import("react").Context<IslandContext | null>;
|
|
7
|
+
export interface UseIslandStreamOptions<T> {
|
|
8
|
+
limit?: number;
|
|
9
|
+
sort?: (a: T, b: T) => number;
|
|
10
|
+
}
|
|
11
|
+
export declare function useIslandStream<T extends {
|
|
12
|
+
id: string | number;
|
|
13
|
+
}>(handle: StreamHandle, options?: UseIslandStreamOptions<T>): T[];
|
|
14
|
+
export {};
|
|
15
|
+
//# sourceMappingURL=useIslandStream.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useIslandStream.d.ts","sourceRoot":"","sources":["../src/useIslandStream.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE/D,UAAU,aAAa;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,iBAAiB,CAAC;CAChC;AAED,eAAO,MAAM,qBAAqB,+CAA4C,CAAC;AAE/E,MAAM,WAAW,sBAAsB,CAAC,CAAC;IACvC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,KAAK,MAAM,CAAC;CAC/B;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS;IAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAAA;CAAE,EAC/D,MAAM,EAAE,YAAY,EACpB,OAAO,CAAC,EAAE,sBAAsB,CAAC,CAAC,CAAC,GAClC,CAAC,EAAE,CA2BL"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { StreamHandle } from "./types";
|
|
2
|
+
export interface UseStreamOptions<T> {
|
|
3
|
+
limit?: number;
|
|
4
|
+
capper?: (items: T[]) => T[];
|
|
5
|
+
}
|
|
6
|
+
export declare function useStream<T extends {
|
|
7
|
+
id: string | number;
|
|
8
|
+
}>(handle: StreamHandle, options?: UseStreamOptions<T>): T[];
|
|
9
|
+
//# sourceMappingURL=useStream.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useStream.d.ts","sourceRoot":"","sources":["../src/useStream.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAG5C,MAAM,WAAW,gBAAgB,CAAC,CAAC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,KAAK,CAAC,EAAE,CAAC;CAC9B;AAED,wBAAgB,SAAS,CAAC,CAAC,SAAS;IAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAAA;CAAE,EACzD,MAAM,EAAE,YAAY,EACpB,OAAO,CAAC,EAAE,gBAAgB,CAAC,CAAC,CAAC,GAC5B,CAAC,EAAE,CAuBL"}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@live-react-islands/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Core React integration for LiveReactIslands - Phoenix LiveView hook and utilities",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"module": "dist/index.esm.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.esm.js",
|
|
11
|
+
"require": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./ssr": {
|
|
14
|
+
"import": "./dist/ssr.esm.js",
|
|
15
|
+
"require": "./dist/ssr.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"types": "dist/index.d.ts",
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "rollup -c",
|
|
25
|
+
"dev": "rollup -c -w",
|
|
26
|
+
"test": "jest",
|
|
27
|
+
"clean": "rm -rf dist"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"phoenix",
|
|
31
|
+
"liveview",
|
|
32
|
+
"react",
|
|
33
|
+
"islands",
|
|
34
|
+
"ssr",
|
|
35
|
+
"elixir"
|
|
36
|
+
],
|
|
37
|
+
"author": "David Czaplinski",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "https://github.com/dcza/live-react-islands.git",
|
|
42
|
+
"directory": "packages/js/core"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"phoenix": "^1.7.0 || ^1.8.0",
|
|
46
|
+
"phoenix_live_view": "^1.0.0",
|
|
47
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
48
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@rollup/plugin-commonjs": "^25.0.7",
|
|
52
|
+
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
53
|
+
"@rollup/plugin-typescript": "^11.1.6",
|
|
54
|
+
"@testing-library/jest-dom": "^6.1.5",
|
|
55
|
+
"@testing-library/react": "^14.1.2",
|
|
56
|
+
"@types/jest": "^29.5.12",
|
|
57
|
+
"@types/react": "^18.3.12",
|
|
58
|
+
"@types/react-dom": "^18.3.1",
|
|
59
|
+
"jest": "^29.7.0",
|
|
60
|
+
"jest-environment-jsdom": "^29.7.0",
|
|
61
|
+
"rollup": "^4.24.0",
|
|
62
|
+
"ts-jest": "^29.1.2",
|
|
63
|
+
"tslib": "^2.8.1",
|
|
64
|
+
"typescript": "^5.6.3"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import React, { useSyncExternalStore, memo, useMemo } from "react";
|
|
2
|
+
import type { IndividualIslandsRendererProps } from "./types";
|
|
3
|
+
import { IslandContextProvider } from "./context";
|
|
4
|
+
|
|
5
|
+
export const IndividualIslandRenderer: React.FC<
|
|
6
|
+
IndividualIslandsRendererProps
|
|
7
|
+
> = ({ data, storeAccess }) => {
|
|
8
|
+
const { Component, ContextProvider, id, pushEvent, globalKeys } = data;
|
|
9
|
+
|
|
10
|
+
const MemoizedComponent = useMemo(() => memo(Component), [Component]);
|
|
11
|
+
const MemoizedContextProvider = useMemo(
|
|
12
|
+
() => (ContextProvider ? memo(ContextProvider) : null),
|
|
13
|
+
[ContextProvider]
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const currentProps = useSyncExternalStore(
|
|
17
|
+
storeAccess.subscribeToProps,
|
|
18
|
+
() => storeAccess.getProps(id),
|
|
19
|
+
() => data.hydrationData?.props
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// Check once if hydration globals version matches store version
|
|
23
|
+
const serverGlobalsSnapshot = useMemo(() => {
|
|
24
|
+
const hydrationGlobals = data.hydrationData?.globals;
|
|
25
|
+
if (!hydrationGlobals) return () => hydrationGlobals;
|
|
26
|
+
let comparedVersions = false;
|
|
27
|
+
let useStoreReference = false;
|
|
28
|
+
return () => {
|
|
29
|
+
if (!comparedVersions) {
|
|
30
|
+
comparedVersions = true;
|
|
31
|
+
const storeGlobals = storeAccess.getGlobals();
|
|
32
|
+
const hydrationVersion = hydrationGlobals.__version ?? -1;
|
|
33
|
+
const storeVersion = storeGlobals?.__version ?? -1;
|
|
34
|
+
useStoreReference = storeVersion === hydrationVersion;
|
|
35
|
+
}
|
|
36
|
+
return useStoreReference ? storeAccess.getGlobals() : hydrationGlobals;
|
|
37
|
+
};
|
|
38
|
+
}, [data.hydrationData?.globals, storeAccess]);
|
|
39
|
+
|
|
40
|
+
const globals = useSyncExternalStore(
|
|
41
|
+
storeAccess.subscribeToGlobals,
|
|
42
|
+
storeAccess.getGlobals,
|
|
43
|
+
serverGlobalsSnapshot
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const filteredGlobals: Record<string, any> = {};
|
|
47
|
+
if (globals) {
|
|
48
|
+
globalKeys.forEach((key) => {
|
|
49
|
+
if (key in globals) {
|
|
50
|
+
filteredGlobals[key] = globals[key];
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const contextValue = useMemo(() => ({ id, storeAccess }), [id, storeAccess]);
|
|
56
|
+
|
|
57
|
+
const islandNode = (
|
|
58
|
+
<MemoizedComponent
|
|
59
|
+
{...filteredGlobals}
|
|
60
|
+
{...currentProps}
|
|
61
|
+
id={id}
|
|
62
|
+
pushEvent={pushEvent}
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const withContext = (
|
|
67
|
+
<IslandContextProvider.Provider value={contextValue}>
|
|
68
|
+
{islandNode}
|
|
69
|
+
</IslandContextProvider.Provider>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return MemoizedContextProvider ? (
|
|
73
|
+
<MemoizedContextProvider>{withContext}</MemoizedContextProvider>
|
|
74
|
+
) : (
|
|
75
|
+
withContext
|
|
76
|
+
);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
IndividualIslandRenderer.displayName =
|
|
80
|
+
"[ReactLiveIslands] IndividualIslandRenderer";
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, waitFor, act, cleanup } from "@testing-library/react";
|
|
3
|
+
import { PortalIslandsRenderer } from "./PortalIslandsRenderer";
|
|
4
|
+
import type {
|
|
5
|
+
IslandData,
|
|
6
|
+
IslandStoreAccess,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
jest.spyOn(console, "log").mockImplementation(() => {});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
cleanup();
|
|
15
|
+
document.body.innerHTML = "";
|
|
16
|
+
jest.restoreAllMocks();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("PortalIslandsRenderer", () => {
|
|
20
|
+
const createMockStoreAccess = (): {
|
|
21
|
+
storeAccess: IslandStoreAccess;
|
|
22
|
+
setSharedIslands: (islands: Record<string, IslandData>) => void;
|
|
23
|
+
setProps: (islandId: string, props: Record<string, any>) => void;
|
|
24
|
+
setGlobals: (globals: Record<string, any> | null) => void;
|
|
25
|
+
} => {
|
|
26
|
+
const propsMap = new Map<string, Record<string, any>>();
|
|
27
|
+
const propsListeners = new Set<() => void>();
|
|
28
|
+
let globals: Record<string, any> | null = { __version: 1 };
|
|
29
|
+
const globalsListeners = new Set<() => void>();
|
|
30
|
+
let sharedIslands: Record<string, IslandData> = {};
|
|
31
|
+
const sharedIslandsListeners = new Set<() => void>();
|
|
32
|
+
|
|
33
|
+
const streamsStore = new Map<string, any[]>();
|
|
34
|
+
const streamsListeners = new Map<string, Set<() => void>>();
|
|
35
|
+
|
|
36
|
+
const storeAccess: IslandStoreAccess = {
|
|
37
|
+
getProps: (id: string) => propsMap.get(id) || null,
|
|
38
|
+
subscribeToProps: (cb: () => void) => {
|
|
39
|
+
propsListeners.add(cb);
|
|
40
|
+
return () => propsListeners.delete(cb);
|
|
41
|
+
},
|
|
42
|
+
getGlobals: () => globals,
|
|
43
|
+
subscribeToGlobals: (cb: () => void) => {
|
|
44
|
+
globalsListeners.add(cb);
|
|
45
|
+
return () => globalsListeners.delete(cb);
|
|
46
|
+
},
|
|
47
|
+
getSharedIslands: () => sharedIslands,
|
|
48
|
+
subscribeToSharedIslands: (cb: () => void) => {
|
|
49
|
+
sharedIslandsListeners.add(cb);
|
|
50
|
+
return () => sharedIslandsListeners.delete(cb);
|
|
51
|
+
},
|
|
52
|
+
getStreamItems: (islandId: string, streamName: string) => {
|
|
53
|
+
return streamsStore.get(`${islandId}:${streamName}`) || [];
|
|
54
|
+
},
|
|
55
|
+
subscribeToStream: (islandId: string, streamName: string, cb: () => void, _config?: any) => {
|
|
56
|
+
const key = `${islandId}:${streamName}`;
|
|
57
|
+
if (!streamsListeners.has(key)) {
|
|
58
|
+
streamsListeners.set(key, new Set());
|
|
59
|
+
}
|
|
60
|
+
streamsListeners.get(key)!.add(cb);
|
|
61
|
+
return () => streamsListeners.get(key)?.delete(cb);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
storeAccess,
|
|
67
|
+
setSharedIslands: (islands: Record<string, IslandData>) => {
|
|
68
|
+
sharedIslands = islands;
|
|
69
|
+
sharedIslandsListeners.forEach((cb) => cb());
|
|
70
|
+
},
|
|
71
|
+
setProps: (islandId: string, props: Record<string, any>) => {
|
|
72
|
+
propsMap.set(islandId, props);
|
|
73
|
+
propsListeners.forEach((cb) => cb());
|
|
74
|
+
},
|
|
75
|
+
setGlobals: (newGlobals: Record<string, any> | null) => {
|
|
76
|
+
globals = newGlobals;
|
|
77
|
+
globalsListeners.forEach((cb) => cb());
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const createMockIslandData = (
|
|
83
|
+
overrides?: Partial<IslandData>
|
|
84
|
+
): IslandData => {
|
|
85
|
+
const el = document.createElement("div");
|
|
86
|
+
el.id = overrides?.id || "test-island";
|
|
87
|
+
document.body.appendChild(el);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
id: overrides?.id || "test-island",
|
|
91
|
+
el,
|
|
92
|
+
Component: ({ count }: { count: number }) => <div>Count: {count}</div>,
|
|
93
|
+
ssrStrategy: "none",
|
|
94
|
+
pushEvent: jest.fn(),
|
|
95
|
+
hydrationData: null,
|
|
96
|
+
globalKeys: [],
|
|
97
|
+
...overrides,
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
test("renders islands from shared islands store", async () => {
|
|
102
|
+
const { storeAccess, setSharedIslands, setProps } = createMockStoreAccess();
|
|
103
|
+
|
|
104
|
+
render(<PortalIslandsRenderer storeAccess={storeAccess} />);
|
|
105
|
+
|
|
106
|
+
const islandData = createMockIslandData({ id: "island-1" });
|
|
107
|
+
|
|
108
|
+
act(() => {
|
|
109
|
+
setProps("island-1", { count: 0 });
|
|
110
|
+
setSharedIslands({ "island-1": islandData });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await waitFor(() => {
|
|
114
|
+
expect(islandData.el).toHaveTextContent("Count: 0");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("removes islands when removed from store", async () => {
|
|
119
|
+
const { storeAccess, setSharedIslands, setProps } = createMockStoreAccess();
|
|
120
|
+
|
|
121
|
+
render(<PortalIslandsRenderer storeAccess={storeAccess} />);
|
|
122
|
+
|
|
123
|
+
const islandData = createMockIslandData({ id: "island-1" });
|
|
124
|
+
|
|
125
|
+
act(() => {
|
|
126
|
+
setProps("island-1", { count: 0 });
|
|
127
|
+
setSharedIslands({ "island-1": islandData });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await waitFor(() => {
|
|
131
|
+
expect(islandData.el).toHaveTextContent("Count: 0");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
act(() => {
|
|
135
|
+
setSharedIslands({});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await waitFor(() => {
|
|
139
|
+
expect(islandData.el).toHaveTextContent("");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("subscribes to props and globals stores for each island", async () => {
|
|
144
|
+
const { storeAccess, setSharedIslands, setProps } = createMockStoreAccess();
|
|
145
|
+
|
|
146
|
+
const subscribeToProps = jest.spyOn(storeAccess, "subscribeToProps");
|
|
147
|
+
const subscribeToGlobals = jest.spyOn(storeAccess, "subscribeToGlobals");
|
|
148
|
+
|
|
149
|
+
render(<PortalIslandsRenderer storeAccess={storeAccess} />);
|
|
150
|
+
|
|
151
|
+
const islandData = createMockIslandData({ id: "island-1" });
|
|
152
|
+
|
|
153
|
+
act(() => {
|
|
154
|
+
setProps("island-1", { count: 0 });
|
|
155
|
+
setSharedIslands({ "island-1": islandData });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await waitFor(() => {
|
|
159
|
+
expect(subscribeToProps).toHaveBeenCalled();
|
|
160
|
+
expect(subscribeToGlobals).toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("clears innerHTML for overwrite strategy", async () => {
|
|
165
|
+
const { storeAccess, setSharedIslands, setProps } = createMockStoreAccess();
|
|
166
|
+
|
|
167
|
+
render(<PortalIslandsRenderer storeAccess={storeAccess} />);
|
|
168
|
+
|
|
169
|
+
const el = document.createElement("div");
|
|
170
|
+
el.innerHTML = "<div>SSR content</div>";
|
|
171
|
+
document.body.appendChild(el);
|
|
172
|
+
|
|
173
|
+
const islandData: IslandData = {
|
|
174
|
+
id: "island-1",
|
|
175
|
+
el,
|
|
176
|
+
Component: ({ count }: { count: number }) => <div>Count: {count}</div>,
|
|
177
|
+
ssrStrategy: "overwrite",
|
|
178
|
+
pushEvent: jest.fn(),
|
|
179
|
+
hydrationData: null,
|
|
180
|
+
globalKeys: [],
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
act(() => {
|
|
184
|
+
setProps("island-1", { count: 0 });
|
|
185
|
+
setSharedIslands({ "island-1": islandData });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await waitFor(() => {
|
|
189
|
+
expect(el.innerHTML).not.toContain("SSR content");
|
|
190
|
+
expect(el).toHaveTextContent("Count: 0");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("merges globals and props for island component", async () => {
|
|
195
|
+
const { storeAccess, setSharedIslands, setProps, setGlobals } = createMockStoreAccess();
|
|
196
|
+
|
|
197
|
+
const MockComponent = ({ count, user }: any) => (
|
|
198
|
+
<div>
|
|
199
|
+
{count}-{user}
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
render(<PortalIslandsRenderer storeAccess={storeAccess} />);
|
|
204
|
+
|
|
205
|
+
const islandData = createMockIslandData({
|
|
206
|
+
id: "island-1",
|
|
207
|
+
Component: MockComponent,
|
|
208
|
+
globalKeys: ["user"],
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
act(() => {
|
|
212
|
+
setGlobals({ user: "test", __version: 1 });
|
|
213
|
+
setProps("island-1", { count: 5 });
|
|
214
|
+
setSharedIslands({ "island-1": islandData });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await waitFor(() => {
|
|
218
|
+
expect(islandData.el).toHaveTextContent("5-test");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("wraps island in ContextProvider when provided", async () => {
|
|
223
|
+
const { storeAccess, setSharedIslands, setProps } = createMockStoreAccess();
|
|
224
|
+
|
|
225
|
+
const ContextProvider: React.FC<{ children: React.ReactNode }> = ({
|
|
226
|
+
children,
|
|
227
|
+
}) => <div data-context="wrapper">{children}</div>;
|
|
228
|
+
|
|
229
|
+
render(<PortalIslandsRenderer storeAccess={storeAccess} />);
|
|
230
|
+
|
|
231
|
+
const islandData = createMockIslandData({
|
|
232
|
+
id: "island-1",
|
|
233
|
+
ContextProvider,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
act(() => {
|
|
237
|
+
setProps("island-1", { count: 0 });
|
|
238
|
+
setSharedIslands({ "island-1": islandData });
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await waitFor(() => {
|
|
242
|
+
const wrapper = islandData.el.querySelector('[data-context="wrapper"]');
|
|
243
|
+
expect(wrapper).toBeInTheDocument();
|
|
244
|
+
expect(islandData.el).toHaveTextContent("Count: 0");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("updates when props change in store", async () => {
|
|
249
|
+
const { storeAccess, setSharedIslands, setProps } = createMockStoreAccess();
|
|
250
|
+
|
|
251
|
+
render(<PortalIslandsRenderer storeAccess={storeAccess} />);
|
|
252
|
+
|
|
253
|
+
const islandData = createMockIslandData({ id: "island-1" });
|
|
254
|
+
|
|
255
|
+
act(() => {
|
|
256
|
+
setProps("island-1", { count: 0 });
|
|
257
|
+
setSharedIslands({ "island-1": islandData });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
await waitFor(() => {
|
|
261
|
+
expect(islandData.el).toHaveTextContent("Count: 0");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
act(() => {
|
|
265
|
+
setProps("island-1", { count: 5 });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await waitFor(() => {
|
|
269
|
+
expect(islandData.el).toHaveTextContent("Count: 5");
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("updates when globals change in store", async () => {
|
|
274
|
+
const { storeAccess, setSharedIslands, setProps, setGlobals } = createMockStoreAccess();
|
|
275
|
+
|
|
276
|
+
const MockComponent = ({ user }: any) => <div>User: {user}</div>;
|
|
277
|
+
|
|
278
|
+
render(<PortalIslandsRenderer storeAccess={storeAccess} />);
|
|
279
|
+
|
|
280
|
+
const islandData = createMockIslandData({
|
|
281
|
+
id: "island-1",
|
|
282
|
+
Component: MockComponent,
|
|
283
|
+
globalKeys: ["user"],
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
act(() => {
|
|
287
|
+
setGlobals({ user: "Alice", __version: 1 });
|
|
288
|
+
setProps("island-1", {});
|
|
289
|
+
setSharedIslands({ "island-1": islandData });
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
await waitFor(() => {
|
|
293
|
+
expect(islandData.el).toHaveTextContent("User: Alice");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
act(() => {
|
|
297
|
+
setGlobals({ user: "Bob", __version: 2 });
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await waitFor(() => {
|
|
301
|
+
expect(islandData.el).toHaveTextContent("User: Bob");
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import React, { useRef, memo, useSyncExternalStore, useMemo } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import type {
|
|
4
|
+
IslandData,
|
|
5
|
+
SharedIslandsRendererProps,
|
|
6
|
+
IslandStoreAccess,
|
|
7
|
+
} from "./types";
|
|
8
|
+
import { IslandContextProvider } from "./context";
|
|
9
|
+
|
|
10
|
+
const PortalIsland = memo(
|
|
11
|
+
({
|
|
12
|
+
data,
|
|
13
|
+
storeAccess,
|
|
14
|
+
}: {
|
|
15
|
+
data: IslandData;
|
|
16
|
+
storeAccess: IslandStoreAccess;
|
|
17
|
+
}) => {
|
|
18
|
+
const {
|
|
19
|
+
Component,
|
|
20
|
+
ContextProvider,
|
|
21
|
+
id,
|
|
22
|
+
pushEvent,
|
|
23
|
+
ssrStrategy,
|
|
24
|
+
el,
|
|
25
|
+
globalKeys,
|
|
26
|
+
} = data;
|
|
27
|
+
|
|
28
|
+
const MemoizedComponent = useMemo(() => memo(Component), [Component]);
|
|
29
|
+
const MemoizedContextProvider = useMemo(
|
|
30
|
+
() => (ContextProvider ? memo(ContextProvider) : null),
|
|
31
|
+
[ContextProvider]
|
|
32
|
+
);
|
|
33
|
+
const contextValue = useMemo(
|
|
34
|
+
() => ({ id, storeAccess }),
|
|
35
|
+
[id, storeAccess]
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const currentProps = useSyncExternalStore(
|
|
39
|
+
storeAccess.subscribeToProps,
|
|
40
|
+
() => storeAccess.getProps(id),
|
|
41
|
+
() => null
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const allGlobals = useSyncExternalStore(
|
|
45
|
+
storeAccess.subscribeToGlobals,
|
|
46
|
+
storeAccess.getGlobals,
|
|
47
|
+
() => null
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const didClearRef = useRef(false);
|
|
51
|
+
if (ssrStrategy === "overwrite" && !didClearRef.current) {
|
|
52
|
+
el.innerHTML = "";
|
|
53
|
+
didClearRef.current = true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!allGlobals) {
|
|
57
|
+
console.log("[PortalIsland] Waiting for globals", id);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log("[PortalIsland] Rendering", { id, allGlobals });
|
|
62
|
+
|
|
63
|
+
const relevantGlobals: Record<string, any> = {};
|
|
64
|
+
if (allGlobals) {
|
|
65
|
+
globalKeys.forEach((key) => {
|
|
66
|
+
if (key in allGlobals) {
|
|
67
|
+
relevantGlobals[key] = allGlobals[key];
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const mergedProps = { ...relevantGlobals, ...currentProps };
|
|
73
|
+
|
|
74
|
+
const islandNode = (
|
|
75
|
+
<MemoizedComponent {...mergedProps} id={id} pushEvent={pushEvent} />
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const withContext = (
|
|
79
|
+
<IslandContextProvider.Provider value={contextValue}>
|
|
80
|
+
{islandNode}
|
|
81
|
+
</IslandContextProvider.Provider>
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const content = MemoizedContextProvider ? (
|
|
85
|
+
<MemoizedContextProvider>{withContext}</MemoizedContextProvider>
|
|
86
|
+
) : (
|
|
87
|
+
withContext
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return createPortal(content, el);
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
PortalIsland.displayName = "[ReactLiveIslands]PortalIsland";
|
|
94
|
+
|
|
95
|
+
export const PortalIslandsRenderer: React.FC<SharedIslandsRendererProps> = ({
|
|
96
|
+
storeAccess,
|
|
97
|
+
}) => {
|
|
98
|
+
const sharedIslandsObj = useSyncExternalStore(
|
|
99
|
+
storeAccess.subscribeToSharedIslands,
|
|
100
|
+
storeAccess.getSharedIslands
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const islands = Object.values(sharedIslandsObj);
|
|
104
|
+
|
|
105
|
+
console.log(
|
|
106
|
+
"[PortalIslandsRenderer] Rendering islands:",
|
|
107
|
+
islands.map((d) => d.id)
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<>
|
|
112
|
+
{islands.map((data) => (
|
|
113
|
+
<PortalIsland key={data.id} data={data} storeAccess={storeAccess} />
|
|
114
|
+
))}
|
|
115
|
+
</>
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
PortalIslandsRenderer.displayName = "[ReactLiveIslands]PortalIslandsRenderer";
|
package/src/context.ts
ADDED