diff --git a/src/index.mjs b/src/index.mjs index 82b94bb..e991aaf 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -1,3 +1,4 @@ export { createMemactClient } from "./client.mjs" export { MemactSDKError } from "./errors.mjs" -export { validateClientConfig } from "./config-validation.mjs" \ No newline at end of file +export { validateClientConfig } from "./config-validation.mjs" +export { MemactProvider, useContextClaim } from "./react.mjs" \ No newline at end of file diff --git a/src/react.mjs b/src/react.mjs new file mode 100644 index 0000000..5c9edc1 --- /dev/null +++ b/src/react.mjs @@ -0,0 +1,113 @@ +/** + * Safe reference helper to pull the running React instance. + * Checks typeof window first to guarantee absolute safety in Node environment tests. + */ +const getReactInstance = () => { + if (typeof window !== "undefined" && window.React) return window.React; + if (typeof globalThis !== "undefined" && globalThis.React) return globalThis.React; + return null; +}; + +// 1. FIXED: Creating the shared stable context instance OUTSIDE of the rendering cycle +let SharedMemactContext = null; + +const getContextInstance = (R) => { + if (!SharedMemactContext && R && typeof R.createContext === "function") { + SharedMemactContext = R.createContext(null); + } + return SharedMemactContext; +}; + +export function MemactProvider({ client, children }) { + if (!client) { + console.warn("[Memact SDK] MemactProvider was mounted without a valid client instance."); + } + + const R = getReactInstance(); + const Context = getContextInstance(R); + + // If running in an active React environment, cleanly create the element provider + if (R && Context) { + return R.createElement(Context.Provider, { value: client }, children); + } + + return children; +} + +/** + * Custom React hook subscribing to live context state updates and claims. + * @param {string} category - The domain category filtering scope (e.g., 'developer_work', 'music') + * @param {Object} options - Subscriptions modifiers and query configuration hooks + */ +export function useContextClaim(category, options = {}) { + const R = getReactInstance(); + const Context = getContextInstance(R); + + if (!R || !Context) { + return { + claim: null, + loading: false, + error: new Error("React instance not found. Ensure this hook is executed inside a running React application environment.") + }; + } + + // 3. FIXED: Extract client via React.useContext from our stable, outer context instance + const client = R.useContext(Context); + + const [claim, setClaim] = R.useState(null); + const [loading, setLoading] = R.useState(true); + const [error, setError] = R.useState(null); + + R.useEffect(() => { + if (!client) { + setError(new Error("useContextClaim must be used within a MemactProvider element tree.")); + setLoading(false); + return; + } + + if (!category) { + setError(new Error("category parameter is required for state management filtering.")); + setLoading(false); + return; + } + + let isMounted = true; + setLoading(true); + + const fetchCurrentClaim = async () => { + try { + const connectionId = options.connection_id || client.connectionId; + const result = await client.getAllowedMemory({ + connection_id: connectionId, + activity_categories: [category], + ...options + }); + + if (isMounted) { + setClaim(result || null); + setError(null); + } + } catch (err) { + if (isMounted) { + setError(err); + } + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + fetchCurrentClaim(); + + const liveIntervalMs = options.pollInterval || 4000; + const trackerClockId = setInterval(fetchCurrentClaim, liveIntervalMs); + + return () => { + isMounted = false; + clearInterval(trackerClockId); + }; + }, [client, category, JSON.stringify(options)]); + + return { claim, loading, error }; +} \ No newline at end of file diff --git a/test/react.test.mjs b/test/react.test.mjs new file mode 100644 index 0000000..daf8ac4 --- /dev/null +++ b/test/react.test.mjs @@ -0,0 +1,74 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { MemactProvider, useContextClaim } from "../src/react.mjs" + +test("useContextClaim returns error if executed outside of running React engine environment context", () => { + const originalGlobalReact = globalThis.React; + delete globalThis.React; + + try { + const result = useContextClaim("developer_work", {}); + assert.ok(result.error); + assert.match(result.error.message, /React instance not found/); + } finally { + globalThis.React = originalGlobalReact; + } +}); + +test("useContextClaim executes queries correctly under mock structural context injection passes", async () => { + let queriesPassed = []; + let trackingClocksCleared = false; + + const mockClient = { + connectionId: "conn_react_isolated_suite", + async getAllowedMemory(payload) { + queriesPassed.push(payload); + return { ok: true, claims: ["react-hook-isolated-assertion-token"] }; + } + }; + + let hookEffectClosure = null; + const mockReactEngine = { + createContext() { + return { Provider: function({ children }) { return children; } }; + }, + // FIXED: Added mock createElement tracking to return children context safely + createElement(type, props, children) { + return children; + }, + useContext() { + return mockClient; + }, + useState(initial) { + let val = initial; + const setter = (newVal) => { val = newVal; }; + return [val, setter]; + }, + useEffect(effectClosure) { + hookEffectClosure = effectClosure; + } + }; + + globalThis.React = mockReactEngine; + + try { + // Mount provider then pull hook state tracking metrics + MemactProvider({ client: mockClient, children: null }); + const result = useContextClaim("music", {}); + + if (typeof hookEffectClosure === "function") { + const cleanupFn = await hookEffectClosure(); + if (typeof cleanupFn === "function") { + cleanupFn(); + trackingClocksCleared = true; + } + } + + assert.equal(queriesPassed.length, 1); + assert.equal(queriesPassed[0].connection_id, "conn_react_isolated_suite"); + assert.deepEqual(queriesPassed[0].activity_categories, ["music"]); + assert.ok(trackingClocksCleared); + } finally { + delete globalThis.React; + } +}); \ No newline at end of file