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
3 changes: 2 additions & 1 deletion src/index.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { createMemactClient } from "./client.mjs"
export { MemactSDKError } from "./errors.mjs"
export { validateClientConfig } from "./config-validation.mjs"
export { validateClientConfig } from "./config-validation.mjs"
export { MemactProvider, useContextClaim } from "./react.mjs"
113 changes: 113 additions & 0 deletions src/react.mjs
Original file line number Diff line number Diff line change
@@ -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 };
}
74 changes: 74 additions & 0 deletions test/react.test.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
});