0xMiden / 0xMiden/web-sdk

useSessionAccount never cancels an in-flight initialize() on unmount — polling and note consumption continue after the component is gone

オープン
#288 コメント 1 件 リアクション 0 件 担当者 0 名 GitHub で見る
主要言語
TypeScript
スター
1
フォーク
21
平均マージ
12時間 14分
マージ済み PR(30日)
41

説明

### Packages versions

@miden-sdk/react: 0.15.9
@miden-sdk/miden-sdk: 0.15.9
miden-client (Cargo workspace dep): 0.15.5
Repo commit: dcfdc6a

### Bug description

`useSessionAccount` has a cancellation flag (`cancelledRef`) threaded all the way into its polling loop, but no
thing sets it when the component unmounts. `reset()` (line 174) is the only place that flips it to `true`, and
the hook registers no effect cleanup.

**What happens:** an `initialize()` call that is in its `funding` or `consuming` stage keeps running after the
component that started it has unmounted. Until the deadline expires, the unmounted hook:

- keeps calling `client.syncState()` and `client.getConsumableNotes()` on every poll iteration;
- builds and submits a consume transaction via `client.submitNewTransaction()` (lines 236–241) if a consumable
note shows up — an on-chain side effect originating from a component that no longer exists;
- calls `setSessionAccountId` / `setStep` / `setError` on the unmounted component, and `setAccounts` (line 118)
writes to the global zustand store;
- writes to `localStorage` (lines 124 and 147);
- on timeout throws at line 247, which propagates through lines 150–156 and calls `setError`/`setStep` and reth
rows, because `cancelledRef.current` is still `false`.

**What should have happened:** unmounting the component should stop the in-flight `initialize()` — no further R
PC/WASM calls and, above all, no transaction submitted on behalf of a screen the user has already left.

**Relevant code:** `packages/react-sdk/src/hooks/useSessionAccount.ts`

- `cancelledRef` is created at line 53 and reset to `false` at the start of every `initialize()` (line 103). Th
e only assignment to `true` is inside `reset()` (line 174).
- The hook's single `useEffect` (lines 7ssion and returns no cleanup function. The
re is no unmount cleanup anywhere in the file.
- `waitAndConsume` (lines 218–248) loops until `Date.now() >= deadline`, where `deadline = Date.now() + maxWait
Ms` (`maxWaitMs` defaults to `60_000`, l `client.syncState()` (line 230) and `clie
nt.getConsumableNotes()` (line 235), then sleeps `pollIntervalMs` (defaults to `3000`, line 57) at line 244.
- After unmount `cancelledRef.current` i (cancelledRef.current) return;` guard (li
nes 228, 232) passes and the loop runs to completion.

Callers have no way to stop this other t)` from their own unmount cleanup, which is not documented in the hook's JSDoc.

### How can this be reproduced?

Reproduced on `dcfdc6a` (v0.15.9) using `packages/react-sdk`'s own vitest/jsdom setup — no WASM build needed, s
ince `@miden-sdk/miden-sdk` is already aliased to the test mock.

Steps:

1. Render a component using `useSessionAccount({ fund, assetId })`.
2. Call `initialize()`.
3. While `step` is `"funding"` or `"consuming"`, unmount the component (e.g. navigate away) without calling `re
set()`.
4. Observe that `syncState` / `getConsumableNotes` keep being called, and that a consume transaction is submitt
ed if funding arrives.

As a test — drop this in `packages/react-sdk/src/__tests__/hooks/` and run `pnpm --filter @miden-sdk/react test
`. It passes, i.e. the unmounted hook is still working:

```tsx
const syncState = vi.fn().mockResolvedValue({});
let fundingArrived = false;
const mockClient = createMockWebClient({
newWallet: vi.fn().mockResolvedValue(mockWallet),
syncState,
getConsumableNotes: vi.fn(async () => (fundingArrived ? [mockConsumableNote] : [])),
newConsumeTransactionRequest: vi.fn().mockReturnValue({}),
submitNewTransaction,
});
mockUseMiden.mockReturnValue({ client: mockClient, isReady: true, sync: vi.fn() });

const { result, unmount } = renderHook((
useSessionAccount({ fund: vi.fn().mockResolvedValue(undefined), assetId: "0xfaucet",
pollIntervalMs: 10, maxWaitMs: 30_000 })
);

await act(async () => {
void result.current.initialize().catch(() => {}); // stays in the poll loop
await sleep(80);
});

const atUnmount = syncState.mock.calls.l
unmount(); // user navigates away; reset() is never called
fundingArrived = true; // funding lands after the component is gone
await sleep(300);

expect(syncState.mock.calls.length).toBeGreaterThan(atUnmount); // passes
expect(submitNewTransaction).toHaveBeenCalled(); // passes
```

A second variant, where funding never arrives, shows the loop simply keeps polling: 32 additional `syncState` c
alls within 500ms of unmount, still going. At the default `pollIntervalMs: 3000` / `maxWaitMs: 60_000` that is
a poll every ~3s for up to a minute.

### Relevant log output

```shell
Case 1 — funding arrives after unmount (pollIntervalMs: 10):
syncState calls at unmount: 7
syncState calls 300ms after unmount: 8
submitNewTransaction called after unmount: 1 time(s)

Case 2 — funding never arrives (pollIntervalMs: 10):
syncState calls at unmount: 6
syncState calls 500ms after unmount: 38
extra calls made by the unmounted hook: 32
```

コントリビューションガイド

コントリビューションガイドを開く

調査の方向性

The bug is in packages/react-sdk/src/hooks/useSessionAccount.ts. Start by reading the hook's useEffect and the initialize function. The cancellation flag cancelledRef is not set on unmount. Add a cleanup function to the useEffect that sets cancelledRef.current = true and calls reset(). Verify the fix by running the provided test in packages/react-sdk/src/__tests__/hooks/. Ensure polling stops and no transactions are submitted after unmount.

索引モデルが issue の本文から書いたものです。

評価

技術スタック
react, typescript
領域
frontend, web-dev
issue の種類
バグ
難易度
3/5
見積もり時間
1〜2日
活発さ
静か
明瞭さ
明確に書かれている
初心者へのやさしさ
65/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。