@assistant-ui/react 0.12.28 → 0.14.0

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 (124) hide show
  1. package/dist/client/ExternalThread.d.ts.map +1 -1
  2. package/dist/client/ExternalThread.js +0 -2
  3. package/dist/client/ExternalThread.js.map +1 -1
  4. package/dist/client/InMemoryThreadList.d.ts.map +1 -1
  5. package/dist/client/InMemoryThreadList.js +3 -0
  6. package/dist/client/InMemoryThreadList.js.map +1 -1
  7. package/dist/client/SingleThreadList.d.ts.map +1 -1
  8. package/dist/client/SingleThreadList.js +3 -0
  9. package/dist/client/SingleThreadList.js.map +1 -1
  10. package/dist/context/providers/ThreadViewportProvider.d.ts.map +1 -1
  11. package/dist/context/providers/ThreadViewportProvider.js +2 -10
  12. package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
  13. package/dist/context/stores/ThreadViewport.d.ts +46 -4
  14. package/dist/context/stores/ThreadViewport.d.ts.map +1 -1
  15. package/dist/context/stores/ThreadViewport.js +51 -7
  16. package/dist/context/stores/ThreadViewport.js.map +1 -1
  17. package/dist/index.d.ts +1 -29
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -28
  20. package/dist/index.js.map +1 -1
  21. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.d.ts +1 -1
  22. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.d.ts.map +1 -1
  23. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.js +1 -1
  24. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.js.map +1 -1
  25. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  26. package/dist/primitives/composer/ComposerInput.js +9 -4
  27. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  28. package/dist/primitives/message/MessageRoot.d.ts +6 -30
  29. package/dist/primitives/message/MessageRoot.d.ts.map +1 -1
  30. package/dist/primitives/message/MessageRoot.js +68 -25
  31. package/dist/primitives/message/MessageRoot.js.map +1 -1
  32. package/dist/primitives/thread/ThreadViewport.d.ts +38 -0
  33. package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
  34. package/dist/primitives/thread/ThreadViewport.js +53 -5
  35. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  36. package/dist/primitives/thread/ThreadViewportFooter.d.ts +2 -1
  37. package/dist/primitives/thread/ThreadViewportFooter.d.ts.map +1 -1
  38. package/dist/primitives/thread/ThreadViewportFooter.js +2 -1
  39. package/dist/primitives/thread/ThreadViewportFooter.js.map +1 -1
  40. package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.d.ts +22 -0
  41. package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.d.ts.map +1 -0
  42. package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.js +53 -0
  43. package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.js.map +1 -0
  44. package/dist/primitives/thread/topAnchor/createReserveObservers.d.ts +5 -0
  45. package/dist/primitives/thread/topAnchor/createReserveObservers.d.ts.map +1 -0
  46. package/dist/primitives/thread/topAnchor/createReserveObservers.js +38 -0
  47. package/dist/primitives/thread/topAnchor/createReserveObservers.js.map +1 -0
  48. package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.d.ts +22 -0
  49. package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.d.ts.map +1 -0
  50. package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.js +75 -0
  51. package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.js.map +1 -0
  52. package/dist/primitives/thread/topAnchor/topAnchorTurn.d.ts +15 -0
  53. package/dist/primitives/thread/topAnchor/topAnchorTurn.d.ts.map +1 -0
  54. package/dist/primitives/thread/topAnchor/topAnchorTurn.js +13 -0
  55. package/dist/primitives/thread/topAnchor/topAnchorTurn.js.map +1 -0
  56. package/dist/primitives/thread/topAnchor/topAnchorUtils.d.ts +15 -0
  57. package/dist/primitives/thread/topAnchor/topAnchorUtils.d.ts.map +1 -0
  58. package/dist/primitives/thread/topAnchor/topAnchorUtils.js +51 -0
  59. package/dist/primitives/thread/topAnchor/topAnchorUtils.js.map +1 -0
  60. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.d.ts +7 -0
  61. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.d.ts.map +1 -0
  62. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +18 -0
  63. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -0
  64. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  65. package/dist/primitives/thread/useThreadViewportAutoScroll.js +13 -1
  66. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  67. package/dist/primitives/thread.d.ts +0 -1
  68. package/dist/primitives/thread.d.ts.map +1 -1
  69. package/dist/primitives/thread.js +0 -1
  70. package/dist/primitives/thread.js.map +1 -1
  71. package/dist/primitives/threadList/ThreadListLoadMore.d.ts +13 -0
  72. package/dist/primitives/threadList/ThreadListLoadMore.d.ts.map +1 -0
  73. package/dist/primitives/threadList/ThreadListLoadMore.js +11 -0
  74. package/dist/primitives/threadList/ThreadListLoadMore.js.map +1 -0
  75. package/dist/primitives/threadList.d.ts +1 -0
  76. package/dist/primitives/threadList.d.ts.map +1 -1
  77. package/dist/primitives/threadList.js +1 -0
  78. package/dist/primitives/threadList.js.map +1 -1
  79. package/dist/utils/hooks/useManagedRef.d.ts.map +1 -1
  80. package/dist/utils/hooks/useManagedRef.js +1 -0
  81. package/dist/utils/hooks/useManagedRef.js.map +1 -1
  82. package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
  83. package/dist/utils/hooks/useOnResizeContent.js +1 -2
  84. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  85. package/package.json +10 -10
  86. package/src/client/ExternalThread.ts +0 -2
  87. package/src/client/InMemoryThreadList.ts +3 -0
  88. package/src/client/SingleThreadList.ts +3 -0
  89. package/src/context/providers/ThreadViewportProvider.tsx +2 -12
  90. package/src/context/stores/ThreadViewport.ts +111 -11
  91. package/src/index.ts +0 -35
  92. package/src/legacy-runtime/runtime-cores/assistant-transport/utils.ts +1 -5
  93. package/src/primitives/composer/ComposerInput.test.tsx +232 -0
  94. package/src/primitives/composer/ComposerInput.tsx +9 -4
  95. package/src/primitives/message/MessageRoot.tsx +135 -57
  96. package/src/primitives/thread/ThreadViewport.tsx +95 -4
  97. package/src/primitives/thread/ThreadViewportFooter.tsx +2 -1
  98. package/src/primitives/thread/topAnchor/computeTopAnchorSlack.test.ts +131 -0
  99. package/src/primitives/thread/topAnchor/computeTopAnchorSlack.ts +94 -0
  100. package/src/primitives/thread/topAnchor/createReserveObservers.ts +50 -0
  101. package/src/primitives/thread/topAnchor/mountTopAnchorReserve.test.ts +131 -0
  102. package/src/primitives/thread/topAnchor/mountTopAnchorReserve.ts +127 -0
  103. package/src/primitives/thread/topAnchor/topAnchorTurn.test.ts +46 -0
  104. package/src/primitives/thread/topAnchor/topAnchorTurn.ts +30 -0
  105. package/src/primitives/thread/topAnchor/topAnchorUtils.ts +58 -0
  106. package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +19 -0
  107. package/src/primitives/thread/useThreadViewportAutoScroll.ts +15 -1
  108. package/src/primitives/thread.ts +0 -1
  109. package/src/primitives/threadList/ThreadListLoadMore.tsx +24 -0
  110. package/src/primitives/threadList.ts +1 -0
  111. package/src/tests/RemoteThreadListRuntime.adapterProvider.test.tsx +138 -0
  112. package/src/tests/RemoteThreadListRuntime.deferredProvider.test.tsx +28 -17
  113. package/src/utils/hooks/useManagedRef.ts +1 -0
  114. package/src/utils/hooks/useOnResizeContent.ts +1 -2
  115. package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.d.ts +0 -3
  116. package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.d.ts.map +0 -1
  117. package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.js +0 -3
  118. package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.js.map +0 -1
  119. package/dist/primitives/thread/ThreadViewportSlack.d.ts +0 -20
  120. package/dist/primitives/thread/ThreadViewportSlack.d.ts.map +0 -1
  121. package/dist/primitives/thread/ThreadViewportSlack.js +0 -80
  122. package/dist/primitives/thread/ThreadViewportSlack.js.map +0 -1
  123. package/src/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.ts +0 -6
  124. package/src/primitives/thread/ThreadViewportSlack.tsx +0 -116
