@rmc-toolkit/react 0.1.0 → 0.2.3
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/README.md +86 -0
- package/dist/create-dynamic-module-boundary.d.ts +13 -0
- package/dist/create-dynamic-module-boundary.d.ts.map +1 -0
- package/dist/create-dynamic-module-boundary.js +65 -0
- package/dist/create-react-adapter.d.ts +11 -0
- package/dist/create-react-adapter.d.ts.map +1 -0
- package/dist/create-react-adapter.js +45 -0
- package/dist/create-react-adapter.test.d.ts +2 -0
- package/dist/create-react-adapter.test.d.ts.map +1 -0
- package/dist/create-react-adapter.test.js +74 -0
- package/dist/index.d.ts +4 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -26
- package/dist/index.test.js +34 -1
- package/package.json +8 -3
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# @rmc-toolkit/react
|
|
2
|
+
|
|
3
|
+
React adapter for [Runtime Module Composition](https://runtime-module-composition.dev). Two factories, for two different slice conventions — pick the one matching how your slices are written, not both for the same slice. Both take your app's own already-resolved `React` instance rather than importing one themselves, so this package never bundles a second, conflicting copy of React.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @rmc-toolkit/react @rmc-toolkit/core react
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick example
|
|
12
|
+
|
|
13
|
+
**Slices share the `mount()`/`unmount()` convention:** use `createReactAdapter`, which wraps the resolve/import/mount lifecycle in a `useRuntimeHost` hook:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// src/rmc-adapter.ts
|
|
17
|
+
import React from "react";
|
|
18
|
+
import { createReactAdapter } from "@rmc-toolkit/react";
|
|
19
|
+
|
|
20
|
+
export const { useRuntimeHost } = createReactAdapter(React);
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
// App.tsx
|
|
25
|
+
import { useLocation } from "react-router-dom";
|
|
26
|
+
import { useRuntimeHost } from "./rmc-adapter";
|
|
27
|
+
import { manifest } from "./runtime-composition.manifest";
|
|
28
|
+
|
|
29
|
+
function App() {
|
|
30
|
+
const location = useLocation();
|
|
31
|
+
const { ref, status } = useRuntimeHost<HTMLElement>(location.pathname, { manifest });
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="app-shell">
|
|
35
|
+
<SiteHeader loading={status.type === "loading"} />
|
|
36
|
+
<main ref={ref} />
|
|
37
|
+
<SiteFooter />
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**Slices are plain default-exported components (not `mount()`/`unmount()`):** use `createDynamicModuleBoundary` instead:
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
// src/rmc-adapter.ts
|
|
47
|
+
import React from "react";
|
|
48
|
+
import { createDynamicModuleBoundary } from "@rmc-toolkit/react";
|
|
49
|
+
|
|
50
|
+
export const { DynamicModuleBoundary } = createDynamicModuleBoundary(React);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
// RouteSlot.tsx
|
|
55
|
+
import { resolveRoute } from "@rmc-toolkit/core";
|
|
56
|
+
import { DynamicModuleBoundary } from "./rmc-adapter";
|
|
57
|
+
import { manifest } from "./runtime-composition.manifest";
|
|
58
|
+
|
|
59
|
+
export function RouteSlot() {
|
|
60
|
+
const match = resolveRoute(manifest, window.location.pathname);
|
|
61
|
+
if (!match) return null;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<DynamicModuleBoundary
|
|
65
|
+
specifier={match.specifier}
|
|
66
|
+
context={{ route: match, manifest }}
|
|
67
|
+
fallback={<div>Loading...</div>}
|
|
68
|
+
errorFallback={<div>Unable to load this section.</div>}
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## What's in here
|
|
75
|
+
|
|
76
|
+
- `createReactAdapter(React)` → `{ useRuntimeHost }` — a hook wrapping `createRuntimeHostObservable`'s resolve/import/mount/unmount lifecycle, with loading/error status.
|
|
77
|
+
- `createDynamicModuleBoundary(React)` → `{ DynamicModuleBoundary }` — a `Suspense` + error-boundary component around `React.lazy()`-loading a slice's default-exported component.
|
|
78
|
+
|
|
79
|
+
Full signatures and behavior for both: [API Reference](https://runtime-module-composition.dev/api-reference/#react-adapter-rmc-toolkitreact).
|
|
80
|
+
|
|
81
|
+
## Documentation
|
|
82
|
+
|
|
83
|
+
- [Getting Started](https://runtime-module-composition.dev/getting-started/) — install and wire up a host + slice end to end
|
|
84
|
+
- [API Reference](https://runtime-module-composition.dev/api-reference/#react-adapter-rmc-toolkitreact)
|
|
85
|
+
- [Technical Implementation](https://runtime-module-composition.dev/technical-implementation/) — the architecture and failure modes behind the pattern
|
|
86
|
+
- [Multi-Framework Demo](https://runtime-module-composition.dev/demo/) — a full, runnable reference implementation, including a React slice
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type * as ReactNamespace from "react";
|
|
2
|
+
import { type DynamicImporter, type RuntimeModuleContext } from "@rmc-toolkit/core";
|
|
3
|
+
export type DynamicModuleBoundaryProps = {
|
|
4
|
+
specifier: string;
|
|
5
|
+
context?: RuntimeModuleContext;
|
|
6
|
+
fallback?: ReactNamespace.ReactNode;
|
|
7
|
+
errorFallback?: ReactNamespace.ReactNode;
|
|
8
|
+
importer?: DynamicImporter;
|
|
9
|
+
};
|
|
10
|
+
export declare const createDynamicModuleBoundary: (React: typeof ReactNamespace) => {
|
|
11
|
+
DynamicModuleBoundary: (props: DynamicModuleBoundaryProps) => ReactNamespace.ReactElement;
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=create-dynamic-module-boundary.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-dynamic-module-boundary.d.ts","sourceRoot":"","sources":["../src/create-dynamic-module-boundary.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,cAAc,MAAM,OAAO,CAAC;AAC7C,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,oBAAoB,EAC1B,MAAM,mBAAmB,CAAC;AAM3B,MAAM,MAAM,0BAA0B,GAAG;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,oBAAoB,CAAC;IAC/B,QAAQ,CAAC,EAAE,cAAc,CAAC,SAAS,CAAC;IACpC,aAAa,CAAC,EAAE,cAAc,CAAC,SAAS,CAAC;IACzC,QAAQ,CAAC,EAAE,eAAe,CAAC;CAC5B,CAAC;AAYF,eAAO,MAAM,2BAA2B,GACtC,OAAO,OAAO,cAAc,KAC3B;IACD,qBAAqB,EAAE,CAAC,KAAK,EAAE,0BAA0B,KAAK,cAAc,CAAC,YAAY,CAAC;CAiF3F,CAAC"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { importModule, } from "@rmc-toolkit/core";
|
|
2
|
+
const defaultImporter = importModule;
|
|
3
|
+
// This file is intentionally `.ts`, not `.tsx`: with "jsx": "react-jsx" in
|
|
4
|
+
// tsconfig.json, any JSX syntax in a file makes the compiler auto-inject a
|
|
5
|
+
// runtime `import ... from "react/jsx-runtime"` that never appears in source.
|
|
6
|
+
// That import would be just as unreconcilable with a host app's own React
|
|
7
|
+
// instance as the top-level `import React from "react"` this factory is
|
|
8
|
+
// fixing. So every element below is built with React.createElement(...)
|
|
9
|
+
// (exactly what JSX desugars to) using the React namespace passed in as a
|
|
10
|
+
// parameter, never a module-level import of "react" as a value.
|
|
11
|
+
export const createDynamicModuleBoundary = (React) => {
|
|
12
|
+
// ErrorBoundary is redefined on every createDynamicModuleBoundary call
|
|
13
|
+
// rather than hoisted out as a module-level class: it extends the
|
|
14
|
+
// injected `React.Component`, and React class instances/statics
|
|
15
|
+
// (getDerivedStateFromError, etc.) are only valid against the exact
|
|
16
|
+
// React copy they were built from. Threading a second DI parameter
|
|
17
|
+
// through just for this base class would be over-engineering -- these
|
|
18
|
+
// factories are called once per host app at module scope, so redefining
|
|
19
|
+
// the class here has no meaningful runtime cost.
|
|
20
|
+
class ErrorBoundary extends React.Component {
|
|
21
|
+
state = { error: null };
|
|
22
|
+
static getDerivedStateFromError(error) {
|
|
23
|
+
return { error };
|
|
24
|
+
}
|
|
25
|
+
render() {
|
|
26
|
+
if (this.state.error) {
|
|
27
|
+
return this.props.fallback;
|
|
28
|
+
}
|
|
29
|
+
return this.props.children;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const DynamicModuleBoundary = ({ specifier, context, fallback = null, errorFallback = null, importer = defaultImporter, }) => {
|
|
33
|
+
// Memoized by specifier/importer: React.lazy() must return the same
|
|
34
|
+
// component type across renders, or React treats it as a brand-new
|
|
35
|
+
// component and remounts (re-suspending) on every parent re-render.
|
|
36
|
+
const LazyModule = React.useMemo(() => React.lazy(async () => {
|
|
37
|
+
const loadedModule = (await importer(specifier));
|
|
38
|
+
return loadedModule;
|
|
39
|
+
}), [specifier, importer]);
|
|
40
|
+
const moduleProps = context === undefined
|
|
41
|
+
? {}
|
|
42
|
+
: { context };
|
|
43
|
+
// Equivalent to:
|
|
44
|
+
// <React.Suspense fallback={fallback}>
|
|
45
|
+
// <ErrorBoundary fallback={errorFallback}>
|
|
46
|
+
// <LazyModule {...moduleProps} />
|
|
47
|
+
// </ErrorBoundary>
|
|
48
|
+
// </React.Suspense>
|
|
49
|
+
//
|
|
50
|
+
// `children` is passed inside ErrorBoundary's props object (rather than
|
|
51
|
+
// as a third createElement argument) to satisfy TypeScript's overload
|
|
52
|
+
// resolution, since ErrorBoundary's props type declares `children` as
|
|
53
|
+
// required -- it is the same substitution JSX itself performs, not an
|
|
54
|
+
// accidental extra prop.
|
|
55
|
+
return React.createElement(React.Suspense, { fallback }, React.createElement(ErrorBoundary, {
|
|
56
|
+
// Keyed by specifier so switching modules mounts a fresh
|
|
57
|
+
// ErrorBoundary instance instead of reusing one still holding a
|
|
58
|
+
// previous module's error state.
|
|
59
|
+
key: specifier,
|
|
60
|
+
fallback: errorFallback,
|
|
61
|
+
children: React.createElement(LazyModule, moduleProps),
|
|
62
|
+
}));
|
|
63
|
+
};
|
|
64
|
+
return { DynamicModuleBoundary };
|
|
65
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type * as ReactNamespace from "react";
|
|
2
|
+
import { type RuntimeHostObservableOptions, type RuntimeHostStatus } from "@rmc-toolkit/core";
|
|
3
|
+
export type UseRuntimeHostOptions = Omit<RuntimeHostObservableOptions, "target">;
|
|
4
|
+
export type UseRuntimeHostResult<T extends Element> = {
|
|
5
|
+
ref: ReactNamespace.RefObject<T | null>;
|
|
6
|
+
status: RuntimeHostStatus;
|
|
7
|
+
};
|
|
8
|
+
export declare const createReactAdapter: (React: typeof ReactNamespace) => {
|
|
9
|
+
useRuntimeHost<T extends Element>(path: string, options: UseRuntimeHostOptions): UseRuntimeHostResult<T>;
|
|
10
|
+
};
|
|
11
|
+
//# sourceMappingURL=create-react-adapter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-react-adapter.d.ts","sourceRoot":"","sources":["../src/create-react-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,cAAc,MAAM,OAAO,CAAC;AAC7C,OAAO,EAGL,KAAK,4BAA4B,EACjC,KAAK,iBAAiB,EACvB,MAAM,mBAAmB,CAAC;AAE3B,MAAM,MAAM,qBAAqB,GAAG,IAAI,CAAC,4BAA4B,EAAE,QAAQ,CAAC,CAAC;AAEjF,MAAM,MAAM,oBAAoB,CAAC,CAAC,SAAS,OAAO,IAAI;IACpD,GAAG,EAAE,cAAc,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IACxC,MAAM,EAAE,iBAAiB,CAAC;CAC3B,CAAC;AAQF,eAAO,MAAM,kBAAkB,GAC7B,OAAO,OAAO,cAAc,KAC3B;IACD,cAAc,CAAC,CAAC,SAAS,OAAO,EAC9B,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,qBAAqB,GAC7B,oBAAoB,CAAC,CAAC,CAAC,CAAC;CAoD5B,CAAC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createRuntimeHostObservable, } from "@rmc-toolkit/core";
|
|
2
|
+
// Shared reference, not an inline `{ type: "idle" }` literal: useSyncExternalStore
|
|
3
|
+
// compares getSnapshot()'s return value by reference and treats any change as "the
|
|
4
|
+
// store changed". A fresh object on every call (before the observable exists) would
|
|
5
|
+
// look like a change on every render and cause an infinite re-render loop.
|
|
6
|
+
const idleStatus = { type: "idle" };
|
|
7
|
+
export const createReactAdapter = (React) => {
|
|
8
|
+
const useRuntimeHost = (path, options) => {
|
|
9
|
+
const ref = React.useRef(null);
|
|
10
|
+
const observableRef = React.useRef(null);
|
|
11
|
+
React.useEffect(() => {
|
|
12
|
+
if (!ref.current) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const observable = createRuntimeHostObservable({ ...options, target: ref.current });
|
|
16
|
+
observableRef.current = observable;
|
|
17
|
+
return () => {
|
|
18
|
+
void observable.destroy();
|
|
19
|
+
observableRef.current = null;
|
|
20
|
+
};
|
|
21
|
+
}, []);
|
|
22
|
+
// Relies on React running a component's effects in declaration order on mount:
|
|
23
|
+
// the creation effect above must run first and populate observableRef.current
|
|
24
|
+
// before this effect's initial run, so the first next(path) call isn't dropped.
|
|
25
|
+
// Do not reorder these effects or merge them into one.
|
|
26
|
+
React.useEffect(() => {
|
|
27
|
+
observableRef.current?.next(path);
|
|
28
|
+
}, [path]);
|
|
29
|
+
// Re-reads observableRef.current lazily on each call rather than capturing the
|
|
30
|
+
// observable once: capturing it here would keep referencing a destroyed
|
|
31
|
+
// observable across a remount or a Strict-Mode mount/unmount/mount cycle, since
|
|
32
|
+
// this callback (memoized with an empty dep array) is not recreated when the
|
|
33
|
+
// creation effect re-runs and assigns a new observable to the ref.
|
|
34
|
+
const subscribe = React.useCallback((onStoreChange) => {
|
|
35
|
+
if (!observableRef.current) {
|
|
36
|
+
return () => { };
|
|
37
|
+
}
|
|
38
|
+
return observableRef.current.subscribe(() => onStoreChange());
|
|
39
|
+
}, []);
|
|
40
|
+
const getSnapshot = React.useCallback(() => observableRef.current?.getSnapshot() ?? idleStatus, []);
|
|
41
|
+
const status = React.useSyncExternalStore(subscribe, getSnapshot);
|
|
42
|
+
return { ref, status };
|
|
43
|
+
};
|
|
44
|
+
return { useRuntimeHost };
|
|
45
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-react-adapter.test.d.ts","sourceRoot":"","sources":["../src/create-react-adapter.test.tsx"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, test, vi } from "vitest";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { act } from "react";
|
|
5
|
+
import { createRoot } from "react-dom/client";
|
|
6
|
+
const observers = new Set();
|
|
7
|
+
let currentStatus = { type: "idle" };
|
|
8
|
+
const mockNext = vi.fn((path) => {
|
|
9
|
+
currentStatus = { type: "loading", path };
|
|
10
|
+
observers.forEach((observer) => observer(currentStatus));
|
|
11
|
+
currentStatus = { type: "ready", path };
|
|
12
|
+
observers.forEach((observer) => observer(currentStatus));
|
|
13
|
+
});
|
|
14
|
+
const mockDestroy = vi.fn(async () => { });
|
|
15
|
+
const mockCreateRuntimeHostObservable = vi.fn((_options) => ({
|
|
16
|
+
next: mockNext,
|
|
17
|
+
destroy: mockDestroy,
|
|
18
|
+
subscribe: (observer) => {
|
|
19
|
+
observers.add(observer);
|
|
20
|
+
return () => observers.delete(observer);
|
|
21
|
+
},
|
|
22
|
+
getSnapshot: () => currentStatus,
|
|
23
|
+
}));
|
|
24
|
+
vi.mock("@rmc-toolkit/core", () => ({
|
|
25
|
+
createRuntimeHostObservable: (options) => mockCreateRuntimeHostObservable(options),
|
|
26
|
+
}));
|
|
27
|
+
const { createReactAdapter } = await import("./create-react-adapter.js");
|
|
28
|
+
const manifest = { namespace: "@acme", assetsOrigin: "https://assets.example.com" };
|
|
29
|
+
describe("createReactAdapter", () => {
|
|
30
|
+
test("creates one observable per mounted component, forwards path to next(), and surfaces status", async () => {
|
|
31
|
+
const { useRuntimeHost } = createReactAdapter(React);
|
|
32
|
+
let renderedStatus;
|
|
33
|
+
const TestComponent = ({ path }) => {
|
|
34
|
+
const { ref, status } = useRuntimeHost(path, { manifest });
|
|
35
|
+
renderedStatus = status;
|
|
36
|
+
return React.createElement("div", { ref });
|
|
37
|
+
};
|
|
38
|
+
const container = document.createElement("div");
|
|
39
|
+
const root = createRoot(container);
|
|
40
|
+
await act(async () => {
|
|
41
|
+
root.render(React.createElement(TestComponent, { path: "/search" }));
|
|
42
|
+
});
|
|
43
|
+
expect(mockCreateRuntimeHostObservable).toHaveBeenCalledTimes(1);
|
|
44
|
+
expect(mockNext).toHaveBeenCalledWith("/search");
|
|
45
|
+
expect(renderedStatus).toEqual({ type: "ready", path: "/search" });
|
|
46
|
+
await act(async () => {
|
|
47
|
+
root.unmount();
|
|
48
|
+
});
|
|
49
|
+
expect(mockDestroy).toHaveBeenCalledTimes(1);
|
|
50
|
+
});
|
|
51
|
+
test("forwards a new path to next() when the path prop changes, without recreating the observable", async () => {
|
|
52
|
+
mockCreateRuntimeHostObservable.mockClear();
|
|
53
|
+
mockNext.mockClear();
|
|
54
|
+
const { useRuntimeHost } = createReactAdapter(React);
|
|
55
|
+
const TestComponent = ({ path }) => {
|
|
56
|
+
const { ref } = useRuntimeHost(path, { manifest });
|
|
57
|
+
return React.createElement("div", { ref });
|
|
58
|
+
};
|
|
59
|
+
const container = document.createElement("div");
|
|
60
|
+
const root = createRoot(container);
|
|
61
|
+
await act(async () => {
|
|
62
|
+
root.render(React.createElement(TestComponent, { path: "/search" }));
|
|
63
|
+
});
|
|
64
|
+
await act(async () => {
|
|
65
|
+
root.render(React.createElement(TestComponent, { path: "/cart" }));
|
|
66
|
+
});
|
|
67
|
+
expect(mockCreateRuntimeHostObservable).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(mockNext).toHaveBeenNthCalledWith(1, "/search");
|
|
69
|
+
expect(mockNext).toHaveBeenNthCalledWith(2, "/cart");
|
|
70
|
+
await act(async () => {
|
|
71
|
+
root.unmount();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
context?: RuntimeModuleContext;
|
|
6
|
-
fallback?: React.ReactNode;
|
|
7
|
-
errorFallback?: React.ReactNode;
|
|
8
|
-
importer?: DynamicImporter;
|
|
9
|
-
};
|
|
10
|
-
export declare const DynamicModuleBoundary: ({ specifier, context, fallback, errorFallback, importer, }: DynamicModuleBoundaryProps) => React.ReactElement;
|
|
1
|
+
export { createDynamicModuleBoundary } from "./create-dynamic-module-boundary.js";
|
|
2
|
+
export type { DynamicModuleBoundaryProps } from "./create-dynamic-module-boundary.js";
|
|
3
|
+
export { createReactAdapter } from "./create-react-adapter.js";
|
|
4
|
+
export type { UseRuntimeHostOptions, UseRuntimeHostResult, } from "./create-react-adapter.js";
|
|
11
5
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,2BAA2B,EAAE,MAAM,qCAAqC,CAAC;AAClF,YAAY,EAAE,0BAA0B,EAAE,MAAM,qCAAqC,CAAC;AAEtF,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAC/D,YAAY,EACV,qBAAqB,EACrB,oBAAoB,GACrB,MAAM,2BAA2B,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,26 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import React from "react";
|
|
4
|
-
class ErrorBoundary extends React.Component {
|
|
5
|
-
state = { error: null };
|
|
6
|
-
static getDerivedStateFromError(error) {
|
|
7
|
-
return { error };
|
|
8
|
-
}
|
|
9
|
-
render() {
|
|
10
|
-
if (this.state.error) {
|
|
11
|
-
return this.props.fallback;
|
|
12
|
-
}
|
|
13
|
-
return this.props.children;
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
const defaultImporter = importModule;
|
|
17
|
-
export const DynamicModuleBoundary = ({ specifier, context, fallback = null, errorFallback = null, importer = defaultImporter, }) => {
|
|
18
|
-
const LazyModule = React.lazy(async () => {
|
|
19
|
-
const loadedModule = (await importer(specifier));
|
|
20
|
-
return loadedModule;
|
|
21
|
-
});
|
|
22
|
-
const moduleProps = context === undefined
|
|
23
|
-
? {}
|
|
24
|
-
: { context };
|
|
25
|
-
return (_jsx(React.Suspense, { fallback: fallback, children: _jsx(ErrorBoundary, { fallback: errorFallback, children: _jsx(LazyModule, { ...moduleProps }) }) }));
|
|
26
|
-
};
|
|
1
|
+
export { createDynamicModuleBoundary } from "./create-dynamic-module-boundary.js";
|
|
2
|
+
export { createReactAdapter } from "./create-react-adapter.js";
|
package/dist/index.test.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
// @vitest-environment happy-dom
|
|
3
3
|
import { describe, expect, test, vi } from "vitest";
|
|
4
|
+
import * as React from "react";
|
|
4
5
|
import { act } from "react";
|
|
5
6
|
import { createRoot } from "react-dom/client";
|
|
6
7
|
const FixtureComponent = () => "fixture content";
|
|
@@ -10,7 +11,8 @@ const mockImportModule = vi.fn(async () => ({
|
|
|
10
11
|
vi.mock("@rmc-toolkit/core", () => ({
|
|
11
12
|
importModule: mockImportModule,
|
|
12
13
|
}));
|
|
13
|
-
const {
|
|
14
|
+
const { createDynamicModuleBoundary } = await import("./index.js");
|
|
15
|
+
const { DynamicModuleBoundary } = createDynamicModuleBoundary(React);
|
|
14
16
|
describe("DynamicModuleBoundary", () => {
|
|
15
17
|
test("default importer delegates to core's importModule", async () => {
|
|
16
18
|
const container = document.createElement("div");
|
|
@@ -22,4 +24,35 @@ describe("DynamicModuleBoundary", () => {
|
|
|
22
24
|
expect(container.textContent).toBe("fixture content");
|
|
23
25
|
root.unmount();
|
|
24
26
|
});
|
|
27
|
+
test("createDynamicModuleBoundary is injected with the caller's own React instance, not an internally imported one", async () => {
|
|
28
|
+
mockImportModule.mockClear();
|
|
29
|
+
// Two independently-created factories, each given its own React-like
|
|
30
|
+
// namespace object. If the implementation secretly imported "react"
|
|
31
|
+
// itself instead of using the injected parameter, both boundaries would
|
|
32
|
+
// still work identically and this test would give no signal either way
|
|
33
|
+
// -- that create-dynamic-module-boundary.ts has no runtime `import` of
|
|
34
|
+
// "react" is a static fact, verifiable by inspection of its import
|
|
35
|
+
// statements, not something a render test can detect. What this test
|
|
36
|
+
// does prove is that the DI parameter is actually wired through to
|
|
37
|
+
// produce a working component for whichever React instance is passed,
|
|
38
|
+
// by creating two boundaries from two separate calls and rendering both.
|
|
39
|
+
const { DynamicModuleBoundary: BoundaryA } = createDynamicModuleBoundary(React);
|
|
40
|
+
const { DynamicModuleBoundary: BoundaryB } = createDynamicModuleBoundary(React);
|
|
41
|
+
const containerA = document.createElement("div");
|
|
42
|
+
const rootA = createRoot(containerA);
|
|
43
|
+
const containerB = document.createElement("div");
|
|
44
|
+
const rootB = createRoot(containerB);
|
|
45
|
+
await act(async () => {
|
|
46
|
+
rootA.render(_jsx(BoundaryA, { specifier: "specifier-a" }));
|
|
47
|
+
});
|
|
48
|
+
await act(async () => {
|
|
49
|
+
rootB.render(_jsx(BoundaryB, { specifier: "specifier-b" }));
|
|
50
|
+
});
|
|
51
|
+
expect(mockImportModule).toHaveBeenCalledWith("specifier-a");
|
|
52
|
+
expect(mockImportModule).toHaveBeenCalledWith("specifier-b");
|
|
53
|
+
expect(containerA.textContent).toBe("fixture content");
|
|
54
|
+
expect(containerB.textContent).toBe("fixture content");
|
|
55
|
+
rootA.unmount();
|
|
56
|
+
rootB.unmount();
|
|
57
|
+
});
|
|
25
58
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmc-toolkit/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/runtime-module-composition/rmc-toolkit",
|
|
8
|
+
"directory": "packages/react"
|
|
9
|
+
},
|
|
5
10
|
"main": "./dist/index.js",
|
|
6
11
|
"types": "./dist/index.d.ts",
|
|
7
12
|
"exports": {
|
|
@@ -17,11 +22,11 @@
|
|
|
17
22
|
"access": "public"
|
|
18
23
|
},
|
|
19
24
|
"peerDependencies": {
|
|
20
|
-
"@rmc-toolkit/core": "0.
|
|
25
|
+
"@rmc-toolkit/core": "0.2.3",
|
|
21
26
|
"react": ">=18"
|
|
22
27
|
},
|
|
23
28
|
"dependencies": {
|
|
24
|
-
"@rmc-toolkit/core": "0.
|
|
29
|
+
"@rmc-toolkit/core": "0.2.3"
|
|
25
30
|
},
|
|
26
31
|
"scripts": {
|
|
27
32
|
"build": "tsc -b"
|