@assistant-ui/react 0.15.17 → 0.15.19

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.
Files changed (169) hide show
  1. package/README.md +8 -2
  2. package/dist/context/providers/ThreadViewportProvider.js +2 -2
  3. package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
  4. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  5. package/dist/index.d.ts +7 -4
  6. package/dist/index.js +5 -3
  7. package/dist/mcp-apps/McpAppRenderer.d.ts +30 -8
  8. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  9. package/dist/mcp-apps/McpAppRenderer.js +91 -47
  10. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  11. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  12. package/dist/mcp-apps/app-frame.js +57 -33
  13. package/dist/mcp-apps/app-frame.js.map +1 -1
  14. package/dist/mcp-apps/bridge.js +9 -4
  15. package/dist/mcp-apps/bridge.js.map +1 -1
  16. package/dist/mcp-apps/index.d.ts +2 -2
  17. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  18. package/dist/primitives/assistantModal/AssistantModalRoot.js +47 -79
  19. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  20. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  21. package/dist/primitives/composer/ComposerAttachmentDropzone.js +61 -138
  22. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  23. package/dist/primitives/composer/ComposerInput.js +3 -8
  24. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  25. package/dist/primitives/composer/ComposerInputPluginContext.d.ts +1 -2
  26. package/dist/primitives/composer/ComposerInputPluginContext.d.ts.map +1 -1
  27. package/dist/primitives/composer/ComposerInputPluginContext.js +1 -6
  28. package/dist/primitives/composer/ComposerInputPluginContext.js.map +1 -1
  29. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +46 -15
  30. package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
  31. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +40 -10
  32. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
  33. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts +6 -0
  34. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts.map +1 -0
  35. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js +9 -0
  36. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js.map +1 -0
  37. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
  38. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +53 -61
  39. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  40. package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
  41. package/dist/primitives/composer/trigger/triggerNavigationResource.js +129 -141
  42. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  43. package/dist/primitives/message/MessageParts.js +2 -14
  44. package/dist/primitives/message/MessageParts.js.map +1 -1
  45. package/dist/primitives/message/MessagePartsGrouped.d.ts +3 -3
  46. package/dist/primitives/message/MessagePartsGrouped.js +2 -2
  47. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  48. package/dist/primitives/messagePart/MessagePartText.d.ts +2 -0
  49. package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
  50. package/dist/primitives/messagePart/MessagePartText.js +13 -36
  51. package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
  52. package/dist/primitives/reasoning/useScrollLock.js +3 -1
  53. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  54. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  55. package/dist/primitives/thread/useThreadViewportAutoScroll.js +3 -3
  56. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  57. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  58. package/dist/sandbox-host/SandboxHost.js +36 -9
  59. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  60. package/dist/unstable/useLiveCompletionAdapter.js +24 -2
  61. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  62. package/dist/unstable/useMentionAdapter.d.ts +9 -3
  63. package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
  64. package/dist/unstable/useMentionAdapter.js +50 -43
  65. package/dist/unstable/useMentionAdapter.js.map +1 -1
  66. package/dist/unstable/useMessageStallDetection.d.ts +4 -5
  67. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  68. package/dist/unstable/useMessageStallDetection.js +16 -15
  69. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  70. package/dist/unstable/useModelContextSnapshot.d.ts +34 -0
  71. package/dist/unstable/useModelContextSnapshot.d.ts.map +1 -0
  72. package/dist/unstable/useModelContextSnapshot.js +61 -0
  73. package/dist/unstable/useModelContextSnapshot.js.map +1 -0
  74. package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
  75. package/dist/unstable/useSlashCommandAdapter.js +2 -8
  76. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  77. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts +14 -0
  78. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts.map +1 -0
  79. package/dist/unstable/webmcp/WebMcpRegistrationResource.js +85 -0
  80. package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -0
  81. package/dist/unstable/webmcp/convertTools.d.ts +21 -0
  82. package/dist/unstable/webmcp/convertTools.d.ts.map +1 -0
  83. package/dist/unstable/webmcp/convertTools.js +161 -0
  84. package/dist/unstable/webmcp/convertTools.js.map +1 -0
  85. package/dist/unstable/webmcp/useWebMcpProvider.d.ts +21 -0
  86. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -0
  87. package/dist/unstable/webmcp/useWebMcpProvider.js +106 -0
  88. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -0
  89. package/dist/unstable/webmcp/webmcp-host.d.ts +39 -0
  90. package/dist/unstable/webmcp/webmcp-host.d.ts.map +1 -0
  91. package/dist/unstable/webmcp/webmcp-host.js +29 -0
  92. package/dist/unstable/webmcp/webmcp-host.js.map +1 -0
  93. package/dist/utils/Primitive.d.ts +10 -2
  94. package/dist/utils/Primitive.d.ts.map +1 -1
  95. package/dist/utils/Primitive.js +25 -6
  96. package/dist/utils/Primitive.js.map +1 -1
  97. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  98. package/dist/utils/getSelectionMessageId.js +17 -3
  99. package/dist/utils/getSelectionMessageId.js.map +1 -1
  100. package/package.json +13 -13
  101. package/src/context/providers/ThreadViewportProvider.tsx +2 -2
  102. package/src/hooks/useToolCallElapsed.ts +3 -0
  103. package/src/index.ts +14 -1
  104. package/src/mcp-apps/McpAppRenderer.test.tsx +253 -5
  105. package/src/mcp-apps/McpAppRenderer.tsx +147 -52
  106. package/src/mcp-apps/app-frame.test.tsx +437 -1
  107. package/src/mcp-apps/app-frame.tsx +76 -21
  108. package/src/mcp-apps/bridge.test.ts +98 -0
  109. package/src/mcp-apps/bridge.ts +14 -3
  110. package/src/mcp-apps/index.ts +5 -1
  111. package/src/primitives/assistantModal/AssistantModalRoot.test.tsx +123 -0
  112. package/src/primitives/assistantModal/AssistantModalRoot.tsx +11 -31
  113. package/src/primitives/assistantModal/AssistantModalTrigger.test.tsx +1 -16
  114. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +43 -0
  115. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +2 -10
  116. package/src/primitives/composer/ComposerInput.test.tsx +22 -0
  117. package/src/primitives/composer/ComposerInput.tsx +5 -9
  118. package/src/primitives/composer/ComposerInputPluginContext.tsx +0 -10
  119. package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +4 -2
  120. package/src/primitives/composer/trigger/TriggerPopoverBehavior.test.tsx +109 -0
  121. package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +4 -2
  122. package/src/primitives/composer/trigger/matchesTriggerItemQuery.test.ts +52 -0
  123. package/src/primitives/composer/trigger/matchesTriggerItemQuery.ts +13 -0
  124. package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
  125. package/src/primitives/composer/trigger/triggerNavigationResource.ts +8 -13
  126. package/src/primitives/message/MessageParts.tsx +1 -21
  127. package/src/primitives/message/MessagePartsGrouped.test.tsx +93 -0
  128. package/src/primitives/message/MessagePartsGrouped.tsx +6 -6
  129. package/src/primitives/messagePart/MessagePartText.test.tsx +69 -0
  130. package/src/primitives/messagePart/MessagePartText.tsx +22 -9
  131. package/src/primitives/reasoning/useScrollLock.test.tsx +129 -0
  132. package/src/primitives/reasoning/useScrollLock.ts +15 -1
  133. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +38 -1
  134. package/src/primitives/thread/useThreadViewportAutoScroll.ts +7 -6
  135. package/src/sandbox-host/SandboxHost.test.tsx +160 -2
  136. package/src/sandbox-host/SandboxHost.tsx +50 -10
  137. package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
  138. package/src/tests/toolCallTiming.test.tsx +55 -1
  139. package/src/unstable/useComposerInputHistory.test.tsx +2 -1
  140. package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
  141. package/src/unstable/useLiveCompletionAdapter.ts +38 -8
  142. package/src/unstable/useMentionAdapter.test.tsx +383 -0
  143. package/src/unstable/useMentionAdapter.ts +94 -57
  144. package/src/unstable/useMessageStallDetection.ts +26 -19
  145. package/src/unstable/useModelContextSnapshot.test.tsx +132 -0
  146. package/src/unstable/useModelContextSnapshot.ts +67 -0
  147. package/src/unstable/useSlashCommandAdapter.ts +2 -9
  148. package/src/unstable/webmcp/WebMcpRegistrationResource.ts +92 -0
  149. package/src/unstable/webmcp/__tests__/webmcp.fake.tsx +168 -0
  150. package/src/unstable/webmcp/convertTools.test.ts +553 -0
  151. package/src/unstable/webmcp/convertTools.ts +260 -0
  152. package/src/unstable/webmcp/useWebMcpProvider.test.tsx +466 -0
  153. package/src/unstable/webmcp/useWebMcpProvider.ts +148 -0
  154. package/src/unstable/webmcp/webmcp-host.test.ts +142 -0
  155. package/src/unstable/webmcp/webmcp-host.ts +83 -0
  156. package/src/utils/Primitive.test.tsx +46 -1
  157. package/src/utils/Primitive.tsx +37 -7
  158. package/src/utils/getSelectionMessageId.test.ts +176 -1
  159. package/src/utils/getSelectionMessageId.ts +21 -3
  160. package/dist/utils/invokeCallbackSafely.d.ts +0 -5
  161. package/dist/utils/invokeCallbackSafely.d.ts.map +0 -1
  162. package/dist/utils/invokeCallbackSafely.js +0 -15
  163. package/dist/utils/invokeCallbackSafely.js.map +0 -1
  164. package/dist/utils/useToolArgsFieldStatus.d.ts +0 -19
  165. package/dist/utils/useToolArgsFieldStatus.d.ts.map +0 -1
  166. package/dist/utils/useToolArgsFieldStatus.js +0 -23
  167. package/dist/utils/useToolArgsFieldStatus.js.map +0 -1
  168. package/src/utils/invokeCallbackSafely.ts +0 -11
  169. package/src/utils/useToolArgsFieldStatus.ts +0 -18