@@ -60,28 +60,80 @@ export type ThreadViewportState = {
60
60
  /** Controls scroll anchoring: "top" anchors user messages at top, "bottom" is classic behavior */
61
61
  readonly turnAnchor: "top" | "bottom";
62
62
 
63
+ /** Clamps tall user messages so the assistant response stays in view. */
64
+ readonly topAnchorMessageClamp: {
65
+ readonly tallerThan: string;
66
+ readonly visibleHeight: string;
67
+ };
68
+
63
69
  /** Raw height values from registered elements */
64
70
  readonly height: {
65
71
  /** Total viewport height */
66
72
  readonly viewport: number;
67
73
  /** Total content inset height (footer, anchor message, etc.) */
68
74
  readonly inset: number;
69
- /** Height of the anchor user message (full height) */
70
- readonly userMessage: number;
71
75
  };
72
76
 
77
+ /** Current DOM elements used for geometry-based top anchoring */
78
+ readonly element: {
79
+ readonly viewport: HTMLElement | null;
80
+ readonly anchor: HTMLElement | null;
81
+ readonly target: HTMLElement | null;
82
+ };
83
+
84
+ /** Numeric clamp configuration for the active top-anchor target message */
85
+ readonly targetConfig: {
86
+ readonly tallerThan: number;
87
+ readonly visibleHeight: number;
88
+ } | null;
89
+
90
+ /**
91
+ * The current top-anchor turn activated in this viewport session.
92
+ * History-loaded messages do not populate this; it is set when a run creates
93
+ * a live user/assistant pair and remains after the run completes.
94
+ */
95
+ readonly topAnchorTurn: {
96
+ readonly anchorId: string;
97
+ readonly targetId: string;
98
+ } | null;
99
+
73
100
  /** Register a viewport and get a handle to update its height */
74
101
  readonly registerViewport: () => SizeHandle;
75
102
 
76
103
  /** Register a content inset (footer, anchor message, etc.) and get a handle to update its height */
77
104
  readonly registerContentInset: () => SizeHandle;
78
105
 
79
- /** Register the anchor user message height */
80
- readonly registerUserMessageHeight: () => SizeHandle;
106
+ /** Register the scroll viewport element */
107
+ readonly registerViewportElement: (
108
+ element: HTMLElement | null,
109
+ ) => Unsubscribe;
110
+
111
+ /** Register the current anchor user message element */
112
+ readonly registerAnchorElement: (element: HTMLElement | null) => Unsubscribe;
113
+
114
+ /**
115
+ * Register the current top-anchor target (last assistant response) element
116
+ * along with its numeric clamp configuration. When unregistered, both
117
+ * `element.target` and `targetConfig` clear together.
118
+ */
119
+ readonly registerAnchorTargetElement: (
120
+ element: HTMLElement | null,
121
+ config?: { readonly tallerThan: number; readonly visibleHeight: number },
122
+ ) => Unsubscribe;
123
+
124
+ readonly setTopAnchorTurn: (
125
+ turn: { readonly anchorId: string; readonly targetId: string } | null,
126
+ ) => void;
81
127
  };
