useSessionAccount never cancels an in-flight initialize() on unmount — polling and note consumption continue after the component is gone
- 主要言語
- 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