@rmc-toolkit/react 0.1.0 → 0.2.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.
@@ -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;CAsE3F,CAAC"}
@@ -0,0 +1,58 @@
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
+ const LazyModule = React.lazy(async () => {
34
+ const loadedModule = (await importer(specifier));
35
+ return loadedModule;
36
+ });
37
+ const moduleProps = context === undefined
38
+ ? {}
39
+ : { context };
40
+ // Equivalent to:
41
+ // <React.Suspense fallback={fallback}>
42
+ // <ErrorBoundary fallback={errorFallback}>
43
+ // <LazyModule {...moduleProps} />
44
+ // </ErrorBoundary>
45
+ // </React.Suspense>
46
+ //
47
+ // `children` is passed inside ErrorBoundary's props object (rather than
48
+ // as a third createElement argument) to satisfy TypeScript's overload
49
+ // resolution, since ErrorBoundary's props type declares `children` as
50
+ // required -- it is the same substitution JSX itself performs, not an
51
+ // accidental extra prop.
52
+ return React.createElement(React.Suspense, { fallback }, React.createElement(ErrorBoundary, {
53
+ fallback: errorFallback,
54
+ children: React.createElement(LazyModule, moduleProps),
55
+ }));
56
+ };
57
+ return { DynamicModuleBoundary };
58
+ };
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=create-react-adapter.test.d.ts.map
@@ -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
- import { type DynamicImporter, type RuntimeModuleContext } from "@rmc-toolkit/core";
2
- import React from "react";
3
- export type DynamicModuleBoundaryProps = {
4
- specifier: string;
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
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,oBAAoB,EAC1B,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,MAAM,OAAO,CAAC;AAM1B,MAAM,MAAM,0BAA0B,GAAG;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,oBAAoB,CAAC;IAC/B,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B,aAAa,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAChC,QAAQ,CAAC,EAAE,eAAe,CAAC;CAC5B,CAAC;AA2BF,eAAO,MAAM,qBAAqB,GAAI,4DAMnC,0BAA0B,KAAG,KAAK,CAAC,YAiBrC,CAAC"}
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
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { importModule, } from "@rmc-toolkit/core";
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";
@@ -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 { DynamicModuleBoundary } = await import("./index.js");
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmc-toolkit/react",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -17,11 +17,11 @@
17
17
  "access": "public"
18
18
  },
19
19
  "peerDependencies": {
20
- "@rmc-toolkit/core": "0.1.0",
20
+ "@rmc-toolkit/core": "0.2.0",
21
21
  "react": ">=18"
22
22
  },
23
23
  "dependencies": {
24
- "@rmc-toolkit/core": "0.1.0"
24
+ "@rmc-toolkit/core": "0.2.0"
25
25
  },
26
26
  "scripts": {
27
27
  "build": "tsc -b"