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
86 changes: 86 additions & 0 deletions view/src/StoredStateStore.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { vi } from "vitest";
import React from "react";
import { render, act } from "@testing-library/react";

// Declare mockPatch in outer scope so the mocked module can access the latest reference
let mockPatch: any;

vi.mock("./common/patchConnection", () => ({
getPatchConnection: () => mockPatch,
}));

import { StoredStateStoreProvider, useStoredStateStore } from "./StoredStateStore";

// Helper component to drive updates
const TestComponent: React.FC<{ onRender?: (api: any) => void }> = ({ onRender }) => {
const store = useStoredStateStore();
onRender?.(store);
return null;
};

describe("StoredStateStore patch integration", () => {
let listeners: Record<string, Function[]>;

beforeEach(() => {
listeners = {};
mockPatch = {
sendStoredStateValue: vi.fn(),
requestStoredStateValue: vi.fn(),
requestFullStoredState: vi.fn((cb) => {
// simulate async callback with empty state
setTimeout(() => cb({ values: {} }), 0);
}),
addStoredStateValueListener: vi.fn((fn) => {
(listeners["state_key_value"] ||= []).push(fn);
}),
removeStoredStateValueListener: vi.fn((fn) => {
listeners["state_key_value"] = (listeners["state_key_value"] || []).filter((l) => l !== fn);
}),
// utility to emit
emit(key: string, value: any) {
(listeners["state_key_value"] || []).forEach((l) => l({ key, value }));
},
};

// mock already registered above – just ensure module under test sees fresh mockPatch
});

afterEach(() => {
vi.resetModules();
// Re-register mock after module reset (Vitest clears mocks); ensure StoredStateStore keeps working if re-imported.
vi.doMock("./common/patchConnection", () => ({
getPatchConnection: () => mockPatch,
}));
});

it("sends delta when updating local state", async () => {
let api: any;
render(
<StoredStateStoreProvider>
<TestComponent onRender={(a) => (api = a)} />
</StoredStateStoreProvider>
);

await act(async () => {
api.updateStoredStateItem("selectedInstrument")("snare1");
});

// should have sent stored state value for selectedInstrument
expect(mockPatch.sendStoredStateValue).toHaveBeenCalledWith("selectedInstrument", "snare1");
});

it("updates local state when patch emits change", async () => {
let api: any;
render(
<StoredStateStoreProvider>
<TestComponent onRender={(a) => (api = a)} />
</StoredStateStoreProvider>
);

await act(async () => {
mockPatch.emit("selectedInstrument", "snare2");
});

expect(api.storedState.selectedInstrument).toBe("snare2");
});
});
134 changes: 131 additions & 3 deletions view/src/StoredStateStore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
* @todo: actually link to patchConnection
*/

import React, { createContext, useState, useContext } from "react";
import React, { createContext, useState, useContext, useEffect, useRef } from "react";
import { InstrumentKey, instrumentKeys } from "./params";

import { getPatchConnection } from "./common/patchConnection";
export interface StoredState {
selectedInstrument: InstrumentKey;
}
Expand Down Expand Up @@ -37,9 +37,61 @@ export const StoredStateStoreProvider: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
const [state, setState] = useState<StoredState>(initialState);
// Keep a ref of last state so we can compare inside effects without stale closures
const stateRef = useRef(state);
useEffect(() => {
stateRef.current = state;
}, [state]);

// Push a delta to the patch connection for each updated key
const sendStoredStateDelta = (delta: Partial<StoredState>) => {
const patchConnection = getPatchConnection();
if (!patchConnection) return;
for (const k in delta) {
const key = k as keyof StoredState;
const value = delta[key];
// Validate value before sending
if (key === 'selectedInstrument' && value && !instrumentKeys.includes(value as InstrumentKey)) {
console.warn(`Invalid selectedInstrument value: ${value}, skipping`);
continue;
}
// Future-proof: allow null/undefined to clear
patchConnection.sendStoredStateValue?.(key, value as any);
}
};

const setStoredState = (value: Partial<StoredState>) => {
setState((prevState) => ({ ...prevState, ...value }));
if (!value || Object.keys(value).length === 0) return;

// Validate incoming values
const validatedValue: Partial<StoredState> = {};
for (const k in value) {
const key = k as keyof StoredState;
const val = value[key];
if (key === 'selectedInstrument') {
if (val && instrumentKeys.includes(val as InstrumentKey)) {
(validatedValue as any)[key] = val;
} else {
console.warn(`Invalid selectedInstrument: ${val}, keeping current value`);
}
} else {
(validatedValue as any)[key] = val;
}
}

setState((prevState) => {
const next = { ...prevState, ...validatedValue };
// Send only changed keys
const changed: Partial<StoredState> = {};
for (const k in validatedValue) {
const key = k as keyof StoredState;
if (prevState[key] !== next[key]) (changed as any)[key] = next[key];
}
if (Object.keys(changed).length) {
sendStoredStateDelta(changed);
}
return next;
});
};

const updateStoredStateItem: StoredStateUpdater = (key) => (value) =>
Expand All @@ -66,6 +118,82 @@ export const StoredStateStoreProvider: React.FC<{
});
};

// Initial sync + listener registration
useEffect(() => {
let patchConnection: any;
try {
patchConnection = getPatchConnection();
} catch {
return;
}
if (!patchConnection) return;

// Listener for individual key updates from patch
const storedStateListener = ({ key, value }: { key: string; value: any }) => {
if (!(key in stateRef.current)) return; // ignore keys we don't know yet
const typedKey = key as keyof StoredState;

// Validate incoming value
if (typedKey === 'selectedInstrument') {
if (!value || !instrumentKeys.includes(value as InstrumentKey)) {
return;
}
}

if (stateRef.current[typedKey] !== value) {
setState((prev) => ({ ...prev, [typedKey]: value }));
}
};

try {
patchConnection.addStoredStateValueListener?.(storedStateListener);
} catch { }

// Request full state so we can merge existing values (if any) stored by host
try {
patchConnection.requestFullStoredState?.((full: any) => {
try {
const values = full?.values || {};
const incoming: Partial<StoredState> = {};
for (const k in values) {
if (k in stateRef.current) {
const key = k as keyof StoredState;
const value = values[k];

// Validate values from patch
if (key === 'selectedInstrument') {
if (value && instrumentKeys.includes(value as InstrumentKey)) {
(incoming as any)[key] = value;
}
} else {
(incoming as any)[key] = value;
}
}
}
if (Object.keys(incoming).length) {
setState((prev) => ({ ...prev, ...incoming }));
} else {
// If host has nothing, push our initial state so it becomes persisted
sendStoredStateDelta(stateRef.current);
}
} catch { }
});
} catch { }

// Also request each individual key to trigger callbacks (mirrors ParamStore pattern)
try {
for (const key in stateRef.current) {
patchConnection.requestStoredStateValue?.(key);
}
} catch { }

return () => {
try {
patchConnection.removeStoredStateValueListener?.(storedStateListener);
} catch { }
};
}, []);

return (
<StoredStateStoreContext.Provider
value={{
Expand Down