82
128
 
83
129
  export type ThreadViewportStoreOptions = {
84
130
  turnAnchor?: "top" | "bottom" | undefined;
131
+ topAnchorMessageClamp?:
132
+ | {
133
+ tallerThan?: string | undefined;
134
+ visibleHeight?: string | undefined;
135
+ }
136
+ | undefined;
85
137
  };
86
138
 
87
139
  export const makeThreadViewportStore = (
@@ -107,14 +159,27 @@ export const makeThreadViewportStore = (
107
159
  },
108
160
  });
109
161
  });
110
- const userMessageRegistry = createSizeRegistry((total) => {
162
+ const registerElementSlot = (
163
+ key: "viewport" | "anchor",
164
+ element: HTMLElement | null,
165
+ ) => {
111
166
  store.setState({
112
- height: {
113
- ...store.getState().height,
114
- userMessage: total,
167
+ element: {
168
+ ...store.getState().element,
169
+ [key]: element,
115
170
  },
116
171
  });
117
- });
172
+
173
+ return () => {
174
+ if (store.getState().element[key] !== element) return;
175
+ store.setState({
176
+ element: {
177
+ ...store.getState().element,
178
+ [key]: null,
179
+ },
180
+ });
181
+ };
182
+ };
118
183
 
119
184
  const store = create<ThreadViewportState>(() => ({
120
185
  isAtBottom: true,
@@ -131,16 +196,51 @@ export const makeThreadViewportStore = (
131
196
  },
132
197
 
133
198
  turnAnchor: options.turnAnchor ?? "bottom",
199
+ topAnchorMessageClamp: {
200
+ tallerThan: options.topAnchorMessageClamp?.tallerThan ?? "10em",
201
+ visibleHeight: options.topAnchorMessageClamp?.visibleHeight ?? "6em",
202
+ },
134
203
 
135
204
  height: {
136
205
  viewport: 0,
137
206
  inset: 0,
138
- userMessage: 0,
139
207
  },
208
+ element: {
209
+ viewport: null,
210
+ anchor: null,
211
+ target: null,
212
+ },
213
+ targetConfig: null,
214
+ topAnchorTurn: null,
140
215
 
141
216
  registerViewport: viewportRegistry.register,
142
217
  registerContentInset: insetRegistry.register,
143
- registerUserMessageHeight: userMessageRegistry.register,
218
+ registerViewportElement: (element) =>
219
+ registerElementSlot("viewport", element),
220
+ registerAnchorElement: (element) => registerElementSlot("anchor", element),
221
+ registerAnchorTargetElement: (element, config) => {
222
+ store.setState({
223
+ element: {
224
+ ...store.getState().element,
225
+ target: element,
226
+ },
227
+ targetConfig: element && config ? config : null,
228
+ });
229
+
230
+ return () => {
231
+ if (store.getState().element.target !== element) return;
232
+ store.setState({
233
+ element: {
234
+ ...store.getState().element,
235
+ target: null,
236
+ },
237
+ targetConfig: null,
238
+ });
239
+ };
240
+ },
241
+ setTopAnchorTurn: (topAnchorTurn) => {
242
+ store.setState({ topAnchorTurn });
243
+ },
144
244
  }));
145
245
 
146
246
  return store;
package/src/index.ts CHANGED
@@ -125,7 +125,6 @@ export type {
125
125
  // --- external-store ---
126
126
  export type { ThreadMessageLike } from "@assistant-ui/core";
127
127
  export {
128
- getExternalStoreMessage,
129
128
  getExternalStoreMessages,
130
129
  bindExternalStoreMessage,
131
130
  } from "@assistant-ui/core";
@@ -151,25 +150,13 @@ export type {
151
150
  LocalRuntimeOptionsBase,
152
151
  } from "@assistant-ui/core";
153
152
  export { useLocalRuntime } from "./legacy-runtime/runtime-cores/local/useLocalRuntime";
154
- /**
155
- * @deprecated Use `useLocalRuntime` instead.
156
- */
157
- export { useLocalRuntime as useLocalThreadRuntime } from "./legacy-runtime/runtime-cores/local/useLocalRuntime";
158
153
  export type { LocalRuntimeOptions } from "./legacy-runtime/runtime-cores/local/LocalRuntimeOptions";
159
154
 
160
155
  // --- remote-thread-list ---
161
156
  export { useRemoteThreadListRuntime } from "./legacy-runtime/runtime-cores/remote-thread-list/useRemoteThreadListRuntime";
162
- /** @deprecated Use `useRemoteThreadListRuntime` instead. */
163
- export { useRemoteThreadListRuntime as unstable_useRemoteThreadListRuntime } from "./legacy-runtime/runtime-cores/remote-thread-list/useRemoteThreadListRuntime";
164
157
  export { useCloudThreadListAdapter } from "./legacy-runtime/runtime-cores/remote-thread-list/adapter/cloud";
165
- /** @deprecated Use `useCloudThreadListAdapter` instead. */
166
- export { useCloudThreadListAdapter as unstable_useCloudThreadListAdapter } from "./legacy-runtime/runtime-cores/remote-thread-list/adapter/cloud";
167
158
  export type { RemoteThreadListAdapter } from "@assistant-ui/core";
168
- /** @deprecated Use `RemoteThreadListAdapter` instead. */
169
- export type { RemoteThreadListAdapter as unstable_RemoteThreadListAdapter } from "@assistant-ui/core";
170
159
  export { InMemoryThreadListAdapter } from "@assistant-ui/core";
171
- /** @deprecated Use `InMemoryThreadListAdapter` instead. */
172
- export { InMemoryThreadListAdapter as unstable_InMemoryThreadListAdapter } from "@assistant-ui/core";
173
160
 
174
161
  // Re-export from @assistant-ui/core (runtime-cores root)
175
162
  export type { ExportedMessageRepositoryItem } from "@assistant-ui/core";
@@ -391,25 +378,3 @@ export {
391
378
  } from "./primitives/composer/trigger";
392
379
 
393
380
  export type { Assistant } from "./augmentations";
394
-
395
- // Backwards compatibility — deprecated exports, to be removed in v0.13
396
-
397
- /**
398
- * @deprecated Use `useAui` instead. This alias will be removed in v0.13.
399
- */
400
- export { useAui as useAssistantApi } from "@assistant-ui/store";
401
-
402
- /**
403
- * @deprecated Use `useAuiState` instead. This alias will be removed in v0.13.
404
- */
405
- export { useAuiState as useAssistantState } from "@assistant-ui/store";
406
-
407
- /**
408
- * @deprecated Use `useAuiEvent` instead. This alias will be removed in v0.13.
409
- */
410
- export { useAuiEvent as useAssistantEvent } from "@assistant-ui/store";
411
-
412
- /**
413
- * @deprecated Use `AuiIf` instead. This alias will be removed in v0.13.
414
- */
415
- export { AuiIf as AssistantIf } from "@assistant-ui/store";
@@ -1,5 +1 @@
1
- export {
2
- toAISDKTools,
3
- getEnabledTools,
4
- createRequestHeaders,
5
- } from "@assistant-ui/core";
1
+ export { createRequestHeaders } from "@assistant-ui/core";
@@ -0,0 +1,232 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { act } from "react";
5
+ import { createRoot, type Root } from "react-dom/client";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { ComposerPrimitiveInput } from "./ComposerInput";
8
+
9
+ const setText = vi.fn<(text: string) => void>();
10
+ const setCursorPosition = vi.fn<(pos: number) => void>();
11
+
12
+ const composerState = {
13
+ isEditing: true,
14
+ text: "",
15
+ type: "thread" as const,
16
+ isEmpty: true,
17
+ canCancel: false,
18
+ dictation: undefined as undefined | { inputDisabled: boolean },
19
+ };
20
+
21
+ const threadState = {
22
+ isDisabled: false,
23
+ isRunning: false,
24
+ capabilities: { queue: false, attachments: false },
25
+ };
26
+
27
+ const plugin = {
28
+ handleKeyDown: () => false,
29
+ setCursorPosition,
30
+ };
31
+
32
+ let pluginRegistry: { getPlugins: () => (typeof plugin)[] } | null = null;
33
+
34
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
35
+
36
+ vi.mock("@assistant-ui/store", () => {
37
+ const aui = {
38
+ composer: () => ({
39
+ setText,
40
+ getState: () => composerState,
41
+ cancel: () => {},
42
+ send: () => {},
43
+ addAttachment: async () => {},
44
+ }),
45
+ thread: () => ({
46
+ getState: () => threadState,
47
+ }),
48
+ on: () => () => {},
49
+ };
50
+ type Selector<T> = (s: {
51
+ composer: typeof composerState;
52
+ thread: typeof threadState;
53
+ }) => T;
54
+ return {
55
+ useAui: () => aui,
56
+ useAuiState: <T,>(selector: Selector<T>) =>
57
+ selector({ composer: composerState, thread: threadState }),
58
+ };
59
+ });
60
+
61
+ vi.mock("@assistant-ui/tap", () => ({
62
+ flushResourcesSync: (fn: () => void) => fn(),
63
+ }));
64
+
65
+ vi.mock("./ComposerInputPluginContext", () => ({
66
+ useComposerInputPluginRegistryOptional: () => pluginRegistry,
67
+ }));
68
+
69
+ vi.mock("@radix-ui/react-use-escape-keydown", () => ({
70
+ useEscapeKeydown: () => {},
71
+ }));
72
+
73
+ vi.mock("../../utils/hooks/useOnScrollToBottom", () => ({
74
+ useOnScrollToBottom: () => {},
75
+ }));
76
+
77
+ const setNativeValue = (textarea: HTMLTextAreaElement, value: string) => {
78
+ const setter = Object.getOwnPropertyDescriptor(
79
+ HTMLTextAreaElement.prototype,
80
+ "value",
81
+ )?.set;
82
+ setter?.call(textarea, value);
83
+ };
84
+
85
+ const fireInput = (
86
+ textarea: HTMLTextAreaElement,
87
+ value: string,
88
+ isComposing: boolean,
89
+ ) => {
90
+ setNativeValue(textarea, value);
91
+ textarea.dispatchEvent(
92
+ new InputEvent("input", { bubbles: true, isComposing }),
93
+ );
94
+ };
95
+
96
+ const fireCompositionStart = (textarea: HTMLTextAreaElement) => {
97
+ textarea.dispatchEvent(
98
+ new CompositionEvent("compositionstart", { bubbles: true }),
99
+ );
100
+ };
101
+
102
+ const fireCompositionEnd = (textarea: HTMLTextAreaElement, value: string) => {
103
+ setNativeValue(textarea, value);
104
+ textarea.dispatchEvent(
105
+ new CompositionEvent("compositionend", { bubbles: true }),
106
+ );
107
+ };
108
+
109
+ describe("ComposerPrimitiveInput", () => {
110
+ let container: HTMLDivElement;
111
+ let root: Root;
112
+
113
+ beforeEach(() => {
114
+ setText.mockReset();
115
+ setCursorPosition.mockReset();
116
+ composerState.isEditing = true;
117
+ composerState.text = "";
118
+ composerState.isEmpty = true;
119
+ composerState.dictation = undefined;
120
+ threadState.isDisabled = false;
121
+ threadState.isRunning = false;
122
+ threadState.capabilities = { queue: false, attachments: false };
123
+ pluginRegistry = null;
124
+
125
+ container = document.createElement("div");
126
+ document.body.appendChild(container);
127
+ root = createRoot(container);
128
+ });
129
+
130
+ afterEach(async () => {
131
+ await act(async () => {
132
+ root.unmount();
133
+ });
134
+ container.remove();
135
+ vi.restoreAllMocks();
136
+ });
137
+
138
+ const mount = async () => {
139
+ await act(async () => {
140
+ root.render(<ComposerPrimitiveInput data-testid="input" />);
141
+ });
142
+ const textarea = container.querySelector("textarea") as HTMLTextAreaElement;
143
+ expect(textarea).not.toBeNull();
144
+ return textarea;
145
+ };
146
+
147
+ it("syncs setText during active composition so React 19 cannot reset the textarea", async () => {
148
+ const textarea = await mount();
149
+
150
+ await act(async () => {
151
+ fireCompositionStart(textarea);
152
+ fireInput(textarea, "ㄱ", true);
153
+ });
154
+ expect(setText).toHaveBeenCalledWith("ㄱ");
155
+
156
+ await act(async () => {
157
+ fireInput(textarea, "가", true);
158
+ });
159
+ expect(setText).toHaveBeenLastCalledWith("가");
160
+ });
161
+
162
+ it("commits the final value on compositionend", async () => {
163
+ const textarea = await mount();
164
+
165
+ await act(async () => {
166
+ fireCompositionStart(textarea);
167
+ fireInput(textarea, "가", true);
168
+ });
169
+ expect(setText).toHaveBeenCalledTimes(1);
170
+
171
+ await act(async () => {
172
+ fireCompositionEnd(textarea, "가");
173
+ });
174
+ expect(setText).toHaveBeenCalledTimes(2);
175
+ expect(setText).toHaveBeenLastCalledWith("가");
176
+ });
177
+
178
+ it("recovers when compositionend is dropped before the next input", async () => {
179
+ const textarea = await mount();
180
+
181
+ await act(async () => {
182
+ fireCompositionStart(textarea);
183
+ fireInput(textarea, "hello", false);
184
+ });
185
+ expect(setText).toHaveBeenCalledWith("hello");
186
+
187
+ await act(async () => {
188
+ fireInput(textarea, "hello!", false);
189
+ });
190
+ expect(setText).toHaveBeenLastCalledWith("hello!");
191
+ });
192
+
193
+ it("skips plugin cursor tracking during composition but resumes after", async () => {
194
+ pluginRegistry = { getPlugins: () => [plugin] };
195
+ const textarea = await mount();
196
+
197
+ await act(async () => {
198
+ fireCompositionStart(textarea);
199
+ fireInput(textarea, "ㄱ", true);
200
+ });
201
+ expect(setText).toHaveBeenCalledWith("ㄱ");
202
+ expect(setCursorPosition).not.toHaveBeenCalled();
203
+
204
+ await act(async () => {
205
+ fireCompositionEnd(textarea, "가");
206
+ });
207
+ expect(setCursorPosition).toHaveBeenCalled();
208
+ });
209
+
210
+ it("tracks plugin cursor for non-composition input", async () => {
211
+ pluginRegistry = { getPlugins: () => [plugin] };
212
+ const textarea = await mount();
213
+
214
+ await act(async () => {
215
+ fireInput(textarea, "abc", false);
216
+ });
217
+ expect(setText).toHaveBeenCalledWith("abc");
218
+ expect(setCursorPosition).toHaveBeenCalled();
219
+ });
220
+
221
+ it("ignores input and compositionend when the composer is not editing", async () => {
222
+ composerState.isEditing = false;
223
+ const textarea = await mount();
224
+
225
+ await act(async () => {
226
+ fireInput(textarea, "abc", false);
227
+ fireCompositionStart(textarea);
228
+ fireCompositionEnd(textarea, "가");
229
+ });
230
+ expect(setText).not.toHaveBeenCalled();
231
+ });
232
+ });
@@ -297,13 +297,18 @@ export const ComposerPrimitiveInput = forwardRef<
297
297
  onChange,
298
298
  (e: React.ChangeEvent<HTMLTextAreaElement>) => {
299
299
  if (!aui.composer().getState().isEditing) return;
300
- const isComposing =
301
- (e.nativeEvent as { isComposing?: boolean }).isComposing === true ||
302
- compositionRef.current;
303
- if (isComposing) return;
300
+ const nativeIsComposing =
301
+ (e.nativeEvent as { isComposing?: boolean }).isComposing === true;
302
+ // recover stuck compositionRef when the browser drops compositionend
303
+ if (compositionRef.current && !nativeIsComposing) {
304
+ compositionRef.current = false;
305
+ }
306
+ const isComposing = nativeIsComposing || compositionRef.current;
307
+ // keep controlled value in sync mid-IME so react does not reset the textarea to a stale value
304
308
  flushResourcesSync(() => {
305
309
  aui.composer().setText(e.target.value);
306
310
  });
311
+ if (isComposing) return;
307
312
  const pos = e.target.selectionStart ?? e.target.value.length;
308
313
  if (pluginRegistry) {
309
314
  for (const plugin of pluginRegistry.getPlugins()) {