From 4ac78e12eff5f5da041c4c82c1ee4e82ad9b2c3e Mon Sep 17 00:00:00 2001 From: Kant Date: Sat, 30 May 2026 19:40:37 +0200 Subject: [PATCH] fix(client-react): create WalletAPIClient once in WalletAPIProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider created the client inside a `useState` initializer. Since 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` — which broke the Next.js simulator example. Use the ref "create once" pattern so exactly one client is instantiated and remains the owner of the transport, even under strict mode. Also make the example's simulator flag detection more forgiving: `?simulator` (bare) now activates the simulator, not just `?simulator=true`. --- .changeset/fix-provider-strict-mode-client.md | 15 ++++++++++++ examples/client-nextjs/app/page.tsx | 2 +- .../components/WalletAPIProvider/index.tsx | 23 ++++++++++++++----- 3 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 .changeset/fix-provider-strict-mode-client.md 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) {