react-sdk note cache is global and not keyed by the filter, so useNotes/useNoteStream return each other's notes
- Dominant language
- TypeScript
- Stars
- 1
- Forks
- 21
- Avg merge
- 12h 14m
- Merged PRs (30d)
- 41
Description
### Packages versions
@miden-sdk/react: 0.15.9
@miden-sdk/miden-sdk: 0.15.9
Verified against main @ dcfdc6af43934773dc66b7c442dfbea927bbfabf ("release: 0.15.9", #261)
react: 18.2 (jsdom / vitest)
### Bug description
`useNotes(options)` and `useNoteStream(options)` each fetch a **filtered** note list from the client, but they both write that filtered result into — and read it back from — a **single global slot** in the Zustand store:
```ts
// packages/react-sdk/src/store/MidenStore.ts:29
notes: InputNoteRecord[];
```
The slot carries no record of which filter produced it. Every hook instance is both a producer and a consumer of the same array, so hooks with different `status` / `accountId` options silently serve each other's data. Two distinct failures follow.
**1. A second hook with a different filter never issues its own query.**
`useNotes`'s initial-fetch effect is guarded on the *global* array being empty:
```ts
// packages/react-sdk/src/hooks/useNotes.ts:104-108
useEffect(() => {
if (isReady && notes.length === 0) {
refetch();
}
}, [isReady, notes.length, refetch]);
```
If any other hook already populated the store, a later-mounting `useNotes({ status: 'consumed' })` finds `notes.length > 0`, skips its fetch entirely, and renders the other hook's list as if it were its own. No error, no loading state, no warning.
**2. Concurrently mounted hooks clobber each other on every sync.**
Both hooks also refetch on each `lastSyncTime` change ([`useNotes.ts:111-114`](https://github.com/0xMiden/web-sdk/blob/main/packages/react-sdk/src/hooks/useNotes.ts#L111-L114), [`useNoteStream.ts:107-111`](https://github.com/0xMiden/web-sdk/blob/main/packages/react-sdk/src/hooks/useNoteStream.ts#L107-L111)), each with its own filter, into the same slot. Last writer wins, and since `setNotesIfChanged` diffs on the note-ID set, each write *is* a change relative to the other filter's result — so the two hooks ping-pong the global array on every sync tick, and both re-render with the wrong data.
This breaks the usage the README documents. [`packages/react-sdk/README.md:552-561`](https://github.com/0xMiden/web-sdk/blob/main/packages/react-sdk/README.md#L552-L561) shows exactly this shape in one component:
```tsx
const { notes, consumableNotes, noteSummaries, ... } = useNotes();
// With filtering options
const { notes: committedNotes } = useNotes({
status: 'committed',
...
});
```
The defaults collide too: `useNotes()` defaults to `NoteFilterTypes.All` while `useNoteStream()` defaults to `Committed`, so an app using both hooks as documented hits this without passing any options at all.
**Same root cause, second surface: `consumableNotes`.** `useNotes({ accountId })` scopes `getConsumableNotes(accountId)` per account but writes to the single global `consumableNotes` slot ([`MidenStore.ts:30`](https://github.com/0xMiden/web-sdk/blob/main/packages/react-sdk/src/store/MidenStore.ts#L30)), so a multi-account view where two components watch different accounts serves both of them whichever account fetched last.
**Minor, same area:** `isLoadingNotes` is a single global flag ([`MidenStore.ts:37`](https://github.com/0xMiden/web-sdk/blob/main/packages/react-sdk/src/store/MidenStore.ts#L37)). One hook's `finally { setLoadingNotes(false) }` clears the flag while another hook's fetch is still in flight, so `isLoading` reports `false` mid-fetch.
**Expected behavior:** a hook instance only ever observes notes matching the options it was called with, and always issues its own query for a filter that has not been fetched.
**Suggested fix:** key the cached notes by a derived filter key (e.g. `notes: Record` keyed by `status`, and `consumableNotes` keyed by `accountId ?? '*'`), have each hook read and write only its own bucket, and change the initial-fetch guard from "global array empty" to "this bucket not yet fetched". `noteFirstSeen` should stay global — it is a genuinely cross-filter observation log — but should then be pruned across the union of buckets rather than a single list. Happy to open a PR for this if the approach looks right.
### How can this be reproduced?
Drop this file in `packages/react-sdk/src/__tests__/hooks/` and run `pnpm --filter @miden-sdk/react test`. It needs no WASM build — the vitest config already aliases `@miden-sdk/miden-sdk` to the mock entry. Both tests fail on `main`.
```tsx
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { NoteFilter } from "@miden-sdk/miden-sdk";
import { useNotes } from "../../hooks/useNotes";
import { useNoteStream } from "../../hooks/useNoteStream";
import { useMiden } from "../../context/MidenProvider";
import { useMidenStore } from "../../store/MidenStore";
import {
createMockWebClient,
createMockInputNoteRecord,
} from "../mocks/miden-sdk";
vi.mock("../../context/MidenProvider", () => ({ useMiden: vi.fn() }));
const mockUseMiden = useMiden as ReturnType;
// NoteFilterTypes from the mocked SDK: All=0, Consumed=1, Committed=2
const COMMITTED = [createMockInputNoteRecord("0xcommitted1")];
const CONSUMED = [createMockInputNoteRecord("0xconsumed1")];
const ALL = [
createMockInputNoteRecord("0xcommitted1"),
createMockInputNoteRecord("0xconsumed1"),
];
function makeClient() {
// Tag the filter object with its type so getInputNotes can discriminate,
// the way the real WASM NoteFilter does.
vi.mocked(NoteFilter).mockImplementation(
(type: unknown) => ({ _type: type, free: vi.fn() }) as never
);
return createMockWebClient({
getInputNotes: vi.fn(async (filter: { _type: number }) => {
if (filter._type === 1) return CONSUMED;
if (filter._type === 2) return COMMITTED;
return ALL;
}),
getConsumableNotes: vi.fn().mockResolvedValue([]),
});
}
beforeEach(() => {
useMidenStore.getState().reset();
vi.clearAllMocks();
});
describe("note cache is global and not keyed by filter", () => {
it("a second useNotes with a different status never issues its own query", async () => {
const client = makeClient();
mockUseMiden.mockReturnValue({ client, isReady: true });
act(() => {
useMidenStore.getState().setClient(client as never);
});
// Component A mounts first with the default filter (All).
const a = renderHook(() => useNotes());
await waitFor(() => expect(a.result.current.notes.length).toBe(2));
// Component B mounts asking for consumed notes only.
const b = renderHook(() => useNotes({ status: "consumed" }));
await waitFor(() =>
expect(b.result.current.notes.length).toBeGreaterThan(0)
);
const requestedTypes = (
client.getInputNotes as ReturnType
).mock.calls.map((c) => c[0]._type);
console.log("filter types actually queried:", requestedTypes);
console.log(
"B ids:",
b.result.current.notes.map((n) => n.id()!.toString())
);
expect(requestedTypes).toContain(1); // Consumed(1) is never queried
});
it("useNotes({status}) and useNoteStream() clobber each other's cache", async () => {
const client = makeClient();
mockUseMiden.mockReturnValue({ client, isReady: true });
act(() => {
useMidenStore.getState().setClient(client as never);
});
// Both hooks mount together, each with a different filter.
const stream = renderHook(() => useNoteStream()); // default: committed
const consumed = renderHook(() => useNotes({ status: "consumed" }));
await waitFor(() =>
expect(useMidenStore.getState().notes.length).toBeGreaterThan(0)
);
// Drive a sync tick — both hooks refetch with their own filter into the
// same global `notes` slot.
act(() => {
useMidenStore.getState().setSyncState({ lastSyncTime: Date.now() });
});
await waitFor(() =>
expect(
(client.getInputNotes as ReturnType).mock.calls.length
).toBeGreaterThan(2)
);
const streamIds = stream.result.current.notes.map((n) => n.id);
console.log("useNoteStream() (committed) sees:", streamIds);
console.log(
'useNotes({status:"consumed"}) sees:',
consumed.result.current.notes.map((n) => n.id()!.toString())
);
// Each hook should only ever see notes matching its own filter.
expect(streamIds.every((id) => id.includes("committed"))).toBe(true);
});
});
```
In a real app the equivalent repro is the README snippet itself: render `useNotes()` and `useNotes({ status: 'committed' })` in one component against a client that has both committed and consumed notes, and watch both lists render identical contents that flip on each sync.
### Relevant log output
```shell
$ pnpm --filter @miden-sdk/react test src/__tests__/hooks/repro-note-cache.test.tsx
stdout | note cache is global and not keyed by filter > a second useNotes with a different status never issues its own query
filter types actually queried: [ 0 ]
B ids: [ '0xcommitted1', '0xconsumed1' ]
stdout | note cache is global and not keyed by filter > useNotes({status}) and useNoteStream() clobber each other's cache
useNoteStream() (committed) sees: [ '0xconsumed1' ]
useNotes({status:"consumed"}) sees: [ '0xconsumed1' ]
❯ src/__tests__/hooks/repro-note-cache.test.tsx (2 tests | 2 failed) 235ms
× note cache is global and not keyed by filter > a second useNotes with a different status never issues its own query
→ expected [ +0 ] to include 1
× note cache is global and not keyed by filter > useNotes({status}) and useNoteStream() clobber each other's cache
→ expected false to be true // Object.is equality
Test Files 1 failed (1)
Tests 2 failed (2)
```
Contributor guide
Research direction
The bug is in packages/react-sdk/src/store/MidenStore.ts where notes and consumableNotes are single arrays. Look at the hooks useNotes and useNoteStream to see how they fetch with filters. The fix is to change the store to key notes by filter status and consumableNotes by accountId. Start by examining the test file provided to understand the failure, then modify the store and update the hooks to read/write their own bucket. Run the test to verify the fix.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100