[LWDM] feat(devtools): wire LLD and LWM into devtools WebSocket transport - #20267
Conversation
b2f0614 to
506baf1
Compare
There was a problem hiding this comment.
Pull request overview
This PR wires Ledger Live Desktop (LLD) and Ledger Live Mobile (LWM) DevTools screens into the @devtools/wire WebSocket transport so each app can relay (and hydrate) Redux store snapshots/actions through a local hub (default ws://127.0.0.1:9090), and exposes connection controls via the TransportPanel in the DevTools footer.
Changes:
- Add per-app “sleeping” RTK listener middleware instances and append them to each app’s store middleware chain.
- Wrap each app’s root reducer with
withCopyStoreHydrationto allow incoming store snapshots to replace state. - Add
useDevToolsRelayhooks and renderTransportPanelin DevTools screen footers to manage the hub URL/connection state.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | Updates lockfile to reflect newly added devtools workspace dependencies and peer resolution changes. |
| apps/ledger-live-mobile/src/state-manager/sleepingListener.ts | Adds an exported RTK listener middleware instance to be driven by the copy-store protocol. |
| apps/ledger-live-mobile/src/state-manager/configureStore.ts | Wraps reducer with copy-store hydration and appends the sleeping listener middleware. |
| apps/ledger-live-mobile/src/mvvm/features/DevTools/screens/DevToolsScreen/useDevToolsRelay.ts | Adds a relay hook that builds a @devtools/wire transport/protocol stack for LWM. |
| apps/ledger-live-mobile/src/mvvm/features/DevTools/screens/DevToolsScreen/index.tsx | Renders TransportPanel in the DevTools footer and instantiates the relay. |
| apps/ledger-live-mobile/package.json | Adds @devtools/protocols, @devtools/transport-panel, and @devtools/wire deps. |
| apps/ledger-live-desktop/src/state-manager/sleepingListener.ts | Adds an exported RTK listener middleware instance to be driven by the copy-store protocol. |
| apps/ledger-live-desktop/src/state-manager/configureStore.ts | Wraps reducer with copy-store hydration and appends the sleeping listener middleware. |
| apps/ledger-live-desktop/src/mvvm/features/DevTools/screens/DevToolsScreen/useDevToolsRelay.ts | Adds a relay hook that builds a @devtools/wire transport/protocol stack for LLD. |
| apps/ledger-live-desktop/src/mvvm/features/DevTools/screens/DevToolsScreen/index.tsx | Renders TransportPanel in the DevTools footer and instantiates the relay. |
| apps/ledger-live-desktop/package.json | Adds @devtools/transport, @devtools/protocols, @devtools/transport-panel, and @devtools/wire deps. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (1)
apps/ledger-live-desktop/src/mvvm/features/DevTools/screens/DevToolsScreen/useDevToolsRelay.ts:22
- The relay is created during render (
if (!relay) relay = buildRelay(store);).buildTransportconnects immediately (devtools/wire/src/wire.ts:44-55), so this performs a WebSocket connection as a render-time side effect. Prefer deferring transport creation/connection touseEffect(and callingtransport.disconnect()on cleanup) or changingbuildTransportto not auto-connect and callingconnect()from an effect.
export function useDevToolsRelay() {
const store = useStore();
if (!relay) relay = buildRelay(store);
const wireState = useSyncExternalStore(relay.subscribe, relay.getState, relay.getState);
return { wire: relay, wireState };
Web Tools Build Status
|
Rsdoctor Bundle Diff AnalysisFound 7 projects in monorepo, 3 projects with changes. 📊 Quick Summary
📋 Detailed Reports (Click to expand)📁 desktop-rendererPath:
📦 Download Diff Report: desktop-renderer Bundle Diff 📁 desktop-webviewDappPreloaderPath:
📦 Download Diff Report: desktop-webviewDappPreloader Bundle Diff 📁 mobilePath:
📦 Download Diff Report: mobile Bundle Diff Generated by Rsdoctor GitHub Action |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 12 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (5)
apps/ledger-live-desktop/src/mvvm/features/DevTools/screens/DevToolsScreen/useDevToolsRelay.ts:22
- This hook creates and connects the WebSocket relay during render, and keeps it in a module-level singleton. Because desktop tests (and potentially multiple app providers) create a new Redux store per render, the singleton will stay bound to the first store instance and can leak state across tests; it also never disconnects on unmount.
export function useDevToolsRelay() {
const store = useStore();
if (!relay) relay = buildRelay(store);
const wireState = useSyncExternalStore(relay.subscribe, relay.getState, relay.getState);
return { wire: relay, wireState };
apps/ledger-live-mobile/src/mvvm/features/DevTools/screens/DevToolsScreen/useDevToolsRelay.ts:21
buildTransportconnects immediately (see @devtools/wire) and this hook creates the relay during render (if (!relay) relay = buildRelay()), which is a React side effect and can also throw whenglobalThis.WebSocketis unavailable (e.g. some Jest/Node environments). It also never disconnects, so the WebSocket can stay open after the DevTools screen unmounts.
export function useDevToolsRelay() {
if (!relay) relay = buildRelay();
const wireState = useSyncExternalStore(relay.subscribe, relay.getState, relay.getState);
return { wire: relay, wireState };
apps/ledger-live-mobile/src/mvvm/features/DevTools/screens/DevToolsScreen/index.tsx:10
- MVVM: this screen view now calls
useDevToolsRelay()directly and wires transport concerns in the view layer. Per the repo MVVM rules,index.tsxviews undersrc/mvvm/**/screens/**should only consume the ViewModel output; side-effectful logic (WebSocket transport/store wiring) should live in the ViewModel and be passed down as props (or the footer component should be owned by the ViewModel).
const { config, screenOptions } = useDevToolsScreenViewModel();
const { wire, wireState } = useDevToolsRelay();
apps/ledger-live-desktop/src/mvvm/features/DevTools/screens/DevToolsScreen/index.tsx:10
- MVVM: the screen view calls
useDevToolsRelay()directly and constructs the transport footer in the view layer. In this repo's MVVM pattern,index.tsxviews undersrc/mvvm/**/screens/**should be props-only; side-effectful logic (WebSocket transport/store wiring) should live in the ViewModel and be passed down.
const { config, onClose } = useDevToolsScreenViewModel();
const { wire, wireState } = useDevToolsRelay();
pnpm-lock.yaml:2227
- The lockfile includes unrelated resolution changes for existing React Native deps (e.g.
@react-native/babel-presetnow resolved as0.81.6(@babel/core@...)and new standalone Babel snapshot entries). These changes aren’t directly explained by the new@devtools/*workspace deps and make the PR noisier / harder to audit; consider regenerating the lockfile to limit the diff to dependency graph changes caused by the added devtools packages (or call out why the extra Babel entries are expected).
0f5f517 to
7367878
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (2)
apps/ledger-live-desktop/package.json:79
@devtools/transportis declared as a direct dependency here but does not appear to be imported anywhere inapps/ledger-live-desktop/src/**(the app imports@devtools/wire/@devtools/transport-panelinstead, and those packages already depend on@devtools/transport). Keeping it here makes the dependency graph noisier and increases the chance of unused-dep drift.
"@devtools/shell": "workspace:^",
"@devtools/bindings": "workspace:*",
"@devtools/transport": "workspace:*",
"@devtools/protocols": "workspace:*",
"@devtools/transport-panel": "workspace:*",
pnpm-lock.yaml:2226
- The lockfile includes a large set of resolution changes unrelated to the new DevTools workspace dependencies (e.g.
@react-native/babel-presetnow being recorded as0.81.6(@babel/core@7.28.5)plus new snapshot entries / peerDependency metadata). This makes the PR harder to review and can cause avoidable merge conflicts. Please regeneratepnpm-lock.yamlso it only reflects the DevTools dependency additions, or add a note in the PR description explaining why the React Native Babel preset resolution changed as part of this work.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (1)
apps/ledger-live-desktop/package.json:80
@devtools/transportis declared as a direct dependency here, but this workspace doesn’t import it (only@devtools/wire/@devtools/transport-panelare used). Keeping unused direct deps can trip unused-dependency checks and makes dependency intent unclear.
"@devtools/bindings": "workspace:*",
"@devtools/transport": "workspace:*",
"@devtools/protocols": "workspace:*",
"@devtools/transport-panel": "workspace:*",
"@devtools/wire": "workspace:*",
7367878 to
3f3dcc6
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (5)
apps/ledger-live-desktop/src/mvvm/features/DevTools/screens/DevToolsScreen/useDevToolsScreenViewModel.ts:7
- This ViewModel no longer has an explicit return type. In this codebase, MVVM ViewModel hooks typically expose a named/explicit return type (e.g.
apps/ledger-live-desktop/src/mvvm/features/Contacts/screens/Contacts/useContactsViewModel.ts:38) to keep the public surface stable and avoid leaking implementation types through inference. Consider restoring an explicit return type here.
export function useDevToolsScreenViewModel() {
apps/ledger-live-desktop/package.json:80
@devtools/transportis declared as a direct dependency but there are no imports of@devtools/transportin the Desktop app (it’s already pulled transitively via@devtools/wire/@devtools/transport-panel). Keeping it here adds maintenance overhead and can mask accidental direct usage. Consider removing it unless the app imports it directly.
"@devtools/shell": "workspace:^",
"@devtools/bindings": "workspace:*",
"@devtools/transport": "workspace:*",
"@devtools/protocols": "workspace:*",
"@devtools/transport-panel": "workspace:*",
"@devtools/wire": "workspace:*",
apps/ledger-live-mobile/src/mvvm/features/DevTools/screens/DevToolsScreen/useDevToolsScreenViewModel.ts:10
- This ViewModel no longer has an explicit return type, which makes the exported surface depend on inference and can change unintentionally as implementation details evolve. Consider restoring an explicit return type for the ViewModel result (consistent with many MVVM hooks in the repo).
export function useDevToolsScreenViewModel() {
apps/ledger-live-mobile/src/mvvm/features/DevTools/screens/DevToolsScreen/index.tsx:17
- This adds the
footerTransportPanel wiring to DevToolsScreen, but the existing DevToolsScreen integration test doesn’t assert thatfooteris passed through to@devtools/shell(and thus the relay panel remains wired). Adding an assertion forfooter(and optionally itshubUrl/roleprops) would better protect this behavior from regressions.
return (
<DevTools
config={config}
screenOptions={screenOptions}
footer={
<TransportPanel transport={transport} hubUrl={hubUrl} setHubUrl={setHubUrl} role={role} />
}
/>
apps/ledger-live-desktop/src/mvvm/features/DevTools/screens/DevToolsScreen/index.tsx:20
- This adds the
footerTransportPanel wiring to DevToolsScreen, but the current DevToolsScreen integration tests don’t assert thatfooteris passed to@devtools/shell(and thus the relay panel remains wired). Adding an assertion forfooterwould better cover the new behavior.
<DevTools
config={config}
onClose={onClose}
footer={
<TransportPanel transport={transport} hubUrl={hubUrl} setHubUrl={setHubUrl} role={role} />
}
/>
3f3dcc6 to
4a30521
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (1)
apps/ledger-live-desktop/package.json:80
@devtools/transportis added as a direct dependency, but there are no imports/usages of it inapps/ledger-live-desktop(only@devtools/wire/@devtools/transport-panelare used, and both already depend on@devtools/transport). Keeping it here is redundant and increases dependency surface / lockfile churn.
"@devtools/bindings": "workspace:*",
"@devtools/transport": "workspace:*",
"@devtools/protocols": "workspace:*",
"@devtools/transport-panel": "workspace:*",
"@devtools/wire": "workspace:*",
4a30521 to
6ebde5e
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (3)
apps/ledger-live-mobile/src/state-manager/configureStore.ts:28
sleepingListeneris imported here via a relative path (./sleepingListener), while the relay imports it via the~alias (~/state-manager/sleepingListener). If the bundler/test resolver treats these as distinct module IDs, you can end up with two different listener instances: the store will register middleware from one instance while the relay attaches listeners to the other, breaking the copy-store protocol.
Prefer using the same import specifier everywhere (e.g. the ~ alias) to guarantee a single shared instance.
import { withCopyStoreHydration } from "@devtools/protocols/copyStore";
import { sleepingListener } from "./sleepingListener";
apps/ledger-live-desktop/src/state-manager/configureStore.ts:24
sleepingListeneris imported here via a relative path (./sleepingListener), while the relay imports it via the~alias (~/state-manager/sleepingListener). If these resolve to separate module IDs, the store can register middleware from one listener instance while the relay drives another, so relayed actions/snapshots won’t flow through the middleware actually mounted in the store.
Use the same import specifier across the app (e.g. the ~ alias) to guarantee a single shared instance.
import { fetchRemoteFlags as defaultFetchRemoteFlags } from "~/firebase/remoteConfig";
import { sleepingListener } from "./sleepingListener";
import { withCopyStoreHydration } from "@devtools/protocols/copyStore";
apps/ledger-live-mobile/src/mvvm/features/DevTools/screens/DevToolsScreen/useDevToolsRelay.ts:7
- On Android emulators,
ws://127.0.0.1:9090points to the emulator/device itself, not the host machine running the relay server. This makes the default hub URL non-working out-of-the-box on Android (a similar pattern elsewhere uses10.0.2.2for Android; e.g.apps/ledger-live-mobile/src/e2e/bridge/client.ts:66-68).
Consider using a platform-aware default (localhost for iOS, 10.0.2.2 for Android) so the TransportPanel starts from a usable value.
const HUB_URL = "ws://127.0.0.1:9090";
const ROLE = "host" as const;
6ebde5e to
2331a8a
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated 2 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (4)
apps/ledger-live-mobile/src/state-manager/configureStore.ts:108
sleepingListener.middlewareis appended unconditionally, even though it is only needed when the copy-store relay is active. Consider gating it to dev/debug builds so production doesn’t pay middleware overhead / expose listener wiring when the relay can’t be used.
.concat(sleepingListener.middleware),
apps/ledger-live-desktop/src/state-manager/configureStore.ts:122
sleepingListener.middlewareis added for every Desktop store instance, but it’s only required when the WebSocket relay is in use. Gating this to__DEV__avoids unnecessary middleware work in production builds.
.concat(sleepingListener.middleware),
apps/ledger-live-mobile/src/mvvm/features/DevTools/screens/DevToolsScreen/index.tsx:16
- The
footernow wires aTransportPanel(new user-visible behavior for this screen), but the existing integration test doesn’t assert that thefooterprop is passed or that the panel receives the expectedhubUrl/role/setHubUrlprops. Adding a minimal assertion would prevent regressions.
<DevTools
config={config}
screenOptions={screenOptions}
footer={
<TransportPanel transport={transport} hubUrl={hubUrl} setHubUrl={setHubUrl} role={role} />
}
apps/ledger-live-desktop/src/mvvm/features/DevTools/screens/DevToolsScreen/index.tsx:20
- The
footernow renders aTransportPanel, but Desktop’s DevToolsScreen integration tests don’t verify that this footer is provided (or that it receives the expected transport + URL props). Please add a minimal assertion to cover this new behavior.
<DevTools
config={config}
onClose={onClose}
footer={
<TransportPanel transport={transport} hubUrl={hubUrl} setHubUrl={setHubUrl} role={role} />
}
/>
2331a8a to
53cd058
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (3)
apps/ledger-live-mobile/src/mvvm/features/DevTools/integrations/DevToolsScreen.integration.test.tsx:26
- The app relay is configured with
ROLE = "host"(seeuseDevToolsRelay.ts), but this test mock returnswireState.role: "tool". That mismatch can hide role-specific UI behavior inTransportPaneland makes the test setup inconsistent with runtime.
const wireState = { hubUrl: "ws://127.0.0.1:9090", role: "tool" };
apps/ledger-live-mobile/package.json:92
@devtools/protocolsis not imported anywhere in the mobile app code (only present in this package.json). Since@devtools/wirealready declares@devtools/protocolsas a dependency, this direct dependency looks redundant and increases the app's dependency surface.
"@devtools/bindings": "workspace:^",
"@devtools/shell": "workspace:^",
"@devtools/protocols": "workspace:*",
"@devtools/transport-panel": "workspace:*",
"@devtools/wire": "workspace:*",
apps/ledger-live-desktop/package.json:79
@devtools/protocolsis not imported anywhere in the desktop app code (only present in this package.json). Since@devtools/wirealready depends on@devtools/protocols, this direct dependency appears redundant and expands the app's dependency surface unnecessarily.
"@domain/entity-large-screen-upsell-modal": "workspace:*",
"@devtools/shell": "workspace:^",
"@devtools/bindings": "workspace:*",
"@devtools/protocols": "workspace:*",
"@devtools/transport-panel": "workspace:*",
"@devtools/wire": "workspace:*",
53cd058 to
956d4a1
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (2)
apps/ledger-live-mobile/src/state-manager/configureStore.ts:29
- The PR description mentions wrapping the root reducer with
withCopyStoreHydration, but the store is still configured withreducer: reducers. The copy-store protocol’ssetStoreStatedispatchesreplaceStoreAction, which won’t actually hydrate anything unless the root reducer handles@devtools/copy-store/replace(or is wrapped). If you want snapshot hydration to work (e.g., if this endpoint ever runs asrole: toolor receives a snapshot), wrap the reducer; otherwise, please update the PR description to avoid claiming hydration support.
import { createFeatureFlagsMiddleware, type PartialFeatures } from "@shared/feature-flags";
import { fetchRemoteFlags } from "~/firebase/remoteConfig";
import { sleepingListener } from "./sleepingListener";
import { createPkcePairWithExpoCrypto } from "~/helpers/pkce";
apps/ledger-live-desktop/src/state-manager/configureStore.ts:27
- The PR description mentions wrapping the root reducer with
withCopyStoreHydration, but this store is still configured withreducer: reducers. Since the copy-store protocol’ssetStoreStatedispatchesreplaceStoreAction, snapshot hydration won’t work unless the root reducer handles@devtools/copy-store/replace(or is wrapped). If hydration is intended, wrap the reducer; otherwise update the PR description to reflect the current host-only wiring.
import { fetchRemoteFlags as defaultFetchRemoteFlags } from "~/firebase/remoteConfig";
import { sleepingListener } from "./sleepingListener";
type Props = {
state?: State;
dbMiddleware?: Middleware;
|



📝 Description
Summary
Wires both Ledger Live Desktop and Ledger Live Mobile into the
@devtools/wireWebSocket transport, enabling the Redux store to be relayed to external devtools
clients through a local WebSocket hub.
What changed
Each app gets three additions:
sleepingListener.ts— a bare RTKlistenerMiddlewareinstance that thecopy-store protocol attaches its listeners to at runtime (kept separate so it
can be passed to both the store and the relay without a circular import).
configureStore.ts— appendssleepingListener.middlewaretothe middleware chain.
useDevToolsRelay.ts— lazily builds a singletonbuildTransportrelayconfigured as
"host"onws://127.0.0.1:9090, usingbuildCopyStoreProtocolto broadcast store snapshots/diffs over the wire.
The
TransportPanelis rendered in the DevTools footer (via thefooterprop),exposing connection state and the hub URL field.
To create the websocket to make it work, we can use this command : "pnpm --filter @devtools/relay start" or pnpm web-tools dev:remote" to run web-tools and the server at the same time
🔗 Context