diff --git a/.changeset/fix-provider-strict-mode-client.md b/.changeset/fix-provider-strict-mode-client.md new file mode 100644 index 00000000..3be885a2 --- /dev/null +++ b/.changeset/fix-provider-strict-mode-client.md @@ -0,0 +1,15 @@ +--- +"@ledgerhq/wallet-api-client-react": patch +--- + +fix(client-react): create WalletAPIClient once in WalletAPIProvider + +`WalletAPIProvider` created its client inside a `useState` initializer. Because +the `WalletAPIClient` constructor has a side effect — it registers itself on +`transport.onMessage` — React strict mode's double-invocation constructed a +second, discarded client whose constructor hijacked the transport. Responses +were then routed to a client with no pending requests, throwing +`no ongoingRequest`. + +The client is now instantiated exactly once via the ref "create once" pattern, +so a single instance owns the transport even under strict mode. diff --git a/examples/client-nextjs/app/page.tsx b/examples/client-nextjs/app/page.tsx index 02993996..8e4e01ac 100644 --- a/examples/client-nextjs/app/page.tsx +++ b/examples/client-nextjs/app/page.tsx @@ -11,7 +11,7 @@ import { AccountsList } from "../components/AccountsList"; const isSimulator = typeof window === "undefined" ? false - : new URLSearchParams(window.location.search).get("simulator"); + : new URLSearchParams(window.location.search).has("simulator"); function getWalletAPITransport() { if (typeof window === "undefined") { diff --git a/packages/client-react/src/components/WalletAPIProvider/index.tsx b/packages/client-react/src/components/WalletAPIProvider/index.tsx index bf89a395..ccc15805 100644 --- a/packages/client-react/src/components/WalletAPIProvider/index.tsx +++ b/packages/client-react/src/components/WalletAPIProvider/index.tsx @@ -1,5 +1,5 @@ import { WalletAPIClient } from "@ledgerhq/wallet-api-client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { WalletAPIProviderContext, initialContextValue } from "./context"; import type { WalletAPIProviderContextState, @@ -21,11 +21,22 @@ export function WalletAPIProvider({ initialContextValue.state, ); - const [client] = useState( - () => - providedClient ?? - new WalletAPIClient(transport, logger, getCustomModule, eventHandlers), - ); + // A WalletAPIClient registers itself on `transport.onMessage` in its + // constructor, so it must be instantiated exactly once. A `useState` + // initializer is unsafe here: React strict mode invokes it twice, which + // constructs a second (discarded) client whose constructor hijacks the + // transport, leaving responses routed to a client with no pending requests + // ("no ongoingRequest"). The ref "create once" pattern guarantees a single + // instance that owns the transport. + const clientRef = useRef(undefined); + clientRef.current ??= + providedClient ?? + new WalletAPIClient(transport, logger, getCustomModule, eventHandlers); + // Reading the ref during render is intentional here: the client is created + // once (above) and its identity is stable for the lifetime of the provider, + // so it can safely be passed down without triggering re-renders. + // eslint-disable-next-line react-hooks/refs -- stable create-once instance + const client = clientRef.current; useEffect(() => { if (eventHandlers) {