@blocksdiy/react-common 1.38.0 → 1.39.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.
@@ -1 +1 @@
1
- {"version":3,"file":"bridge-provider.d.ts","sourceRoot":"","sources":["../../src/iframe-communication/bridge-provider.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,EAAiB,KAAK,SAAS,EAAwB,MAAM,OAAO,CAAC;AAE5E,KAAK,iBAAiB,CAAC,IAAI,IAAI;IAAE,GAAG,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,SAAS,CAAA;CAAE,CAAC;AAC/D,KAAK,eAAe,GAAG;IAAE,GAAG,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,KAAK,CAAA;CAAE,CAAC;AACxD,KAAK,mBAAmB,GAAG;IAAE,GAAG,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,SAAS,CAAA;CAAE,CAAC;AAEhE,KAAK,UAAU,CAAC,IAAI,IAAI,iBAAiB,CAAC,IAAI,CAAC,GAAG,eAAe,GAAG,mBAAmB,CAAC;AAExF,MAAM,MAAM,WAAW,CAAC,IAAI,SAAS,OAAO,IAAI;IAC9C,SAAS,EAAE,OAAO,CAAC;IACnB,mBAAmB,EAAE,MAAM,IAAI,CAAC;IAChC,mBAAmB,EAAE,CAAC,GAAG,EAAE,IAAI,KAAK,IAAI,CAAC;IACzC,iBAAiB,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IAC1C,iBAAiB,EAAE,MAAM,IAAI,CAAC;CAC/B,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC;AAIrB,wBAAgB,cAAc,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BA+BnE;AAED,wBAAgB,cAAc,CAAC,IAAI,SAAS,OAAO,EAAE,QAAQ,SAAmB,EAAE,YAAY,SAAmB,qBAOhH"}
1
+ {"version":3,"file":"bridge-provider.d.ts","sourceRoot":"","sources":["../../src/iframe-communication/bridge-provider.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,EAAiB,KAAK,SAAS,EAAiC,MAAM,OAAO,CAAC;AAErF,KAAK,iBAAiB,CAAC,IAAI,IAAI;IAAE,GAAG,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,SAAS,CAAA;CAAE,CAAC;AAC/D,KAAK,eAAe,GAAG;IAAE,GAAG,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,KAAK,CAAA;CAAE,CAAC;AACxD,KAAK,mBAAmB,GAAG;IAAE,GAAG,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,SAAS,CAAA;CAAE,CAAC;AAEhE,KAAK,UAAU,CAAC,IAAI,IAAI,iBAAiB,CAAC,IAAI,CAAC,GAAG,eAAe,GAAG,mBAAmB,CAAC;AAExF,MAAM,MAAM,WAAW,CAAC,IAAI,SAAS,OAAO,IAAI;IAC9C,SAAS,EAAE,OAAO,CAAC;IACnB,mBAAmB,EAAE,MAAM,IAAI,CAAC;IAChC,mBAAmB,EAAE,CAAC,GAAG,EAAE,IAAI,KAAK,IAAI,CAAC;IACzC,iBAAiB,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IAC1C,iBAAiB,EAAE,MAAM,IAAI,CAAC;CAC/B,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC;AAIrB,wBAAgB,cAAc,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAgCnE;AAED,wBAAgB,cAAc,CAAC,IAAI,SAAS,OAAO,EAAE,QAAQ,SAAmB,EAAE,YAAY,SAAmB,qBAOhH"}
@@ -1,26 +1,35 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { createContext, useContext, useState } from "react";
2
+ import { createContext, useContext, useMemo, useState } from "react";
3
3
  const BridgeStateContext = createContext(undefined);
4
4
  export function BridgeProvider({ children }) {
5
5
  const [data, setData] = useState({ api: undefined, error: undefined });
6
6
  const [isLoading, setIsLoading] = useState(false);
7
- const onConnectionSuccess = (api) => {
8
- setIsLoading(false);
9
- setData({ api, error: undefined });
10
- };
11
- const onConnectionError = (error) => {
12
- setIsLoading(false);
13
- setData({ api: undefined, error });
14
- };
15
- const onConnectionLoading = () => {
16
- setIsLoading(true);
17
- setData({ api: undefined, error: undefined });
18
- };
19
- const onConnectionEnded = () => {
20
- setIsLoading(false);
21
- setData({ api: undefined, error: undefined });
22
- };
23
- return (_jsx(BridgeStateContext, { value: { isLoading, onConnectionLoading, onConnectionSuccess, onConnectionError, onConnectionEnded, ...data }, children: children }));
7
+ // Created once (state initializer) so the connection-owning effects can list
8
+ // these as dependencies without ever re-running; they only close over React
9
+ // state setters, which are stable.
10
+ const [transitions] = useState(() => ({
11
+ onConnectionLoading: () => {
12
+ setIsLoading(true);
13
+ setData({ api: undefined, error: undefined });
14
+ },
15
+ onConnectionSuccess: (api) => {
16
+ setIsLoading(false);
17
+ setData({ api, error: undefined });
18
+ },
19
+ onConnectionError: (error) => {
20
+ setIsLoading(false);
21
+ setData({ api: undefined, error });
22
+ },
23
+ onConnectionEnded: () => {
24
+ setIsLoading(false);
25
+ setData({ api: undefined, error: undefined });
26
+ },
27
+ }));
28
+ // Memoized so consumers re-render only when the connection state actually
29
+ // changes, not whenever the provider's parent re-renders (the platform side
30
+ // sits under a busy MobX observer).
31
+ const value = useMemo(() => ({ isLoading, ...transitions, ...data }), [isLoading, transitions, data]);
32
+ return _jsx(BridgeStateContext, { value: value, children: children });
24
33
  }
25
34
  export function useBridgeState(hookName = "useBridgeState", providerName = "BridgeProvider") {
26
35
  const context = useContext(BridgeStateContext);
@@ -1,8 +1,10 @@
1
+ import "./promise-with-resolvers-polyfill.js";
1
2
  import { type ReactNode } from "react";
2
- import { IframeApi, PlatformApi } from "./types.js";
3
+ import type { IframeApi, PlatformApi } from "./types.js";
3
4
  /**
4
5
  * Provider for the iframe (app-render) side of the bridge.
5
- * Wraps the app inside the iframe and connects to the parent platform window.
6
+ * Wraps the app inside the iframe and owns the single connection to the
7
+ * parent platform window.
6
8
  *
7
9
  * Use `usePlatform()` anywhere inside to access the platform's api.
8
10
  */
@@ -13,8 +15,9 @@ export declare function IframeBridgeProvider({ iframeApi, children }: {
13
15
  /**
14
16
  * Hook for iframe-side code to access the platform's api and connection state.
15
17
  *
16
- * Returns `{ api, error, isLoading }`.
17
- * `api` is undefined before the connection is established.
18
+ * Returns `{ platformApi, error, isLoading }`; `platformApi` is undefined
19
+ * until the connection is established. Read-only — the connection is owned by
20
+ * `IframeBridgeProvider`, so any number of components may call this.
18
21
  */
19
22
  export declare function usePlatform(): {
20
23
  platformApi: PlatformApi | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"iframe-bridge-provider.d.ts","sourceRoot":"","sources":["../../src/iframe-communication/iframe-bridge-provider.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAiB,KAAK,SAAS,EAAiC,MAAM,OAAO,CAAC;AAGrF,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAIjD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE;IAAE,SAAS,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAM1G;AAED;;;;;GAKG;AACH,wBAAgB,WAAW;;;;EAyD1B"}
1
+ {"version":3,"file":"iframe-bridge-provider.d.ts","sourceRoot":"","sources":["../../src/iframe-communication/iframe-bridge-provider.tsx"],"names":[],"mappings":"AAAA,OAAO,mCAAmC,CAAC;AAI3C,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAGvC,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAGtD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE;IAAE,SAAS,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAO1G;AAkBD;;;;;;GAMG;AACH,wBAAgB,WAAW;;;;EAI1B"}
@@ -1,67 +1,40 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import "./promise-with-resolvers-polyfill.js";
2
3
  import { getDomain, getProtocol } from "@blocksdiy/blocks-client-api/envService";
3
- import { connect, WindowMessenger } from "penpal";
4
- import { createContext, useContext, useEffect, useRef } from "react";
4
+ import { WindowMessenger } from "penpal";
5
5
  import { BridgeProvider, useBridgeState } from "./bridge-provider.js";
6
- const IframeApiContext = createContext(undefined);
6
+ import { useBridgeConnection } from "./use-bridge-connection.js";
7
7
  /**
8
8
  * Provider for the iframe (app-render) side of the bridge.
9
- * Wraps the app inside the iframe and connects to the parent platform window.
9
+ * Wraps the app inside the iframe and owns the single connection to the
10
+ * parent platform window.
10
11
  *
11
12
  * Use `usePlatform()` anywhere inside to access the platform's api.
12
13
  */
13
14
  export function IframeBridgeProvider({ iframeApi, children }) {
14
- return (_jsx(BridgeProvider, { children: _jsx(IframeApiContext, { value: iframeApi, children: children }) }));
15
+ return (_jsxs(BridgeProvider, { children: [_jsx(PlatformConnection, { iframeApi: iframeApi }), children] }));
16
+ }
17
+ /** Owns the penpal connection to the parent platform window. Renders nothing. */
18
+ function PlatformConnection({ iframeApi }) {
19
+ useBridgeConnection({
20
+ hookName: "PlatformConnection",
21
+ providerName: "IframeBridgeProvider",
22
+ methods: iframeApi,
23
+ createMessenger: () => new WindowMessenger({
24
+ remoteWindow: window.parent,
25
+ allowedOrigins: [new RegExp(`^${getProtocol()}://(b-\\d+\\.)?${getDomain().replace(/\./g, "\\.")}$`)],
26
+ }),
27
+ });
28
+ return null;
15
29
  }
16
30
  /**
17
31
  * Hook for iframe-side code to access the platform's api and connection state.
18
32
  *
19
- * Returns `{ api, error, isLoading }`.
20
- * `api` is undefined before the connection is established.
33
+ * Returns `{ platformApi, error, isLoading }`; `platformApi` is undefined
34
+ * until the connection is established. Read-only — the connection is owned by
35
+ * `IframeBridgeProvider`, so any number of components may call this.
21
36
  */
22
37
  export function usePlatform() {
23
- const { error, isLoading, onConnectionSuccess, onConnectionError, onConnectionEnded, onConnectionLoading, api } = useBridgeState("usePlatform", "IframeBridgeProvider");
24
- const iframeApi = useContext(IframeApiContext);
25
- const iframeApiRef = useRef(iframeApi);
26
- useEffect(() => {
27
- iframeApiRef.current = iframeApi;
28
- }, [iframeApi]);
29
- const onConnectionSuccessRef = useRef(onConnectionSuccess);
30
- useEffect(() => {
31
- onConnectionSuccessRef.current = onConnectionSuccess;
32
- }, [onConnectionSuccess]);
33
- const onConnectionErrorRef = useRef(onConnectionError);
34
- useEffect(() => {
35
- onConnectionErrorRef.current = onConnectionError;
36
- }, [onConnectionError]);
37
- const onConnectionLoadingRef = useRef(onConnectionLoading);
38
- useEffect(() => {
39
- onConnectionLoadingRef.current = onConnectionLoading;
40
- }, [onConnectionLoading]);
41
- const onConnectionEndedRef = useRef(onConnectionEnded);
42
- useEffect(() => {
43
- onConnectionEndedRef.current = onConnectionEnded;
44
- }, [onConnectionEnded]);
45
- useEffect(() => {
46
- onConnectionLoadingRef.current?.();
47
- const messenger = new WindowMessenger({
48
- remoteWindow: window.parent,
49
- allowedOrigins: [new RegExp(`^${getProtocol()}://(b-\\d+\\.)?${getDomain().replace(/\./g, "\\.")}$`)],
50
- });
51
- const connection = connect({
52
- messenger,
53
- methods: iframeApiRef.current,
54
- // log: debug("iframe-window"),
55
- });
56
- connection.promise
57
- .then((platformApi) => onConnectionSuccessRef.current(platformApi))
58
- .catch((error) => {
59
- onConnectionErrorRef.current(error);
60
- });
61
- return () => {
62
- connection.destroy();
63
- onConnectionEndedRef.current?.();
64
- };
65
- }, []);
38
+ const { api, error, isLoading } = useBridgeState("usePlatform", "IframeBridgeProvider");
66
39
  return { platformApi: api, error, isLoading };
67
40
  }
@@ -1,5 +1,5 @@
1
1
  export type { ThemeMode, AppUpdate, PlatformApi, IframeApi, EditedElement, ElementData } from "./types.js";
2
- export { BridgeProvider, useBridgeState, type BridgeState } from "./bridge-provider.js";
2
+ export { BridgeProvider, type BridgeState } from "./bridge-provider.js";
3
3
  export { IframeBridgeProvider, usePlatform } from "./iframe-bridge-provider.js";
4
4
  export { PlatformBridgeProvider, PlatformBridgeIframe, useIframe } from "./platform-bridge-provider.js";
5
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/iframe-communication/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,SAAS,EAAE,SAAS,EAAE,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AACxG,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErF,OAAO,EAAE,oBAAoB,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAC7E,OAAO,EAAE,sBAAsB,EAAE,oBAAoB,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/iframe-communication/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,SAAS,EAAE,SAAS,EAAE,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAGxG,OAAO,EAAE,cAAc,EAAE,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErE,OAAO,EAAE,oBAAoB,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAC7E,OAAO,EAAE,sBAAsB,EAAE,oBAAoB,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC"}
@@ -1,3 +1,5 @@
1
- export { BridgeProvider, useBridgeState } from "./bridge-provider.js";
1
+ // useBridgeState is deliberately not exported: it hands out the connection
2
+ // state mutators, which belong solely to the providers below.
3
+ export { BridgeProvider } from "./bridge-provider.js";
2
4
  export { IframeBridgeProvider, usePlatform } from "./iframe-bridge-provider.js";
3
5
  export { PlatformBridgeProvider, PlatformBridgeIframe, useIframe } from "./platform-bridge-provider.js";
@@ -0,0 +1,19 @@
1
+ import type { Methods } from "penpal";
2
+ import { type RefObject } from "react";
3
+ /**
4
+ * penpal holds the `methods` object it was given at connect() time and
5
+ * resolves every incoming call against it at call time (own properties only,
6
+ * walked segment by segment — nested namespaces are part of its contract).
7
+ * React consumers rebuild their handler objects every render, so handing one
8
+ * to connect() directly would pin every handler to its first-render closures —
9
+ * a classic source of stale-state bugs that each consumer previously had to
10
+ * work around with refs.
11
+ *
12
+ * This returns a stable proxy that delegates every lookup to
13
+ * `methodsRef.current`, so remote calls always reach the latest committed
14
+ * handlers — including nested method namespaces and methods added or removed
15
+ * after connect(). Like penpal itself, only own properties are visible;
16
+ * prototype (class-instance) methods are not.
17
+ */
18
+ export declare function createLiveMethods<TMethods extends Methods>(methodsRef: RefObject<TMethods>): TMethods;
19
+ //# sourceMappingURL=live-methods.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"live-methods.d.ts","sourceRoot":"","sources":["../../src/iframe-communication/live-methods.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,SAAS,OAAO,EAAE,UAAU,EAAE,SAAS,CAAC,QAAQ,CAAC,GAAG,QAAQ,CAYrG"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * penpal holds the `methods` object it was given at connect() time and
3
+ * resolves every incoming call against it at call time (own properties only,
4
+ * walked segment by segment — nested namespaces are part of its contract).
5
+ * React consumers rebuild their handler objects every render, so handing one
6
+ * to connect() directly would pin every handler to its first-render closures —
7
+ * a classic source of stale-state bugs that each consumer previously had to
8
+ * work around with refs.
9
+ *
10
+ * This returns a stable proxy that delegates every lookup to
11
+ * `methodsRef.current`, so remote calls always reach the latest committed
12
+ * handlers — including nested method namespaces and methods added or removed
13
+ * after connect(). Like penpal itself, only own properties are visible;
14
+ * prototype (class-instance) methods are not.
15
+ */
16
+ export function createLiveMethods(methodsRef) {
17
+ return new Proxy({}, {
18
+ get: (_target, property) => Reflect.get(methodsRef.current, property),
19
+ has: (_target, property) => Reflect.has(methodsRef.current, property),
20
+ ownKeys: () => Reflect.ownKeys(methodsRef.current),
21
+ getOwnPropertyDescriptor: (_target, property) => {
22
+ const descriptor = Reflect.getOwnPropertyDescriptor(methodsRef.current, property);
23
+ // Proxy invariant: a property the (empty) target lacks must be reported
24
+ // as configurable.
25
+ return descriptor && { ...descriptor, configurable: true };
26
+ },
27
+ });
28
+ }
@@ -1,11 +1,12 @@
1
+ import "./promise-with-resolvers-polyfill.js";
1
2
  import { type ReactNode } from "react";
2
3
  import type { IframeApi, PlatformApi } from "./types.js";
3
4
  /**
4
5
  * Context provider for the platform (Remix) side of the bridge.
5
6
  * Does NOT render any iframe — use `PlatformBridgeIframe` inside this provider.
6
7
  *
7
- * `methods` — handlers the iframe can call on the platform.
8
- * Use `useIframe()` anywhere inside to check `isLoaded` and call iframe methods.
8
+ * Use `useIframe()` anywhere inside to check the connection state and call
9
+ * methods the iframe exposes.
9
10
  */
10
11
  export declare function PlatformBridgeProvider({ children }: {
11
12
  children: ReactNode;
@@ -1 +1 @@
1
- {"version":3,"file":"platform-bridge-provider.d.ts","sourceRoot":"","sources":["../../src/iframe-communication/platform-bridge-provider.tsx"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,SAAS,EAAqB,MAAM,OAAO,CAAC;AAG1D,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAEtD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAE3E;AAED,KAAK,yBAAyB,GAAG;IAAE,WAAW,EAAE,WAAW,CAAA;CAAE,GAAG,IAAI,CAClE,KAAK,CAAC,oBAAoB,CAAC,iBAAiB,CAAC,EAC7C,KAAK,GAAG,OAAO,CAChB,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,EAAE,WAAW,EAAE,GAAG,WAAW,EAAE,EAAE,yBAAyB,+BA2E9F;AAED;;;;GAIG;AACH,wBAAgB,SAAS;;;;EAQxB"}
1
+ {"version":3,"file":"platform-bridge-provider.d.ts","sourceRoot":"","sources":["../../src/iframe-communication/platform-bridge-provider.tsx"],"names":[],"mappings":"AAAA,OAAO,mCAAmC,CAAC;AAG3C,OAAO,EAAE,KAAK,SAAS,EAAU,MAAM,OAAO,CAAC;AAG/C,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAGtD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAE3E;AAED,KAAK,yBAAyB,GAAG;IAAE,WAAW,EAAE,WAAW,CAAA;CAAE,GAAG,IAAI,CAClE,KAAK,CAAC,oBAAoB,CAAC,iBAAiB,CAAC,EAC7C,KAAK,GAAG,OAAO,CAChB,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,EAAE,WAAW,EAAE,GAAG,WAAW,EAAE,EAAE,yBAAyB,+BA0B9F;AAED;;;;GAIG;AACH,wBAAgB,SAAS;;;;EAQxB"}
@@ -1,13 +1,15 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { connect, WindowMessenger } from "penpal";
3
- import { useEffect, useRef } from "react";
2
+ import "./promise-with-resolvers-polyfill.js";
3
+ import { WindowMessenger } from "penpal";
4
+ import { useRef } from "react";
4
5
  import { BridgeProvider, useBridgeState } from "./bridge-provider.js";
6
+ import { useBridgeConnection } from "./use-bridge-connection.js";
5
7
  /**
6
8
  * Context provider for the platform (Remix) side of the bridge.
7
9
  * Does NOT render any iframe — use `PlatformBridgeIframe` inside this provider.
8
10
  *
9
- * `methods` — handlers the iframe can call on the platform.
10
- * Use `useIframe()` anywhere inside to check `isLoaded` and call iframe methods.
11
+ * Use `useIframe()` anywhere inside to check the connection state and call
12
+ * methods the iframe exposes.
11
13
  */
12
14
  export function PlatformBridgeProvider({ children }) {
13
15
  return _jsx(BridgeProvider, { children: children });
@@ -19,57 +21,22 @@ export function PlatformBridgeProvider({ children }) {
19
21
  * Accepts all standard `<iframe>` HTML attributes (`src`, `className`, `allow`, etc.).
20
22
  */
21
23
  export function PlatformBridgeIframe({ platformApi, ...iframeProps }) {
22
- const { onConnectionSuccess, onConnectionError, onConnectionLoading, onConnectionEnded } = useBridgeState("PlatformBridgeIframe", "PlatformBridgeProvider");
23
- const connectionRef = useRef(null);
24
- const platformApiRef = useRef(platformApi);
25
- useEffect(() => {
26
- platformApiRef.current = platformApi;
27
- }, [platformApi]);
28
- const onConnectionSuccessRef = useRef(onConnectionSuccess);
29
- useEffect(() => {
30
- onConnectionSuccessRef.current = onConnectionSuccess;
31
- }, [onConnectionSuccess]);
32
- const onConnectionErrorRef = useRef(onConnectionError);
33
- useEffect(() => {
34
- onConnectionErrorRef.current = onConnectionError;
35
- }, [onConnectionError]);
36
- const onConnectionLoadingRef = useRef(onConnectionLoading);
37
- useEffect(() => {
38
- onConnectionLoadingRef.current = onConnectionLoading;
39
- }, [onConnectionLoading]);
40
- const onConnectionEndedRef = useRef(onConnectionEnded);
41
- useEffect(() => {
42
- onConnectionEndedRef.current = onConnectionEnded;
43
- }, [onConnectionEnded]);
44
24
  const iframeRef = useRef(null);
45
- useEffect(() => {
46
- const iframe = iframeRef.current;
47
- if (!iframe?.contentWindow) {
48
- onConnectionEndedRef.current();
49
- return;
50
- }
51
- onConnectionLoadingRef.current();
52
- const messenger = new WindowMessenger({
53
- remoteWindow: iframe.contentWindow,
54
- allowedOrigins: [new URL(iframe.src).origin],
55
- });
56
- connectionRef.current?.destroy();
57
- connectionRef.current = connect({
58
- messenger,
59
- methods: platformApiRef.current,
60
- // log: debug("platform-window"),
61
- });
62
- connectionRef.current.promise
63
- .then((api) => onConnectionSuccessRef.current(api))
64
- .catch((error) => {
65
- connectionRef.current?.destroy();
66
- onConnectionErrorRef.current(error);
67
- });
68
- return () => {
69
- connectionRef.current?.destroy();
70
- onConnectionEndedRef.current();
71
- };
72
- }, []);
25
+ useBridgeConnection({
26
+ hookName: "PlatformBridgeIframe",
27
+ providerName: "PlatformBridgeProvider",
28
+ methods: platformApi,
29
+ createMessenger: () => {
30
+ const iframe = iframeRef.current;
31
+ if (!iframe?.contentWindow) {
32
+ return undefined;
33
+ }
34
+ return new WindowMessenger({
35
+ remoteWindow: iframe.contentWindow,
36
+ allowedOrigins: [new URL(iframe.src).origin],
37
+ });
38
+ },
39
+ });
73
40
  return (_jsx("iframe", { ref: iframeRef, allow: "accelerometer; autoplay; camera; encrypted-media; fullscreen; geolocation; gyroscope; microphone; midi; clipboard-read; clipboard-write; payment; usb; xr-spatial-tracking; screen-wake-lock; magnetometer; gamepad; picture-in-picture; display-capture; bluetooth;", ...iframeProps }));
74
41
  }
75
42
  /**
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=promise-with-resolvers-polyfill.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"promise-with-resolvers-polyfill.d.ts","sourceRoot":"","sources":["../../src/iframe-communication/promise-with-resolvers-polyfill.ts"],"names":[],"mappings":"AAmBA,OAAO,EAAE,CAAC"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * penpal 8 targets ES2024 and calls `Promise.withResolvers()` natively
3
+ * (Chrome/Edge 119+, Firefox 121+, Safari 17.4+). The iframe side of the
4
+ * bridge ships inside published apps that run on end-user devices we don't
5
+ * control, so back-fill the method where it's missing instead of raising the
6
+ * browser floor for every published app.
7
+ */
8
+ if (typeof Promise.withResolvers !== "function") {
9
+ Promise.withResolvers = function withResolvers() {
10
+ let resolve;
11
+ let reject;
12
+ const promise = new Promise((res, rej) => {
13
+ resolve = res;
14
+ reject = rej;
15
+ });
16
+ return { promise, resolve, reject };
17
+ };
18
+ }
19
+ export {};
@@ -0,0 +1,34 @@
1
+ import { type Messenger, type Methods, PenpalError, type RemoteProxy } from "penpal";
2
+ /**
3
+ * Owns one penpal connection for the lifetime of the calling component and
4
+ * drives the shared bridge state through it: loading on mount, success/error
5
+ * from the handshake, ended on unmount. Both sides of the bridge share this
6
+ * hook so the subtle parts below stay identical on each side.
7
+ */
8
+ export declare function useBridgeConnection<TRemoteApi extends Methods>({ hookName, providerName, createMessenger, methods, }: {
9
+ hookName: string;
10
+ providerName: string;
11
+ /**
12
+ * Builds the messenger for this connection, called one tick after mount.
13
+ * Return undefined to skip connecting (e.g. no iframe contentWindow yet);
14
+ * throws surface as connection errors.
15
+ */
16
+ createMessenger: () => Messenger | undefined;
17
+ /**
18
+ * Methods to expose to the remote. Pass the current render's object as-is —
19
+ * the hook forwards remote calls to the latest committed handlers (see
20
+ * `createLiveMethods`), so it never goes stale and never re-connects.
21
+ */
22
+ methods: Methods;
23
+ }): void;
24
+ /**
25
+ * penpal gives no signal when the remote destroys an established connection —
26
+ * the remote proxy just starts throwing CONNECTION_DESTROYED synchronously
27
+ * from every method call. Wrap the proxy so those throws become rejections
28
+ * (matching the methods' async signatures) and report the death, instead of
29
+ * crashing whichever component happened to make the next call.
30
+ *
31
+ * Exported for tests.
32
+ */
33
+ export declare function guardDestroyedProxy<TApi extends Methods>(api: RemoteProxy<TApi>, onDestroyedCall: (error: PenpalError) => void): RemoteProxy<TApi>;
34
+ //# sourceMappingURL=use-bridge-connection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-bridge-connection.d.ts","sourceRoot":"","sources":["../../src/iframe-communication/use-bridge-connection.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,SAAS,EACd,KAAK,OAAO,EACZ,WAAW,EACX,KAAK,WAAW,EACjB,MAAM,QAAQ,CAAC;AAehB;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,UAAU,SAAS,OAAO,EAAE,EAC9D,QAAQ,EACR,YAAY,EACZ,eAAe,EACf,OAAO,GACR,EAAE;IACD,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,eAAe,EAAE,MAAM,SAAS,GAAG,SAAS,CAAC;IAC7C;;;;OAIG;IACH,OAAO,EAAE,OAAO,CAAC;CAClB,QAoFA;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,SAAS,OAAO,EACtD,GAAG,EAAE,WAAW,CAAC,IAAI,CAAC,EACtB,eAAe,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,GAC5C,WAAW,CAAC,IAAI,CAAC,CAuBnB"}
@@ -0,0 +1,128 @@
1
+ import { connect, ErrorCode, PenpalError, } from "penpal";
2
+ import { useEffect, useRef } from "react";
3
+ import { useBridgeState } from "./bridge-provider.js";
4
+ import { createLiveMethods } from "./live-methods.js";
5
+ /**
6
+ * How long to wait for the penpal handshake before failing the connection.
7
+ * penpal only arms its handshake timer when a timeout is provided — without
8
+ * one, an iframe that never loads (bad src, CSP denial, fatal script error
9
+ * before the provider mounts) leaves the bridge loading forever with no error
10
+ * for consumers to surface.
11
+ */
12
+ const CONNECT_TIMEOUT_MS = 60_000;
13
+ /**
14
+ * Owns one penpal connection for the lifetime of the calling component and
15
+ * drives the shared bridge state through it: loading on mount, success/error
16
+ * from the handshake, ended on unmount. Both sides of the bridge share this
17
+ * hook so the subtle parts below stay identical on each side.
18
+ */
19
+ export function useBridgeConnection({ hookName, providerName, createMessenger, methods, }) {
20
+ const { onConnectionSuccess, onConnectionError, onConnectionLoading, onConnectionEnded } = useBridgeState(hookName, providerName);
21
+ // Latest committed factory and methods, so the connection effect never
22
+ // re-runs just because a render rebuilt them.
23
+ const createMessengerRef = useRef(createMessenger);
24
+ const methodsRef = useRef(methods);
25
+ useEffect(() => {
26
+ createMessengerRef.current = createMessenger;
27
+ methodsRef.current = methods;
28
+ }, [createMessenger, methods]);
29
+ useEffect(() => {
30
+ onConnectionLoading();
31
+ // Deferred one tick, cancelled by cleanup: dev StrictMode's synchronous
32
+ // mount -> cleanup -> remount then never creates the throwaway first
33
+ // connection. A destroyed connection's SYN otherwise stays on the wire and
34
+ // can mispair the real handshake — both sides resolve but the MessagePorts
35
+ // end up crossed and every call hangs (reproduced: 6/12 trials).
36
+ let connection;
37
+ // Set before cleanup destroys the connection, so the rejection that
38
+ // destroy() causes can be told apart from a remote-initiated teardown.
39
+ let destroyedByCleanup = false;
40
+ const connectTimer = setTimeout(() => {
41
+ // Synchronous setup throws (an invalid iframe src URL, penpal argument
42
+ // validation) would otherwise become uncaught errors inside the timer —
43
+ // route them into the bridge state like any other connection failure.
44
+ try {
45
+ const messenger = createMessengerRef.current();
46
+ if (!messenger) {
47
+ onConnectionEnded();
48
+ return;
49
+ }
50
+ connection = connect({
51
+ messenger,
52
+ methods: createLiveMethods(methodsRef),
53
+ timeout: CONNECT_TIMEOUT_MS,
54
+ });
55
+ connection.promise
56
+ .then((api) => {
57
+ const guardedApi = guardDestroyedProxy(api, (error) => {
58
+ // Ignore calls on a proxy this effect already tore down — the
59
+ // bridge state has moved on (possibly to a newer connection).
60
+ if (!destroyedByCleanup) {
61
+ onConnectionError(error);
62
+ }
63
+ });
64
+ // RemoteProxy<T> is structurally T for our promise-returning
65
+ // bridge APIs; TypeScript just can't prove it for an unresolved
66
+ // type parameter.
67
+ onConnectionSuccess(guardedApi);
68
+ })
69
+ .catch((error) => {
70
+ // Since penpal 8, destroying a pending connection rejects its
71
+ // promise instead of leaking it. Only our own cleanup's destroy()
72
+ // is expected teardown — a CONNECTION_DESTROYED from anywhere else
73
+ // means the remote tore down a connection we still wanted, which
74
+ // is a failure like any other.
75
+ if (destroyedByCleanup && error instanceof PenpalError && error.code === ErrorCode.ConnectionDestroyed) {
76
+ return;
77
+ }
78
+ onConnectionError(error);
79
+ });
80
+ }
81
+ catch (error) {
82
+ onConnectionError(error instanceof Error ? error : new Error(String(error)));
83
+ }
84
+ }, 0);
85
+ return () => {
86
+ destroyedByCleanup = true;
87
+ clearTimeout(connectTimer);
88
+ connection?.destroy();
89
+ onConnectionEnded();
90
+ };
91
+ // The transitions are created once by BridgeProvider, so this effect runs
92
+ // once per mounted connection owner.
93
+ }, [onConnectionSuccess, onConnectionError, onConnectionLoading, onConnectionEnded]);
94
+ }
95
+ /**
96
+ * penpal gives no signal when the remote destroys an established connection —
97
+ * the remote proxy just starts throwing CONNECTION_DESTROYED synchronously
98
+ * from every method call. Wrap the proxy so those throws become rejections
99
+ * (matching the methods' async signatures) and report the death, instead of
100
+ * crashing whichever component happened to make the next call.
101
+ *
102
+ * Exported for tests.
103
+ */
104
+ export function guardDestroyedProxy(api, onDestroyedCall) {
105
+ const wrap = (value) => {
106
+ if ((typeof value !== "function" && typeof value !== "object") || value === null) {
107
+ return value;
108
+ }
109
+ return new Proxy(value, {
110
+ // penpal's remote proxy resolves nested method paths lazily through
111
+ // property gets, so wrap whatever comes back the same way.
112
+ get: (target, property, receiver) => wrap(Reflect.get(target, property, receiver)),
113
+ apply: (target, thisArg, args) => {
114
+ try {
115
+ return Reflect.apply(target, thisArg, args);
116
+ }
117
+ catch (error) {
118
+ if (error instanceof PenpalError && error.code === ErrorCode.ConnectionDestroyed) {
119
+ onDestroyedCall(error);
120
+ return Promise.reject(error);
121
+ }
122
+ throw error;
123
+ }
124
+ },
125
+ });
126
+ };
127
+ return wrap(api);
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocksdiy/react-common",
3
- "version": "1.38.0",
3
+ "version": "1.39.0",
4
4
  "type": "module",
5
5
  "description": "React common",
6
6
  "keywords": [],
@@ -35,7 +35,7 @@
35
35
  "@copilotkit/react-core": "1.60.1",
36
36
  "@radix-ui/react-slot": "^1.2.3",
37
37
  "@vapi-ai/web": "^2.5.2",
38
- "penpal": "^7.0.6",
38
+ "penpal": "^8.0.0",
39
39
  "react": "^19.2.8",
40
40
  "use-stick-to-bottom": "1.1.4",
41
41
  "@blocksdiy/blocks-client-api": "1.5.0"
@@ -45,6 +45,8 @@
45
45
  "babel-plugin-react-compiler": "^1.0.0",
46
46
  "eslint-plugin-react": "^7.37.5",
47
47
  "eslint-plugin-react-hooks": "^7.0.1",
48
+ "jest": "^29.7.0",
49
+ "ts-jest": "^29.2.5",
48
50
  "tsc-alias": "^1.8.10",
49
51
  "typescript": "^5.9.3",
50
52
  "@blockscom/eslint-config": "4.6.4"
@@ -55,7 +57,7 @@
55
57
  "scripts": {
56
58
  "clean": "rm -rf ./dist/ && rm -rf tsconfig.tsbuildinfo && rm -rf node_modules",
57
59
  "build": "rm -rf dist && tsc && tsc-alias && echo $(date +%s) > .build-sentinel.json",
58
- "test": "echo \"no tests\"",
60
+ "test": "jest --config test/jest.json",
59
61
  "lint": "pnpm eslint src/",
60
62
  "lint-fix": "pnpm eslint src/ --fix"
61
63
  }