Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions example-apps/dashmint-lab/test/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ interface AppFixture {
export { base as rawTest };

export const test = base.extend<AppFixture>({
page: async ({ page }, use) => {
page: async ({ page }, provide) => {
await page.goto("/");
// Wait until the SDK has connected (sidebar shows "Connected") so any
// Collection query the spec triggers has a live SDK to talk to.
await expect(page.getByText("Connected").first()).toBeVisible({
timeout: 60_000,
});
await use(page);
await provide(page);
},
});

Expand Down
2 changes: 1 addition & 1 deletion example-apps/dashproof-lab/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";

export default defineConfig([
globalIgnores(["dist", "playwright-report", "test-results"]),
globalIgnores(["coverage", "dist", "playwright-report", "test-results"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
Expand Down
31 changes: 21 additions & 10 deletions example-apps/dashproof-lab/src/components/HistoryPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,27 @@ export function HistoryPanel({
const [errorState, setErrorState] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(null);
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [prevRequestToken, setPrevRequestToken] = useState<number | undefined>(
undefined,
);

// Reset state in response to a new parent-issued request (monotonic
// requestToken). React docs recommend doing this during render rather than
// in an effect — see https://react.dev/learn/you-might-not-need-an-effect.
// The sentinel-undefined initial value ensures the reset fires on first
// render too (the parent mounts this panel fresh with the token already
// bumped).
if (prevRequestToken !== requestToken) {
setPrevRequestToken(requestToken);
const trimmed = requestedChainId?.trim();
if (trimmed) {
setMode("chain");
setChainInput(trimmed);
setActiveChainId(trimmed);
setAnchors([]);
setErrorState(null);
}
}

const effectiveMode = session.status === "authenticated" ? mode : "chain";
const canQueryOwner =
Expand All @@ -79,16 +100,6 @@ export function HistoryPanel({
[],
);

useEffect(() => {
const trimmed = requestedChainId?.trim();
if (!trimmed) return;
setMode("chain");
setChainInput(trimmed);
setActiveChainId(trimmed);
setAnchors([]);
setErrorState(null);
}, [requestedChainId, requestToken]);

useEffect(() => {
if (canQueryOwner) {
const sdk = session.sdk;
Expand Down
90 changes: 90 additions & 0 deletions example-apps/dashproof-lab/test/HistoryPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,96 @@ describe("HistoryPanel", () => {
expect(chainSections).toHaveLength(1);
});

it("loads the parent-requested chainId when requestToken bumps", async () => {
// Regression for the render-time prop-reset that replaced the previous
// setState-in-useEffect. Parent dispatches a new chainId by bumping
// requestToken; the panel should switch to chain mode, populate the
// input, and load that chain.
mockUseSession.mockReturnValue({
status: "readonly",
sdk: {},
identityId: null,
log: vi.fn(),
});
mockListAnchorsByChain.mockResolvedValue([
{
id: "anchor-req",
ownerId: "owner-x",
createdAt: 1710000000000,
entryHash: Uint8Array.from([3]),
entryHashHex: "03",
chainId: "requested-chain",
filename: "requested.txt",
},
]);

const { rerender } = render(
<HistoryPanel contractId="contract-1" refreshKey={0} requestToken={0} />,
);

rerender(
<HistoryPanel
contractId="contract-1"
refreshKey={0}
requestedChainId="requested-chain"
requestToken={1}
/>,
);

await waitFor(() => {
expect(mockListAnchorsByChain).toHaveBeenCalledWith(
expect.objectContaining({ chainId: "requested-chain" }),
);
});
await screen.findByText("requested.txt");
expect(screen.getByDisplayValue("requested-chain")).toBeTruthy();
});

it("does not re-fire the parent-requested reset when requestToken is unchanged", async () => {
// The render-time guard (prevRequestToken !== requestToken) must
// fire-once-per-token; otherwise unrelated re-renders would clobber any
// edit the user made to the chain input after the initial dispatch.
mockUseSession.mockReturnValue({
status: "readonly",
sdk: {},
identityId: null,
log: vi.fn(),
});
mockListAnchorsByChain.mockResolvedValue([]);

const { rerender } = render(
<HistoryPanel
contractId="contract-1"
refreshKey={0}
requestedChainId="requested-chain"
requestToken={1}
/>,
);

await waitFor(() => {
expect(screen.getByDisplayValue("requested-chain")).toBeTruthy();
});

// User edits the chain input after the parent's dispatch.
fireEvent.change(screen.getByPlaceholderText("invoice-2026-04"), {
target: { value: "user-edited" },
});
expect(screen.getByDisplayValue("user-edited")).toBeTruthy();

// Unrelated re-render with the same requestToken — must NOT reset the
// input back to requestedChainId.
rerender(
<HistoryPanel
contractId="contract-1"
refreshKey={1}
requestedChainId="requested-chain"
requestToken={1}
/>,
);

expect(screen.getByDisplayValue("user-edited")).toBeTruthy();
});

it("loads chain history in read-only mode", async () => {
mockUseSession.mockReturnValue({
status: "readonly",
Expand Down