@pushwoosh/spa-context 3.4.0 → 3.5.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,23 @@
1
+ import { type FormEvent, type ReactNode } from 'react';
2
+ export type RemoteComponentProps = {
3
+ /** Import-map name of the module. */
4
+ module: string;
5
+ /** Named export of the module; must be a React component. */
6
+ component: string;
7
+ /** Rendered while the module loads. */
8
+ fallback?: ReactNode;
9
+ /** Rendered when the component throws while rendering. */
10
+ errorFallback?: ReactNode;
11
+ /** A failed import, a missing or non-component export, or a render error. */
12
+ onError?: (error: Error) => void;
13
+ /** The component has rendered. */
14
+ onReady?: () => void;
15
+ [prop: string]: unknown;
16
+ };
17
+ /** Renders a named export of an import-map module in the consumer's React tree, with the consumer's spaContext. */
18
+ export declare const RemoteComponent: ({ module, component, fallback, errorFallback, onError, onReady, ...props }: RemoteComponentProps) => import("react").DetailedReactHTMLElement<{
19
+ style: {
20
+ readonly display: "contents";
21
+ };
22
+ onSubmit: (event: FormEvent) => void;
23
+ }, HTMLElement>;
@@ -0,0 +1,58 @@
1
+ import { createElement, useEffect } from 'react';
2
+ import { SPAContext } from './context';
3
+ import { DefaultErrorFallback, RemoteErrorBoundary } from './RemoteErrorBoundary';
4
+ import { useRemoteComponent } from './useRemoteComponent';
5
+ import { useSPAContext } from './useSPAContext';
6
+ // No JSX here or in RemoteErrorBoundary: the CDN bundle keeps importing only `react`.
7
+ const WRAPPER_STYLE = {
8
+ display: 'contents'
9
+ };
10
+ // A form inside the remote component, portals included, must not submit the consumer's form.
11
+ const stopSubmit = event => event.stopPropagation();
12
+ const Ready = ({
13
+ onReady
14
+ }) => {
15
+ useEffect(() => {
16
+ onReady();
17
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
18
+ return null;
19
+ };
20
+ /** Renders a named export of an import-map module in the consumer's React tree, with the consumer's spaContext. */
21
+ export const RemoteComponent = ({
22
+ module,
23
+ component,
24
+ fallback = null,
25
+ errorFallback = createElement(DefaultErrorFallback),
26
+ onError,
27
+ onReady,
28
+ ...props
29
+ }) => {
30
+ const spaContext = useSPAContext();
31
+ const state = useRemoteComponent(module, component);
32
+ useEffect(() => {
33
+ if (state.status !== 'failed') return;
34
+ console.error(state.error);
35
+ onError?.(state.error);
36
+ }, [state]); // eslint-disable-line react-hooks/exhaustive-deps
37
+ let content = null;
38
+ if (state.status === 'loading') content = fallback;
39
+ if (state.status === 'ready') {
40
+ content = createElement(RemoteErrorBoundary, {
41
+ key: `${module}\n${component}`,
42
+ module,
43
+ fallback: errorFallback,
44
+ onError: error => onError?.(error)
45
+ }, createElement(SPAContext, {
46
+ value: spaContext
47
+ }, createElement(state.Component, {
48
+ ...props,
49
+ spaContext
50
+ })), createElement(Ready, {
51
+ onReady: () => onReady?.()
52
+ }));
53
+ }
54
+ return createElement('div', {
55
+ style: WRAPPER_STYLE,
56
+ onSubmit: stopSubmit
57
+ }, content);
58
+ };
@@ -0,0 +1,30 @@
1
+ import { Component, type ErrorInfo, type ReactNode } from 'react';
2
+ export declare const DefaultErrorFallback: () => import("react").DetailedReactHTMLElement<{
3
+ style: {
4
+ display: "flex";
5
+ flexDirection: "column";
6
+ alignItems: "center";
7
+ justifyContent: "center";
8
+ maxWidth: number;
9
+ margin: string;
10
+ padding: number;
11
+ textAlign: "center";
12
+ };
13
+ }, HTMLElement>;
14
+ type RemoteErrorBoundaryProps = {
15
+ module: string;
16
+ fallback: ReactNode;
17
+ onError: (error: Error) => void;
18
+ children?: ReactNode;
19
+ };
20
+ export declare class RemoteErrorBoundary extends Component<RemoteErrorBoundaryProps, {
21
+ hasError: boolean;
22
+ }> {
23
+ constructor(props: RemoteErrorBoundaryProps);
24
+ static getDerivedStateFromError(): {
25
+ hasError: boolean;
26
+ };
27
+ componentDidCatch(error: Error, errorInfo: ErrorInfo): void;
28
+ render(): ReactNode;
29
+ }
30
+ export {};
@@ -0,0 +1,44 @@
1
+ import { Component, createElement } from 'react';
2
+ export const DefaultErrorFallback = () => createElement('div', {
3
+ style: {
4
+ display: 'flex',
5
+ flexDirection: 'column',
6
+ alignItems: 'center',
7
+ justifyContent: 'center',
8
+ maxWidth: 520,
9
+ margin: '64px auto',
10
+ padding: 24,
11
+ textAlign: 'center'
12
+ }
13
+ }, createElement('h2', {
14
+ style: {
15
+ margin: '0 0 8px',
16
+ fontSize: 20,
17
+ fontWeight: 500
18
+ }
19
+ }, 'Something went wrong'), createElement('p', {
20
+ style: {
21
+ margin: 0,
22
+ color: '#7C8595'
23
+ }
24
+ }, 'Please refresh the page or try again later.'));
25
+ export class RemoteErrorBoundary extends Component {
26
+ constructor(props) {
27
+ super(props);
28
+ this.state = {
29
+ hasError: false
30
+ };
31
+ }
32
+ static getDerivedStateFromError() {
33
+ return {
34
+ hasError: true
35
+ };
36
+ }
37
+ componentDidCatch(error, errorInfo) {
38
+ console.error(`RemoteComponent ${this.props.module} caught an error`, error, errorInfo);
39
+ this.props.onError(error);
40
+ }
41
+ render() {
42
+ return this.state.hasError ? this.props.fallback : this.props.children;
43
+ }
44
+ }
package/index.d.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  import { stateOfVersion } from './approvals';
2
2
  import type { ApprovalState, DecidedRequest, VersionState } from './approvals';
3
+ import { loadModule } from './loadModule';
4
+ import { RemoteComponent, type RemoteComponentProps } from './RemoteComponent';
3
5
  import { SimpleEventEmitter } from './SimpleEventEmitter';
4
6
  import type { Environment, SPAContextType } from './types';
5
7
  import { UniversalKeyValueStore } from './UniversalKeyValueStore';
6
8
  import { useEventValue } from './useEventValue';
7
9
  import { useMemoryKey, useMemoryKeySlim } from './useMemoryKey';
10
+ import { type RemoteComponentState, useRemoteComponent } from './useRemoteComponent';
8
11
  import { useSession } from './useSession';
9
12
  import { useSPAContext } from './useSPAContext';
10
- export type { Environment, SPAContextType, ApprovalState, DecidedRequest, VersionState };
11
- export { useSPAContext, useSession, useEventValue, useMemoryKey, useMemoryKeySlim, SimpleEventEmitter, UniversalKeyValueStore, stateOfVersion, };
13
+ export type { Environment, SPAContextType, ApprovalState, DecidedRequest, VersionState, RemoteComponentProps, RemoteComponentState, };
14
+ export { useSPAContext, useSession, useEventValue, useMemoryKey, useMemoryKeySlim, SimpleEventEmitter, UniversalKeyValueStore, stateOfVersion, RemoteComponent, useRemoteComponent, loadModule, };
12
15
  export declare const SPAContextProvider: import("react").Provider<SPAContextType | undefined>;
package/index.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import { stateOfVersion } from './approvals';
2
2
  import { SPAContext } from './context';
3
+ import { loadModule } from './loadModule';
4
+ import { RemoteComponent } from './RemoteComponent';
3
5
  import { SimpleEventEmitter } from './SimpleEventEmitter';
4
6
  import { UniversalKeyValueStore } from './UniversalKeyValueStore';
5
7
  import { useEventValue } from './useEventValue';
6
8
  import { useMemoryKey, useMemoryKeySlim } from './useMemoryKey';
9
+ import { useRemoteComponent } from './useRemoteComponent';
7
10
  import { useSession } from './useSession';
8
11
  import { useSPAContext } from './useSPAContext';
9
- export { useSPAContext, useSession, useEventValue, useMemoryKey, useMemoryKeySlim, SimpleEventEmitter, UniversalKeyValueStore, stateOfVersion };
12
+ export { useSPAContext, useSession, useEventValue, useMemoryKey, useMemoryKeySlim, SimpleEventEmitter, UniversalKeyValueStore, stateOfVersion, RemoteComponent, useRemoteComponent, loadModule };
10
13
  export const SPAContextProvider = SPAContext.Provider;
@@ -0,0 +1,2 @@
1
+ /** Loads a module by import-map name through native `import()`. */
2
+ export declare function loadModule<T = unknown>(name: string): Promise<T>;
package/loadModule.js ADDED
@@ -0,0 +1,4 @@
1
+ /** Loads a module by import-map name through native `import()`. */
2
+ export function loadModule(name) {
3
+ return import(/* webpackIgnore: true */name);
4
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushwoosh/spa-context",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "React context giving every micro-frontend its HTTP/RPC clients, session store, routing, event bus and key-value storage",
5
5
  "repository": {
6
6
  "type": "git",
@@ -15,15 +15,29 @@
15
15
  "access": "public",
16
16
  "directory": "dist"
17
17
  },
18
+ "pushwoosh": {
19
+ "cdn": {
20
+ "path": "spa-context",
21
+ "dir": "cdn-dist",
22
+ "importMap": [
23
+ "@pushwoosh/spa-context"
24
+ ]
25
+ }
26
+ },
18
27
  "dependencies": {
19
28
  "idb-keyval": "^6.2.2"
20
29
  },
21
30
  "devDependencies": {
22
31
  "@pushwoosh/http-client": "^2.12.0",
23
- "@pushwoosh/rpc-v2-http-api-data": "^0.2.524",
32
+ "@pushwoosh/rpc-v2-http-api-data": "^0.2.534",
24
33
  "@pushwoosh/session-store": "^2.14.0",
25
34
  "@types/history": "^4.7.11",
26
- "@types/react": "^19.3.0"
35
+ "@types/react": "^19.3.0",
36
+ "@types/react-dom": "^19.3.0",
37
+ "jsdom": "^26.1.0",
38
+ "react": "^19.3.0",
39
+ "react-dom": "^19.3.0",
40
+ "vitest": "^4.1.10"
27
41
  },
28
42
  "peerDependencies": {
29
43
  "@pushwoosh/http-client": "^2.5.0",
@@ -78,10 +92,11 @@
78
92
  "node": ">= 24"
79
93
  },
80
94
  "scripts": {
81
- "build": "pushwoosh-engine lib:build",
82
- "check": "pnpm run check:lint && pnpm run check:types",
95
+ "build": "pushwoosh-engine lib:build && `pushwoosh-engine app:command:build`",
96
+ "check": "pnpm run check:lint && pnpm run check:types && pnpm run test",
83
97
  "check:lint": "echo \"Run check:lint\" && NODE_ENV=production eslint ./src",
84
98
  "check:lint-fix": "echo \"Run check:lint\" && NODE_ENV=production eslint ./src --fix",
85
- "check:types": "echo \"Run check:types\" && pushwoosh-engine lib:check-types"
99
+ "check:types": "echo \"Run check:types\" && pushwoosh-engine lib:check-types",
100
+ "test": "echo \"Run test\" && vitest run"
86
101
  }
87
102
  }
@@ -0,0 +1,3 @@
1
+ import { type ComponentType } from 'react';
2
+ /** The export as a React component, or the error `RemoteComponent` reports when it is missing or of another shape. */
3
+ export declare function remoteComponent(module: string, component: string, exported: unknown): ComponentType<any> | Error;
@@ -0,0 +1,13 @@
1
+ const elementTypes = new Set([Symbol.for('react.memo'), Symbol.for('react.forward_ref')]);
2
+ const isWrappedComponent = value => typeof value === 'object' && value !== null && elementTypes.has(value.$$typeof);
3
+ /** The export as a React component, or the error `RemoteComponent` reports when it is missing or of another shape. */
4
+ export function remoteComponent(module, component, exported) {
5
+ if (exported === undefined) {
6
+ return new Error(`RemoteComponent ${module}: the module has no export "${component}"`);
7
+ }
8
+ // A createParcelFunction factory is a function too; its .Component is what the old <Parcel> rendered.
9
+ if (typeof exported === 'function' && !('Component' in exported) || isWrappedComponent(exported)) {
10
+ return exported;
11
+ }
12
+ return new Error(`RemoteComponent ${module}: export "${component}" is not a React component ` + '(expected a function, class, memo or forwardRef; an object with .Component or a parcel factory is not supported)');
13
+ }
@@ -0,0 +1,12 @@
1
+ import { type ComponentType } from 'react';
2
+ export type RemoteComponentState<P = any> = {
3
+ status: 'loading';
4
+ } | {
5
+ status: 'ready';
6
+ Component: ComponentType<P>;
7
+ } | {
8
+ status: 'failed';
9
+ error: Error;
10
+ };
11
+ /** Loads `component` from the import-map module `module`; a new pair drops the old result and loads again. */
12
+ export declare function useRemoteComponent<P = any>(module: string, component: string): RemoteComponentState<P>;
@@ -0,0 +1,38 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { loadModule } from './loadModule';
3
+ import { remoteComponent } from './remoteExport';
4
+ const LOADING = {
5
+ status: 'loading'
6
+ };
7
+ const toError = error => error instanceof Error ? error : new Error(String(error));
8
+ /** Loads `component` from the import-map module `module`; a new pair drops the old result and loads again. */
9
+ export function useRemoteComponent(module, component) {
10
+ const key = `${module}\n${component}`;
11
+ const [loaded, setLoaded] = useState(null);
12
+ useEffect(() => {
13
+ let cancelled = false;
14
+ const settle = state => {
15
+ if (!cancelled) setLoaded({
16
+ key,
17
+ state
18
+ });
19
+ };
20
+ loadModule(module).then(imported => {
21
+ const resolved = remoteComponent(module, component, imported[component]);
22
+ settle(resolved instanceof Error ? {
23
+ status: 'failed',
24
+ error: resolved
25
+ } : {
26
+ status: 'ready',
27
+ Component: resolved
28
+ });
29
+ }, error => settle({
30
+ status: 'failed',
31
+ error: toError(error)
32
+ }));
33
+ return () => {
34
+ cancelled = true;
35
+ };
36
+ }, [key, module, component]);
37
+ return loaded?.key === key ? loaded.state : LOADING;
38
+ }