Skip to content

Commit 24db944

Browse files
committed
feat(react-native): add useRenderTool hook and RenderToolContext
Hook for rendering custom tool UIs in React Native with a store-based context provider. Includes tests for register/unregister, subscriber notification, and error handling.
1 parent f99f65f commit 24db944

5 files changed

Lines changed: 853 additions & 0 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import React, {
2+
createContext,
3+
useCallback,
4+
useContext,
5+
useMemo,
6+
useRef,
7+
useSyncExternalStore,
8+
} from "react";
9+
10+
/**
11+
* Props passed to a render tool function.
12+
*/
13+
export interface RenderToolProps<T = Record<string, unknown>> {
14+
args: T;
15+
status: "executing" | "complete";
16+
result?: string;
17+
}
18+
19+
/**
20+
* A render function that returns a React Native element for a tool call.
21+
* Returns `ReactElement | null` (not ReactNode) because React Native's
22+
* FlatList cannot render strings or portals.
23+
*/
24+
export type RenderToolFunction<T = Record<string, unknown>> = (
25+
props: RenderToolProps<T>,
26+
) => React.ReactElement | null;
27+
28+
/**
29+
* The registry maps tool names to their render functions.
30+
*/
31+
export type RenderToolRegistry = Map<string, RenderToolFunction>;
32+
33+
/**
34+
* Internal store that notifies subscribers when the registry changes.
35+
* This avoids unnecessary re-renders of the entire tree when a single
36+
* tool's render function is registered or removed.
37+
*/
38+
interface RegistryStore {
39+
registry: RenderToolRegistry;
40+
version: number;
41+
listeners: Set<() => void>;
42+
}
43+
44+
function createRegistryStore(): RegistryStore {
45+
return {
46+
registry: new Map(),
47+
version: 0,
48+
listeners: new Set(),
49+
};
50+
}
51+
52+
function emitChange(store: RegistryStore) {
53+
store.version++;
54+
// Create a new Map so useSyncExternalStore detects the reference change
55+
store.registry = new Map(store.registry);
56+
for (const listener of store.listeners) {
57+
listener();
58+
}
59+
}
60+
61+
interface RenderToolContextValue {
62+
store: RegistryStore;
63+
register: (name: string, render: RenderToolFunction) => () => void;
64+
}
65+
66+
const RenderToolCtx = createContext<RenderToolContextValue | null>(null);
67+
68+
/**
69+
* Provider that maintains the render tool registry.
70+
* Should be nested inside CopilotKitProvider.
71+
*/
72+
export function RenderToolProvider({
73+
children,
74+
}: {
75+
children: React.ReactNode;
76+
}) {
77+
const storeRef = useRef<RegistryStore>(createRegistryStore());
78+
79+
const register = useCallback(
80+
(name: string, render: RenderToolFunction): (() => void) => {
81+
const store = storeRef.current;
82+
store.registry.set(name, render);
83+
emitChange(store);
84+
85+
// Return unregister function
86+
return () => {
87+
// Only delete if the current render function is the one we registered
88+
if (store.registry.get(name) === render) {
89+
store.registry.delete(name);
90+
emitChange(store);
91+
}
92+
};
93+
},
94+
[],
95+
);
96+
97+
const value = useMemo<RenderToolContextValue>(
98+
() => ({ store: storeRef.current, register }),
99+
[register],
100+
);
101+
102+
return (
103+
<RenderToolCtx.Provider value={value}>{children}</RenderToolCtx.Provider>
104+
);
105+
}
106+
107+
/**
108+
* Returns the current render tool registry (a Map of tool name to render function).
109+
* Re-renders when the registry changes.
110+
*
111+
* @throws if called outside of RenderToolProvider
112+
*/
113+
export function useRenderToolRegistry(): RenderToolRegistry {
114+
const ctx = useContext(RenderToolCtx);
115+
if (!ctx) {
116+
throw new Error(
117+
"useRenderToolRegistry must be used within a RenderToolProvider",
118+
);
119+
}
120+
121+
const { store } = ctx;
122+
123+
// Subscribe to registry changes via useSyncExternalStore for tear-safe reads
124+
return useSyncExternalStore(
125+
(onStoreChange) => {
126+
store.listeners.add(onStoreChange);
127+
return () => {
128+
store.listeners.delete(onStoreChange);
129+
};
130+
},
131+
() => store.registry,
132+
() => store.registry,
133+
);
134+
}
135+
136+
/**
137+
* Internal hook used by useRenderTool to register a render function.
138+
* Not exported from the package — consumers use useRenderTool instead.
139+
*
140+
* @throws if called outside of RenderToolProvider
141+
*/
142+
export function useRenderToolContext() {
143+
const ctx = useContext(RenderToolCtx);
144+
if (!ctx) {
145+
throw new Error(
146+
"useRenderTool must be used within a RenderToolProvider (inside CopilotKitProvider)",
147+
);
148+
}
149+
return ctx;
150+
}

0 commit comments

Comments
 (0)