@assistant-ui/react-google-adk 0.0.33 → 0.0.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,166 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act } from "react";
4
+ import { afterAll, afterEach, expect, it, vi } from "vitest";
5
+ import type { AdkThreadSnapshot } from "./types";
6
+
7
+ type Family = { current: unknown };
8
+ type RendererInternals = {
9
+ setRefreshHandler: (resolve: (type: unknown) => Family | undefined) => void;
10
+ scheduleRefresh: (
11
+ root: unknown,
12
+ update: { staleFamilies: Set<Family>; updatedFamilies: Set<Family> },
13
+ ) => void;
14
+ };
15
+
16
+ const refreshHarness = vi.hoisted(() => {
17
+ const state: {
18
+ renderer: RendererInternals | undefined;
19
+ roots: Set<unknown>;
20
+ } = { renderer: undefined, roots: new Set() };
21
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
22
+ vi.stubGlobal("__REACT_DEVTOOLS_GLOBAL_HOOK__", {
23
+ supportsFiber: true,
24
+ inject: (internals: RendererInternals) => {
25
+ state.renderer = internals;
26
+ return 1;
27
+ },
28
+ onScheduleFiberRoot: () => {},
29
+ onCommitFiberRoot: (_id: number, root: unknown) => state.roots.add(root),
30
+ onCommitFiberUnmount: () => {},
31
+ });
32
+ return state;
33
+ });
34
+
35
+ const { aui } = vi.hoisted(() => ({
36
+ aui: {
37
+ threadListItem: {
38
+ source: {},
39
+ getState: () => ({ externalId: "adk-1" }),
40
+ },
41
+ },
42
+ }));
43
+
44
+ vi.mock("@assistant-ui/store", async (importOriginal) => ({
45
+ ...(await importOriginal<typeof import("@assistant-ui/store")>()),
46
+ useAui: () => aui,
47
+ }));
48
+
49
+ vi.mock("@assistant-ui/core/react", async (importOriginal) => ({
50
+ ...(await importOriginal<typeof import("@assistant-ui/core/react")>()),
51
+ useCloudThreadListAdapter: () => ({}),
52
+ useRemoteThreadListRuntime: ({
53
+ runtimeHook,
54
+ }: {
55
+ runtimeHook: () => unknown;
56
+ }) => runtimeHook(),
57
+ useExternalMessageConverter: ({ messages }: { messages: unknown }) =>
58
+ messages,
59
+ useExternalStoreRuntime: (options: unknown) => options,
60
+ }));
61
+
62
+ const { cleanup, render, waitFor } = await import("@testing-library/react");
63
+ const { useAdkRuntime } = await import("./useAdkRuntime");
64
+
65
+ afterEach(() => {
66
+ cleanup();
67
+ refreshHarness.renderer?.setRefreshHandler(() => undefined);
68
+ refreshHarness.roots.clear();
69
+ });
70
+ afterAll(() => vi.unstubAllGlobals());
71
+
72
+ const deferred = <T,>() => {
73
+ let resolve!: (value: T) => void;
74
+ const promise = new Promise<T>((res) => {
75
+ resolve = res;
76
+ });
77
+ return { promise, resolve };
78
+ };
79
+
80
+ const refresh = async (Before: unknown, After: unknown) => {
81
+ const family: Family = { current: After };
82
+ refreshHarness.renderer!.setRefreshHandler((type) =>
83
+ type === Before || type === After ? family : undefined,
84
+ );
85
+ await act(async () => {
86
+ for (const root of refreshHarness.roots) {
87
+ refreshHarness.renderer!.scheduleRefresh(root, {
88
+ staleFamilies: new Set(),
89
+ updatedFamilies: new Set([family]),
90
+ });
91
+ }
92
+ });
93
+ await act(async () => {});
94
+ };
95
+
96
+ it("keeps an initial thread load through Fast Refresh and aborts a later load on unmount", async () => {
97
+ const initial = deferred<AdkThreadSnapshot>();
98
+ const refetch = deferred<AdkThreadSnapshot>();
99
+ const signals: AbortSignal[] = [];
100
+ const load = vi.fn(
101
+ (_id: string, options?: { signal?: AbortSignal | undefined }) => {
102
+ signals.push(options!.signal!);
103
+ return signals.length === 1 ? initial.promise : refetch.promise;
104
+ },
105
+ );
106
+ let runtime:
107
+ | {
108
+ messages: unknown;
109
+ onRefetchThread: () => Promise<void>;
110
+ }
111
+ | undefined;
112
+ let rendered: string | undefined;
113
+ const host = (name: string) => () => {
114
+ rendered = name;
115
+ runtime = useAdkRuntime({
116
+ stream: async function* () {},
117
+ load,
118
+ }) as unknown as typeof runtime;
119
+ return null;
120
+ };
121
+ const Before = host("before");
122
+ const After = host("after");
123
+ const view = render(<Before />);
124
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
125
+ expect(signals[0]!.aborted).toBe(false);
126
+
127
+ await refresh(Before, After);
128
+ expect(rendered).toBe("after");
129
+ expect(signals[0]!.aborted).toBe(false);
130
+ expect(load).toHaveBeenCalledTimes(1);
131
+
132
+ let refetchSettled = false;
133
+ act(() => {
134
+ void runtime!.onRefetchThread().then(() => {
135
+ refetchSettled = true;
136
+ });
137
+ });
138
+ expect(load).toHaveBeenCalledTimes(1);
139
+ expect(refetchSettled).toBe(false);
140
+
141
+ await act(async () => {
142
+ initial.resolve({
143
+ messages: [
144
+ {
145
+ id: "history-1",
146
+ type: "ai",
147
+ content: [{ type: "text", text: "history landed" }],
148
+ },
149
+ ],
150
+ });
151
+ });
152
+ await waitFor(() => expect(refetchSettled).toBe(true));
153
+ await waitFor(() =>
154
+ expect(JSON.stringify(runtime!.messages)).toContain("history landed"),
155
+ );
156
+
157
+ act(() => {
158
+ void runtime!.onRefetchThread();
159
+ });
160
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(2));
161
+ expect(signals[1]!.aborted).toBe(false);
162
+ view.unmount();
163
+ await act(async () => {});
164
+ expect(signals[1]!.aborted).toBe(true);
165
+ refetch.resolve({ messages: [] });
166
+ });
@@ -330,6 +330,7 @@ describe("useAdkRuntime refetch", () => {
330
330
  expect(signals[1]?.aborted).toBe(false);
331
331
 
332
332
  unmount();
333
+ await act(async () => {});
333
334
 
334
335
  expect(signals[1]?.aborted).toBe(true);
335
336
  pending.resolve({ messages: [] });
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  useCallback,
3
- useEffect,
4
3
  useInsertionEffect,
5
4
  useMemo,
6
5
  useRef,
@@ -30,6 +29,7 @@ import {
30
29
  useExternalStoreRuntime,
31
30
  } from "@assistant-ui/core/react";
32
31
  import { useAui } from "@assistant-ui/store";
32
+ import { useReplaySafeEffect } from "@assistant-ui/store/internal";
33
33
  import type { AssistantCloud } from "assistant-cloud";
34
34
  import type { RemoteThreadListAdapter } from "@assistant-ui/core";
35
35
  import type {
@@ -147,7 +147,7 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
147
147
  useInsertionEffect(() => {
148
148
  loadRef.current = load;
149
149
  }, [load]);
150
- const loadController = useMemo(createAbortableThreadLoad, []);
150
+ const [loadController] = useState(createAbortableThreadLoad);
151
151
  const messagesRef = useRef(messages);
152
152
  useInsertionEffect(() => {
153
153
  messagesRef.current = messages;
@@ -325,7 +325,7 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
325
325
  [threadListItem, loadController, applySnapshot],
326
326
  );
327
327
 
328
- useEffect(() => {
328
+ useReplaySafeEffect(() => {
329
329
  runLoad();
330
330
  return () => {
331
331
  // Whatever is current, not this effect's own controller: a refetch swaps
@@ -333,7 +333,7 @@ const useAdkRuntimeImpl = (options: UseAdkRuntimeOptions) => {
333
333
  loadController.abort();
334
334
  setIsLoadingThread(false);
335
335
  };
336
- }, [loadController, runLoad]);
336
+ }, [threadListItem]);
337
337
 
338
338
  const runtime = useExternalStoreRuntime({
339
339
  ...pickExternalStoreSharedOptions(options),