@@ -7,8 +7,10 @@ import { AssistantRuntimeProvider } from "../context";
7
7
  import * as MessagePrimitive from "../primitives/message";
8
8
  import * as ThreadPrimitive from "../primitives/thread";
9
9
  import { useLocalRuntime } from "../legacy-runtime/runtime-cores/local/useLocalRuntime";
10
- import type { ChatModelAdapter, ThreadMessageLike } from "../index";
11
10
  import {
11
+ type ChatModelAdapter,
12
+ type ThreadMessageLike,
13
+ useExternalStoreRuntime,
12
14
  useToolCallElapsed,
13
15
  unstable_useMessageStallDetection,
14
16
  } from "../index";
@@ -60,6 +62,22 @@ const RuntimeProvider: FC<
60
62
  );
61
63
  };
62
64
 
65
+ const ExternalRuntimeProvider: FC<
66
+ PropsWithChildren<{ messages: ThreadMessageLike[] }>
67
+ > = ({ messages, children }) => {
68
+ const runtime = useExternalStoreRuntime({
69
+ messages,
70
+ isRunning: true,
71
+ convertMessage: (message) => message,
72
+ onNew: async () => {},
73
+ });
74
+ return (
75
+ <AssistantRuntimeProvider runtime={runtime}>
76
+ {children}
77
+ </AssistantRuntimeProvider>
78
+ );
79
+ };
80
+
63
81
  const renderHarness = async (messages: ThreadMessageLike[], probe: FC) => {
64
82
  render(<Harness messages={messages} probe={probe} />);
65
83
  // Flush the runtime's deferred initialization, which fake timers hold back.
@@ -83,6 +101,19 @@ const Harness: FC<{ messages: ThreadMessageLike[]; probe: FC }> = ({
83
101
  </RuntimeProvider>
84
102
  );
85
103
 
104
+ const ExternalHarness: FC<{
105
+ messages: ThreadMessageLike[];
106
+ probe: FC;
107
+ }> = ({ messages, probe: Probe }) => (
108
+ <ExternalRuntimeProvider messages={messages}>
109
+ <ThreadPrimitive.Messages
110
+ components={{
111
+ Message: () => <MessagePrimitive.Parts components={{ Text: Probe }} />,
112
+ }}
113
+ />
114
+ </ExternalRuntimeProvider>
115
+ );
116
+
86
117
  describe("useToolCallElapsed", () => {
87
118
  beforeEach(() => {
88
119
  vi.useFakeTimers();
@@ -218,4 +249,27 @@ describe("unstable_useMessageStallDetection", () => {
218
249
 
219
250
  expect(screen.getByTestId("stalled").textContent).toBe("false");
220
251
  });
252
+
253
+ it("resets the timer for equal-length content changes", async () => {
254
+ const message = (text: string): ThreadMessageLike => ({
255
+ role: "assistant",
256
+ content: [{ type: "text", text }],
257
+ status: { type: "running" },
258
+ });
259
+ const view = render(
260
+ <ExternalHarness messages={[message("first")]} probe={StallProbe} />,
261
+ );
262
+ await act(async () => vi.advanceTimersByTimeAsync(0));
263
+
264
+ await act(async () => vi.advanceTimersByTimeAsync(1500));
265
+ view.rerender(
266
+ <ExternalHarness messages={[message("other")]} probe={StallProbe} />,
267
+ );
268
+ await act(async () => vi.advanceTimersByTimeAsync(1000));
269
+
270
+ expect(screen.getByTestId("stalled").textContent).toBe("false");
271
+
272
+ await act(async () => vi.advanceTimersByTimeAsync(1000));
273
+ expect(screen.getByTestId("stalled").textContent).toBe("true");
274
+ });
221
275
  });
@@ -23,7 +23,8 @@ vi.mock("@assistant-ui/store", () => ({
23
23
  },
24
24
  }),
25
25
  }));
26
- vi.mock("@assistant-ui/tap", () => ({
26
+ vi.mock("@assistant-ui/tap", async (importOriginal) => ({
27
+ ...(await importOriginal<typeof import("@assistant-ui/tap")>()),
27
28
  flushTapSync: (fn: () => void) => fn(),
28
29
  }));
29
30
  vi.mock("../primitives/composer/trigger/TriggerPopoverRootContext", () => ({
@@ -1,5 +1,5 @@
1
1
  /** @vitest-environment jsdom */
2
- import { startTransition, Suspense } from "react";
2
+ import { startTransition, Suspense, useLayoutEffect } from "react";
3
3
  import { act, render, renderHook } from "@testing-library/react";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import type { Unstable_TriggerItem } from "@assistant-ui/core";
@@ -62,6 +62,159 @@ describe("unstable_useLiveCompletionAdapter", () => {
62
62
  expect(fetcher).not.toHaveBeenCalled();
63
63
  });
64
64
 
65
+ it("does not start a queued fetch after unmount", async () => {
66
+ const fetcher = vi.fn(async () => []);
67
+ const { result, unmount } = renderHook(() =>
68
+ unstable_useLiveCompletionAdapter({ fetcher, debounceMs: 0 }),
69
+ );
70
+
71
+ result.current.adapter.search!("alice");
72
+ unmount();
73
+ await Promise.resolve();
74
+
75
+ expect(vi.getTimerCount()).toBe(0);
76
+ await vi.runAllTimersAsync();
77
+
78
+ expect(fetcher).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it("keeps an in-flight fetch when the debounce changes", async () => {
82
+ let resolve!: (items: readonly Unstable_TriggerItem[]) => void;
83
+ const fetcher = vi.fn(
84
+ () =>
85
+ new Promise<readonly Unstable_TriggerItem[]>((r) => {
86
+ resolve = r;
87
+ }),
88
+ );
89
+ const { result, rerender } = renderHook(
90
+ ({ debounceMs }) =>
91
+ unstable_useLiveCompletionAdapter({ fetcher, debounceMs }),
92
+ { initialProps: { debounceMs: 0 } },
93
+ );
94
+
95
+ await act(async () => {
96
+ result.current.adapter.search!("alice");
97
+ await vi.advanceTimersByTimeAsync(0);
98
+ });
99
+ expect(fetcher).toHaveBeenCalledOnce();
100
+
101
+ rerender({ debounceMs: 50 });
102
+ await act(async () => resolve([item("alice")]));
103
+
104
+ expect(result.current.adapter.search!("alice")).toEqual([item("alice")]);
105
+ expect(result.current.isLoading).toBe(false);
106
+ });
107
+
108
+ it("replays a search queued while the adapter is hidden", async () => {
109
+ const fetcher = vi.fn(async () => []);
110
+ const suspended = new Promise<never>(() => {});
111
+ let committed!: ReturnType<typeof unstable_useLiveCompletionAdapter>;
112
+ const Harness = ({ blocked }: { blocked: boolean }) => {
113
+ const current = unstable_useLiveCompletionAdapter({
114
+ fetcher,
115
+ debounceMs: 0,
116
+ });
117
+ useLayoutEffect(() => {
118
+ committed = current;
119
+ }, [current]);
120
+ if (blocked) throw suspended;
121
+ return null;
122
+ };
123
+ const view = (blocked: boolean) => (
124
+ <Suspense fallback={null}>
125
+ <Harness blocked={blocked} />
126
+ </Suspense>
127
+ );
128
+ const rendered = render(view(false));
129
+
130
+ await act(async () => rendered.rerender(view(true)));
131
+ committed.adapter.search!("alice");
132
+ await Promise.resolve();
133
+
134
+ expect(vi.getTimerCount()).toBe(0);
135
+ await act(async () => rendered.rerender(view(false)));
136
+ await act(async () => vi.advanceTimersByTimeAsync(0));
137
+
138
+ expect(fetcher).toHaveBeenCalledOnce();
139
+ expect(fetcher).toHaveBeenCalledWith("alice");
140
+ });
141
+
142
+ it("drops a hidden search superseded by a cached query", async () => {
143
+ const fetcher = vi.fn(async (query: string) => [item(query)]);
144
+ const suspended = new Promise<never>(() => {});
145
+ let committed!: ReturnType<typeof unstable_useLiveCompletionAdapter>;
146
+ const Harness = ({ blocked }: { blocked: boolean }) => {
147
+ const current = unstable_useLiveCompletionAdapter({
148
+ fetcher,
149
+ debounceMs: 0,
150
+ });
151
+ useLayoutEffect(() => {
152
+ committed = current;
153
+ }, [current]);
154
+ if (blocked) throw suspended;
155
+ return null;
156
+ };
157
+ const view = (blocked: boolean) => (
158
+ <Suspense fallback={null}>
159
+ <Harness blocked={blocked} />
160
+ </Suspense>
161
+ );
162
+ const rendered = render(view(false));
163
+
164
+ await act(async () => {
165
+ committed.adapter.search!("alice");
166
+ await vi.advanceTimersByTimeAsync(0);
167
+ });
168
+ expect(committed.adapter.search!("alice")).toEqual([item("alice")]);
169
+
170
+ await act(async () => rendered.rerender(view(true)));
171
+ committed.adapter.search!("bob");
172
+ await Promise.resolve();
173
+ committed.adapter.search!("alice");
174
+ await Promise.resolve();
175
+
176
+ await act(async () => rendered.rerender(view(false)));
177
+ await act(async () => vi.advanceTimersByTimeAsync(0));
178
+
179
+ expect(fetcher).toHaveBeenCalledOnce();
180
+ expect(fetcher).toHaveBeenCalledWith("alice");
181
+ });
182
+
183
+ it("settles loading when a suspended adapter is shown again", async () => {
184
+ const fetcher = vi.fn(() => new Promise<never>(() => {}));
185
+ const suspended = new Promise<never>(() => {});
186
+ let committed!: ReturnType<typeof unstable_useLiveCompletionAdapter>;
187
+ const Harness = ({ blocked }: { blocked: boolean }) => {
188
+ const current = unstable_useLiveCompletionAdapter({
189
+ fetcher,
190
+ debounceMs: 0,
191
+ });
192
+ useLayoutEffect(() => {
193
+ committed = current;
194
+ }, [current]);
195
+ if (blocked) throw suspended;
196
+ return <output data-testid="status">{String(current.isLoading)}</output>;
197
+ };
198
+ const view = (blocked: boolean) => (
199
+ <Suspense fallback={null}>
200
+ <Harness blocked={blocked} />
201
+ </Suspense>
202
+ );
203
+ const rendered = render(view(false));
204
+
205
+ await act(async () => {
206
+ committed.adapter.search!("alice");
207
+ await vi.advanceTimersByTimeAsync(0);
208
+ });
209
+ expect(fetcher).toHaveBeenCalledOnce();
210
+ expect(rendered.getByTestId("status").textContent).toBe("true");
211
+
212
+ await act(async () => rendered.rerender(view(true)));
213
+ await act(async () => rendered.rerender(view(false)));
214
+
215
+ expect(rendered.getByTestId("status").textContent).toBe("false");
216
+ });
217
+
65
218
  it("does not fetch when disabled and clears cached items", async () => {
66
219
  const fetcher = vi.fn(async () => [item("a")]);
67
220
  const { result, rerender } = renderHook(
@@ -435,6 +588,54 @@ describe("unstable_useLiveCompletionAdapter", () => {
435
588
  expect(result.current.adapter.search!("alice")).toEqual([item("alice")]);
436
589
  });
437
590
 
591
+ it("re-arms a failed query when its retry is hidden", async () => {
592
+ const fetcher = vi
593
+ .fn<(query: string) => Promise<readonly Unstable_TriggerItem[]>>()
594
+ .mockRejectedValueOnce(new Error("temporarily unavailable"))
595
+ .mockImplementationOnce(() => new Promise(() => {}))
596
+ .mockResolvedValueOnce([item("alice")]);
597
+ const suspended = new Promise<never>(() => {});
598
+ let committed!: ReturnType<typeof unstable_useLiveCompletionAdapter>;
599
+ const Harness = ({ blocked }: { blocked: boolean }) => {
600
+ const current = unstable_useLiveCompletionAdapter({
601
+ fetcher,
602
+ debounceMs: 0,
603
+ });
604
+ useLayoutEffect(() => {
605
+ committed = current;
606
+ }, [current]);
607
+ if (blocked) throw suspended;
608
+ return null;
609
+ };
610
+ const view = (blocked: boolean) => (
611
+ <Suspense fallback={null}>
612
+ <Harness blocked={blocked} />
613
+ </Suspense>
614
+ );
615
+ const rendered = render(view(false));
616
+
617
+ await act(async () => {
618
+ committed.adapter.search!("alice");
619
+ await vi.advanceTimersByTimeAsync(0);
620
+ });
621
+ expect(fetcher).toHaveBeenCalledOnce();
622
+
623
+ await act(async () => {
624
+ committed.adapter.search!("alice");
625
+ await vi.advanceTimersByTimeAsync(0);
626
+ });
627
+ expect(fetcher).toHaveBeenCalledTimes(2);
628
+
629
+ await act(async () => rendered.rerender(view(true)));
630
+ await act(async () => rendered.rerender(view(false)));
631
+ await act(async () => {
632
+ committed.adapter.search!("alice");
633
+ await vi.advanceTimersByTimeAsync(0);
634
+ });
635
+
636
+ expect(fetcher).toHaveBeenCalledTimes(3);
637
+ });
638
+
438
639
  it("drops an in-flight fetch when the query returns to a cached value", async () => {
439
640
  const resolvers: Record<
440
641
  string,
@@ -84,6 +84,8 @@ export function unstable_useLiveCompletionAdapter(
84
84
  const pendingQueryRef = useRef<string | null>(null);
85
85
  const retryableQueryRef = useRef<string | null>(null);
86
86
  const pendingRetryQueryRef = useRef<string | null>(null);
87
+ const inactiveRef = useRef(true);
88
+ const deferredQueryRef = useRef<string | null>(null);
87
89
 
88
90
  const cancelTimer = useCallback(() => {
89
91
  if (timerRef.current !== null) {
@@ -102,6 +104,10 @@ export function unstable_useLiveCompletionAdapter(
102
104
  const scheduleFetch = useCallback(
103
105
  (query: string) => {
104
106
  if (!enabled) return;
107
+ if (inactiveRef.current) {
108
+ deferredQueryRef.current = query;
109
+ return;
110
+ }
105
111
  if (pendingQueryRef.current === query) return;
106
112
  rearmPendingRetry();
107
113
  if (retryableQueryRef.current === query) {
@@ -136,6 +142,11 @@ export function unstable_useLiveCompletionAdapter(
136
142
  [enabled, debounceMs, cancelTimer, rearmPendingRetry],
137
143
  );
138
144
 
145
+ const scheduleFetchRef = useRef(scheduleFetch);
146
+ useLayoutEffect(() => {
147
+ scheduleFetchRef.current = scheduleFetch;
148
+ }, [scheduleFetch]);
149
+
139
150
  const invalidatePending = useCallback(() => {
140
151
  rearmPendingRetry();
141
152
  cancelTimer();
@@ -162,7 +173,18 @@ export function unstable_useLiveCompletionAdapter(
162
173
  );
163
174
  }, [enabled, invalidatePending]);
164
175
 
165
- useEffect(() => cancelTimer, [cancelTimer]);
176
+ // Render-time searches can outlive an abandoned render, so they only arm
177
+ // request work after this hook commits.
178
+ useLayoutEffect(() => {
179
+ inactiveRef.current = false;
180
+ const deferredQuery = deferredQueryRef.current;
181
+ deferredQueryRef.current = null;
182
+ if (deferredQuery !== null) scheduleFetchRef.current(deferredQuery);
183
+ return () => {
184
+ inactiveRef.current = true;
185
+ invalidatePending();
186
+ };
187
+ }, [invalidatePending]);
166
188
 
167
189
  // Arm retries only after the failed state commits. Arming during rejection
168
190
  // would let the failure render immediately schedule another request.
@@ -179,13 +201,21 @@ export function unstable_useLiveCompletionAdapter(
179
201
  // queueMicrotask so they are not dispatched while another component renders.
180
202
  if (query !== state.query || retryableQueryRef.current === query) {
181
203
  queueMicrotask(() => scheduleFetch(query));
182
- } else if (
183
- pendingQueryRef.current !== null &&
184
- pendingQueryRef.current !== query
185
- ) {
186
- // the query returned to a cached value while a fetch for a different
187
- // query is in flight; drop it so its result cannot overwrite the cache
188
- queueMicrotask(invalidatePending);
204
+ } else {
205
+ queueMicrotask(() => {
206
+ if (
207
+ deferredQueryRef.current !== null &&
208
+ deferredQueryRef.current !== query
209
+ ) {
210
+ deferredQueryRef.current = null;
211
+ }
212
+ if (
213
+ pendingQueryRef.current !== null &&
214
+ pendingQueryRef.current !== query
215
+ ) {
216
+ invalidatePending();
217
+ }
218
+ });
189
219
  }
190
220
  return state.items;
191
221
  },