@assistant-ui/react 0.15.24 → 0.15.26
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.
- package/dist/hooks/useToolCallElapsed.d.ts +3 -1
- package/dist/hooks/useToolCallElapsed.d.ts.map +1 -1
- package/dist/hooks/useToolCallElapsed.js +8 -8
- package/dist/hooks/useToolCallElapsed.js.map +1 -1
- package/dist/index.d.ts +39 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +3 -2
- package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
- package/dist/mcp-apps/app-frame.d.ts.map +1 -1
- package/dist/mcp-apps/app-frame.js +56 -46
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/mcp-apps/csp.d.ts +4 -0
- package/dist/mcp-apps/csp.d.ts.map +1 -0
- package/dist/mcp-apps/csp.js +93 -0
- package/dist/mcp-apps/csp.js.map +1 -0
- package/dist/mcp-apps/types.d.ts +1 -0
- package/dist/mcp-apps/types.d.ts.map +1 -1
- package/dist/mcp-apps/types.js.map +1 -1
- package/dist/model-context/makeAssistantVisible.d.ts +1 -0
- package/dist/model-context/makeAssistantVisible.d.ts.map +1 -1
- package/dist/model-context/makeAssistantVisible.js +31 -12
- package/dist/model-context/makeAssistantVisible.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.d.ts +1 -0
- package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
- package/dist/primitives/attachment.d.ts +3 -1
- package/dist/primitives/attachment.d.ts.map +1 -1
- package/dist/primitives/attachment.js.map +1 -1
- package/dist/primitives/composer/ComposerCancel.d.ts +5 -1
- package/dist/primitives/composer/ComposerCancel.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerCancel.js +14 -1
- package/dist/primitives/composer/ComposerCancel.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts +15 -1
- package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +5 -4
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerRoot.js +24 -19
- package/dist/primitives/composer/ComposerRoot.js.map +1 -1
- package/dist/primitives/composer/composerInputSelector.d.ts +3 -0
- package/dist/primitives/composer/composerInputSelector.d.ts.map +1 -0
- package/dist/primitives/composer/composerInputSelector.js +7 -0
- package/dist/primitives/composer/composerInputSelector.js.map +1 -0
- package/dist/primitives/composer/trigger/TriggerPopover.d.ts +11 -11
- package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopover.js +62 -60
- package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverAction.d.ts +8 -8
- package/dist/primitives/composer/trigger/TriggerPopoverAction.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js +6 -6
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverCategories.d.ts +3 -3
- package/dist/primitives/composer/trigger/TriggerPopoverCategories.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverCategories.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.d.ts +9 -9
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +7 -7
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverItems.d.ts +5 -5
- package/dist/primitives/composer/trigger/TriggerPopoverItems.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverItems.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts +8 -8
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +8 -8
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
- package/dist/primitives/composer/trigger/index.d.ts +1 -1
- package/dist/primitives/composer/useComposerCancelWithFocus.d.ts +2 -0
- package/dist/primitives/composer/useComposerCancelWithFocus.d.ts.map +1 -0
- package/dist/primitives/composer/useComposerCancelWithFocus.js +42 -0
- package/dist/primitives/composer/useComposerCancelWithFocus.js.map +1 -0
- package/dist/primitives/composer.d.ts +55 -15
- package/dist/primitives/composer.d.ts.map +1 -1
- package/dist/primitives/composer.js +8 -1
- package/dist/primitives/composer.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts +8 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.js +58 -278
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +5 -2
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
- package/dist/primitives/thread/ThreadLoadEarlier.d.ts +19 -0
- package/dist/primitives/thread/ThreadLoadEarlier.d.ts.map +1 -0
- package/dist/primitives/thread/ThreadLoadEarlier.js +36 -0
- package/dist/primitives/thread/ThreadLoadEarlier.js.map +1 -0
- package/dist/primitives/thread/ThreadMessages.d.ts +1 -1
- package/dist/primitives/thread/ThreadMessages.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadMessages.js +2 -2
- package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadViewport.js +4 -3
- package/dist/primitives/thread/ThreadViewport.js.map +1 -1
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +2 -2
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +195 -17
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/primitives/thread.d.ts +2 -0
- package/dist/primitives/thread.d.ts.map +1 -1
- package/dist/primitives/thread.js +5 -2
- package/dist/primitives/thread.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
- package/dist/sandbox-host/SandboxHost.js +9 -3
- package/dist/sandbox-host/SandboxHost.js.map +1 -1
- package/dist/unstable/useComposerInput.d.ts +7 -4
- package/dist/unstable/useComposerInput.d.ts.map +1 -1
- package/dist/unstable/useComposerInput.js +4 -4
- package/dist/unstable/useComposerInput.js.map +1 -1
- package/dist/unstable/useComposerInputHistory.d.ts +3 -2
- package/dist/unstable/useComposerInputHistory.d.ts.map +1 -1
- package/dist/unstable/useComposerInputHistory.js +2 -2
- package/dist/unstable/useComposerInputHistory.js.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.d.ts +3 -2
- package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.js +8 -7
- package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
- package/dist/unstable/useMentionAdapter.d.ts +12 -3
- package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
- package/dist/unstable/useMentionAdapter.js +2 -2
- package/dist/unstable/useMentionAdapter.js.map +1 -1
- package/dist/unstable/useMessageStallDetection.d.ts +4 -2
- package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
- package/dist/unstable/useMessageStallDetection.js +12 -8
- package/dist/unstable/useMessageStallDetection.js.map +1 -1
- package/dist/unstable/useSlashCommandAdapter.d.ts +5 -2
- package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
- package/dist/unstable/useSlashCommandAdapter.js +5 -4
- package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts +4 -0
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.js +2 -0
- package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
- package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
- package/dist/utils/getSelectionMessageId.js +31 -8
- package/dist/utils/getSelectionMessageId.js.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.js +2 -22
- package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
- package/dist/utils/smooth/useSmooth.js +6 -6
- package/dist/utils/smooth/useSmooth.js.map +1 -1
- package/dist/utils/useIsomorphicLayoutEffect.d.ts +3 -0
- package/dist/utils/useIsomorphicLayoutEffect.d.ts.map +1 -0
- package/dist/utils/useIsomorphicLayoutEffect.js +6 -0
- package/dist/utils/useIsomorphicLayoutEffect.js.map +1 -0
- package/package.json +12 -11
- package/src/hooks/useToolCallElapsed.test.tsx +37 -0
- package/src/hooks/useToolCallElapsed.ts +10 -7
- package/src/index.ts +47 -5
- package/src/mcp-apps/McpAppRenderer.tsx +3 -9
- package/src/mcp-apps/app-frame.test.tsx +38 -0
- package/src/mcp-apps/app-frame.tsx +5 -8
- package/src/mcp-apps/csp.test.ts +112 -0
- package/src/mcp-apps/csp.ts +124 -0
- package/src/mcp-apps/types.ts +1 -0
- package/src/model-context/frame/AssistantFrame.test.ts +45 -0
- package/src/model-context/frame/SPEC_AssistantFrame.md +1 -0
- package/src/model-context/makeAssistantVisible.test.tsx +315 -5
- package/src/model-context/makeAssistantVisible.tsx +43 -7
- package/src/primitives/assistantModal/AssistantModalRoot.tsx +1 -0
- package/src/primitives/attachment.ts +4 -1
- package/src/primitives/composer/ComposerCancel.focus.test.tsx +307 -0
- package/src/primitives/composer/ComposerCancel.ts +8 -1
- package/src/primitives/composer/ComposerInput.tsx +20 -2
- package/src/primitives/composer/ComposerRoot.tsx +6 -5
- package/src/primitives/composer/composerInputSelector.ts +4 -0
- package/src/primitives/composer/trigger/TriggerPopover.tsx +11 -11
- package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +10 -13
- package/src/primitives/composer/trigger/TriggerPopoverCategories.tsx +2 -2
- package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +11 -14
- package/src/primitives/composer/trigger/TriggerPopoverExports.test.ts +61 -0
- package/src/primitives/composer/trigger/TriggerPopoverItems.tsx +3 -3
- package/src/primitives/composer/trigger/TriggerPopoverRootContext.tsx +8 -8
- package/src/primitives/composer/useComposerCancelWithFocus.ts +57 -0
- package/src/primitives/composer.ts +70 -15
- package/src/primitives/message/MessagePartsGrouped.test.tsx +106 -2
- package/src/primitives/message/MessagePartsGrouped.tsx +36 -134
- package/src/primitives/message/MessageRoot.test.tsx +125 -2
- package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +20 -12
- package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +6 -2
- package/src/primitives/thread/ThreadLoadEarlier.tsx +35 -0
- package/src/primitives/thread/ThreadMessages.tsx +1 -0
- package/src/primitives/thread/ThreadViewport.tsx +3 -3
- package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +2 -2
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +922 -68
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +330 -56
- package/src/primitives/thread.ts +2 -0
- package/src/sandbox-host/SandboxHost.test.tsx +34 -0
- package/src/sandbox-host/SandboxHost.tsx +11 -12
- package/src/tests/toolCallTiming.test.tsx +19 -2
- package/src/unstable/useComposerInput.ts +7 -4
- package/src/unstable/useComposerInputHistory.ts +3 -2
- package/src/unstable/useLiveCompletionAdapter.ts +9 -14
- package/src/unstable/useMentionAdapter.ts +12 -3
- package/src/unstable/useMessageStallDetection.test.tsx +40 -0
- package/src/unstable/useMessageStallDetection.ts +14 -8
- package/src/unstable/useSlashCommandAdapter.ts +8 -4
- package/src/unstable/webmcp/useWebMcpProvider.ts +4 -0
- package/src/utils/getSelectionMessageId.test.ts +231 -0
- package/src/utils/getSelectionMessageId.ts +45 -7
- package/src/utils/hooks/useOnResizeContent.ts +2 -29
- package/src/utils/smooth/useSmooth.test.tsx +16 -5
- package/src/utils/smooth/useSmooth.ts +7 -7
- package/src/utils/useIsomorphicLayoutEffect.test.tsx +21 -0
- package/src/utils/useIsomorphicLayoutEffect.ts +9 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useComposedRefs } from "radix-ui/internal";
|
|
4
|
-
import { useCallback,
|
|
5
|
-
import { useAuiEvent, useAuiState } from "@assistant-ui/store";
|
|
4
|
+
import { useCallback, useRef, type RefCallback } from "react";
|
|
5
|
+
import { useAui, useAuiEvent, useAuiState } from "@assistant-ui/store";
|
|
6
6
|
import {
|
|
7
7
|
isUserScrollUp,
|
|
8
8
|
isViewportAtBottom,
|
|
@@ -13,12 +13,21 @@ import { useOnScrollToBottom } from "../../utils/hooks/useOnScrollToBottom";
|
|
|
13
13
|
import { useManagedRef } from "../../utils/hooks/useManagedRef";
|
|
14
14
|
import { writableStore } from "../../context/ReadonlyStore";
|
|
15
15
|
import { useThreadViewportStore } from "../../context/react/ThreadViewportContext";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
import { useIsomorphicLayoutEffect } from "../../utils/useIsomorphicLayoutEffect";
|
|
17
|
+
|
|
18
|
+
const SCROLL_KEYS = new Set([
|
|
19
|
+
" ",
|
|
20
|
+
"ArrowUp",
|
|
21
|
+
"ArrowDown",
|
|
22
|
+
"PageUp",
|
|
23
|
+
"PageDown",
|
|
24
|
+
"Home",
|
|
25
|
+
"End",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// Enter and Space activate focused controls, while navigation keys can
|
|
29
|
+
// interrupt scrolling without producing a scroll event.
|
|
30
|
+
const INTENT_CANCEL_KEYS = new Set(["Enter", ...SCROLL_KEYS]);
|
|
22
31
|
|
|
23
32
|
// A control that consumes the activation key itself instead of acting on thread
|
|
24
33
|
// content. `contenteditable="false"` marks a non-editable island inside an
|
|
@@ -33,6 +42,85 @@ const TEXT_ENTRY_SELECTOR = [
|
|
|
33
42
|
":not([type='range']):not([type='file']):not([type='color'])",
|
|
34
43
|
].join(", ");
|
|
35
44
|
|
|
45
|
+
const MESSAGE_SELECTOR = "[data-message-id]";
|
|
46
|
+
|
|
47
|
+
const DISCLOSURE_SELECTOR = "[aria-expanded], summary";
|
|
48
|
+
|
|
49
|
+
// A popup trigger (menu, dialog, listbox) also carries aria-expanded, but what
|
|
50
|
+
// it opens is portaled away from the thread, so only disclosures count.
|
|
51
|
+
const isOpeningDisclosure = (target: Element): boolean => {
|
|
52
|
+
const control = target.closest(DISCLOSURE_SELECTOR);
|
|
53
|
+
if (!control) return false;
|
|
54
|
+
if (control.tagName === "SUMMARY")
|
|
55
|
+
return control.parentElement?.hasAttribute("open") === false;
|
|
56
|
+
return (
|
|
57
|
+
control.getAttribute("aria-expanded") === "false" &&
|
|
58
|
+
(control.getAttribute("aria-haspopup") ?? "false") === "false" &&
|
|
59
|
+
control.getAttribute("role") !== "combobox"
|
|
60
|
+
);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type MessageOffset = { readonly id: string; readonly offset: number };
|
|
64
|
+
|
|
65
|
+
const offsetInViewport = (element: Element, viewport: Element) =>
|
|
66
|
+
element.getBoundingClientRect().top - viewport.getBoundingClientRect().top;
|
|
67
|
+
|
|
68
|
+
const measureFirstMessage = (viewport: HTMLElement): MessageOffset | null => {
|
|
69
|
+
const element = viewport.querySelector<HTMLElement>(MESSAGE_SELECTOR);
|
|
70
|
+
const id = element?.dataset["messageId"];
|
|
71
|
+
return element && id
|
|
72
|
+
? { id, offset: offsetInViewport(element, viewport) }
|
|
73
|
+
: null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** The first message that starts in view, else the one spanning its top edge. */
|
|
77
|
+
const measureReaderMessage = (viewport: HTMLElement): MessageOffset | null => {
|
|
78
|
+
const { top, bottom } = viewport.getBoundingClientRect();
|
|
79
|
+
let spanning: MessageOffset | null = null;
|
|
80
|
+
for (const element of viewport.querySelectorAll<HTMLElement>(
|
|
81
|
+
MESSAGE_SELECTOR,
|
|
82
|
+
)) {
|
|
83
|
+
const rect = element.getBoundingClientRect();
|
|
84
|
+
if (rect.bottom <= top) continue;
|
|
85
|
+
if (rect.top >= bottom) break;
|
|
86
|
+
const id = element.dataset["messageId"];
|
|
87
|
+
if (!id) continue;
|
|
88
|
+
if (rect.top >= top) return { id, offset: rect.top - top };
|
|
89
|
+
spanning ??= { id, offset: rect.top - top };
|
|
90
|
+
}
|
|
91
|
+
return spanning;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const findMessage = (viewport: HTMLElement, id: string) => {
|
|
95
|
+
for (const element of viewport.querySelectorAll<HTMLElement>(
|
|
96
|
+
MESSAGE_SELECTOR,
|
|
97
|
+
)) {
|
|
98
|
+
if (element.dataset["messageId"] === id) return element;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Scrolls so the message sits at `anchor.offset` again, recording the
|
|
105
|
+
* position it scrolls to in `heldScrollTop` before the scroll event can
|
|
106
|
+
* report it; false when the message is gone.
|
|
107
|
+
*/
|
|
108
|
+
const keepMessageAt = (
|
|
109
|
+
viewport: HTMLElement,
|
|
110
|
+
anchor: MessageOffset,
|
|
111
|
+
heldScrollTop: { current: number },
|
|
112
|
+
) => {
|
|
113
|
+
const element = findMessage(viewport, anchor.id);
|
|
114
|
+
if (!element) return false;
|
|
115
|
+
const delta = offsetInViewport(element, viewport) - anchor.offset;
|
|
116
|
+
heldScrollTop.current = viewport.scrollTop + delta;
|
|
117
|
+
if (Math.abs(delta) >= 1) {
|
|
118
|
+
viewport.scrollTo({ top: heldScrollTop.current, behavior: "instant" });
|
|
119
|
+
}
|
|
120
|
+
heldScrollTop.current = viewport.scrollTop;
|
|
121
|
+
return true;
|
|
122
|
+
};
|
|
123
|
+
|
|
36
124
|
export namespace useThreadViewportAutoScroll {
|
|
37
125
|
export type Options = {
|
|
38
126
|
/**
|
|
@@ -72,8 +160,11 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
72
160
|
scrollToBottomOnInitialize = true,
|
|
73
161
|
scrollToBottomOnThreadSwitch = true,
|
|
74
162
|
}: useThreadViewportAutoScroll.Options): RefCallback<TElement> => {
|
|
163
|
+
const aui = useAui();
|
|
75
164
|
const divRef = useRef<TElement>(null);
|
|
76
165
|
const hasMessages = useAuiState((s) => s.thread.messages.length > 0);
|
|
166
|
+
const firstMessageId = useAuiState((s) => s.thread.messages[0]?.id);
|
|
167
|
+
const threadId = useAuiState((s) => s.threadListItem.id);
|
|
77
168
|
const isRunning = useAuiState((s) => s.thread.isRunning);
|
|
78
169
|
const initializeScrollRequestedRef = useRef(false);
|
|
79
170
|
const scheduledFrameRef = useRef<number | null>(null);
|
|
@@ -87,15 +178,28 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
87
178
|
const lastScrollHeight = useRef<number>(0);
|
|
88
179
|
const lastObservedScrollHeight = useRef<number>(0);
|
|
89
180
|
const lastObservedClientHeight = useRef<number>(0);
|
|
181
|
+
const firstMessageRef = useRef<MessageOffset | null>(null);
|
|
182
|
+
// The row the reader sees when earlier messages land, held until a gesture
|
|
183
|
+
// or a scroll this hook did not make, so content above it that settles
|
|
184
|
+
// afterwards (lazy layout, late media) does not move it on engines without
|
|
185
|
+
// native scroll anchoring.
|
|
186
|
+
const prependAnchorRef = useRef<MessageOffset | null>(null);
|
|
187
|
+
const heldScrollTopRef = useRef(0);
|
|
90
188
|
|
|
91
189
|
// Pending bottom-scroll intent. Planted by initialize/run-start/switch/button
|
|
92
190
|
// triggers, cleared when handleScroll confirms we reached bottom, or when the
|
|
93
191
|
// user actively scrolls up while content size is stable.
|
|
94
192
|
const scrollingToBottomBehaviorRef = useRef<ScrollBehavior | null>(null);
|
|
95
193
|
const followBottomRef = useRef(autoScroll);
|
|
194
|
+
// Set by expanding a disclosure; suspends follow without clearing its intent
|
|
195
|
+
// until a scroll gesture reaches an overflowing bottom (content that fits
|
|
196
|
+
// reads as at the bottom), a run starts, the thread changes, or the reader
|
|
197
|
+
// asks for the bottom again.
|
|
198
|
+
const followPausedRef = useRef(false);
|
|
199
|
+
const scrolledSincePauseRef = useRef(false);
|
|
96
200
|
const previousAutoScrollRef = useRef(autoScroll);
|
|
97
201
|
|
|
98
|
-
|
|
202
|
+
useIsomorphicLayoutEffect(() => {
|
|
99
203
|
const previousAutoScroll = previousAutoScrollRef.current;
|
|
100
204
|
previousAutoScrollRef.current = autoScroll;
|
|
101
205
|
if (previousAutoScroll || !autoScroll) return;
|
|
@@ -109,8 +213,12 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
109
213
|
if (!div) return;
|
|
110
214
|
|
|
111
215
|
followBottomRef.current = true;
|
|
216
|
+
followPausedRef.current = false;
|
|
112
217
|
scrollingToBottomBehaviorRef.current = behavior;
|
|
218
|
+
prependAnchorRef.current = null;
|
|
113
219
|
div.scrollTo({ top: div.scrollHeight, behavior });
|
|
220
|
+
lastScrollTop.current = div.scrollTop;
|
|
221
|
+
lastScrollHeight.current = div.scrollHeight;
|
|
114
222
|
}, []);
|
|
115
223
|
|
|
116
224
|
const cancelScheduledFrame = useCallback(() => {
|
|
@@ -131,7 +239,10 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
131
239
|
[cancelScheduledFrame, scrollToBottom],
|
|
132
240
|
);
|
|
133
241
|
|
|
134
|
-
|
|
242
|
+
useIsomorphicLayoutEffect(
|
|
243
|
+
() => () => cancelScheduledFrame(),
|
|
244
|
+
[cancelScheduledFrame],
|
|
245
|
+
);
|
|
135
246
|
|
|
136
247
|
const hasActiveTopAnchor = useCallback(() => {
|
|
137
248
|
const state = threadViewportStore.getState();
|
|
@@ -142,15 +253,24 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
142
253
|
);
|
|
143
254
|
}, [threadViewportStore]);
|
|
144
255
|
|
|
145
|
-
const
|
|
256
|
+
const onScroll = () => {
|
|
146
257
|
const div = divRef.current;
|
|
147
258
|
if (!div) return;
|
|
148
259
|
|
|
260
|
+
if (
|
|
261
|
+
prependAnchorRef.current &&
|
|
262
|
+
Math.abs(div.scrollTop - heldScrollTopRef.current) >= 1
|
|
263
|
+
) {
|
|
264
|
+
prependAnchorRef.current = null;
|
|
265
|
+
}
|
|
266
|
+
|
|
149
267
|
const isAtBottom = threadViewportStore.getState().isAtBottom;
|
|
150
268
|
const newIsAtBottom = isViewportAtBottom(div);
|
|
151
269
|
|
|
152
270
|
const isInFlightDownwardScroll =
|
|
153
|
-
|
|
271
|
+
scrollingToBottomBehaviorRef.current !== null &&
|
|
272
|
+
!newIsAtBottom &&
|
|
273
|
+
lastScrollTop.current < div.scrollTop;
|
|
154
274
|
if (isInFlightDownwardScroll) {
|
|
155
275
|
// no-op: a smooth scroll-to-bottom fires many midpoint scroll events
|
|
156
276
|
// before landing, don't flicker isAtBottom or clear intent mid-animation
|
|
@@ -168,6 +288,7 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
168
288
|
// keep intent alive until content can actually scroll
|
|
169
289
|
if (viewportOverflows(div)) {
|
|
170
290
|
scrollingToBottomBehaviorRef.current = null;
|
|
291
|
+
if (scrolledSincePauseRef.current) followPausedRef.current = false;
|
|
171
292
|
}
|
|
172
293
|
if (autoScroll) followBottomRef.current = true;
|
|
173
294
|
} else if (userScrolledUp) {
|
|
@@ -188,9 +309,15 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
188
309
|
|
|
189
310
|
lastScrollTop.current = div.scrollTop;
|
|
190
311
|
lastScrollHeight.current = div.scrollHeight;
|
|
312
|
+
firstMessageRef.current = measureFirstMessage(div);
|
|
191
313
|
};
|
|
314
|
+
const onScrollRef = useRef(onScroll);
|
|
315
|
+
useIsomorphicLayoutEffect(() => {
|
|
316
|
+
onScrollRef.current = onScroll;
|
|
317
|
+
});
|
|
318
|
+
const handleScroll = useCallback(() => onScrollRef.current(), []);
|
|
192
319
|
|
|
193
|
-
const
|
|
320
|
+
const onResize = () => {
|
|
194
321
|
const div = divRef.current;
|
|
195
322
|
if (!div) return;
|
|
196
323
|
|
|
@@ -204,55 +331,199 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
204
331
|
lastObservedScrollHeight.current = scrollHeight;
|
|
205
332
|
lastObservedClientHeight.current = clientHeight;
|
|
206
333
|
|
|
207
|
-
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
334
|
+
// A scheduled frame owns the pending behavior; resize callbacks must not apply it early.
|
|
335
|
+
if (scheduledFrameRef.current === null) {
|
|
336
|
+
const scrollBehavior = scrollingToBottomBehaviorRef.current;
|
|
337
|
+
if (scrollBehavior && hasActiveTopAnchor()) {
|
|
338
|
+
// Let the top-anchor reserve own scrolling while a run starts to avoid a bottom-scroll race.
|
|
339
|
+
scrollingToBottomBehaviorRef.current = null;
|
|
340
|
+
} else if (scrollBehavior) {
|
|
341
|
+
scrollToBottom(scrollBehavior);
|
|
342
|
+
} else if (
|
|
343
|
+
autoScroll &&
|
|
344
|
+
!(isRunning && hasActiveTopAnchor()) &&
|
|
345
|
+
followBottomRef.current &&
|
|
346
|
+
!followPausedRef.current
|
|
347
|
+
) {
|
|
348
|
+
scrollToBottom("instant");
|
|
349
|
+
} else if (
|
|
350
|
+
prependAnchorRef.current &&
|
|
351
|
+
!keepMessageAt(div, prependAnchorRef.current, heldScrollTopRef)
|
|
352
|
+
) {
|
|
353
|
+
prependAnchorRef.current = null;
|
|
354
|
+
}
|
|
219
355
|
}
|
|
220
356
|
|
|
221
357
|
handleScroll();
|
|
358
|
+
};
|
|
359
|
+
const onResizeRef = useRef(onResize);
|
|
360
|
+
useIsomorphicLayoutEffect(() => {
|
|
361
|
+
onResizeRef.current = onResize;
|
|
222
362
|
});
|
|
363
|
+
const resizeRef = useOnResizeContent(() => onResizeRef.current());
|
|
364
|
+
|
|
365
|
+
const scrollRef = useManagedRef<HTMLElement>(
|
|
366
|
+
useCallback(
|
|
367
|
+
(el) => {
|
|
368
|
+
// A user gesture invalidates pending bottom-scroll intent; otherwise an
|
|
369
|
+
// intent kept alive by a non-overflowing thread (see handleScroll) hijacks
|
|
370
|
+
// the next content growth, e.g. expanding a collapsible tool call. Keyboard
|
|
371
|
+
// activation reaches that same content without ever emitting a pointer
|
|
372
|
+
// event, so it has to cancel the intent too.
|
|
373
|
+
const cancelPendingScrollToBottom = () => {
|
|
374
|
+
// With nothing pending, the last scroll event already published isAtBottom.
|
|
375
|
+
if (
|
|
376
|
+
scrollingToBottomBehaviorRef.current === null &&
|
|
377
|
+
scheduledFrameRef.current === null
|
|
378
|
+
)
|
|
379
|
+
return;
|
|
380
|
+
// A scheduled frame re-plants the intent when it runs, so clearing the
|
|
381
|
+
// ref alone leaves the gesture undone.
|
|
382
|
+
cancelScheduledFrame();
|
|
383
|
+
scrollingToBottomBehaviorRef.current = null;
|
|
384
|
+
handleScroll();
|
|
385
|
+
};
|
|
386
|
+
// The composer renders inside the viewport, so its keystrokes bubble here;
|
|
387
|
+
// cancellation keys only represent a gesture on thread content when they
|
|
388
|
+
// originate outside text entry.
|
|
389
|
+
const cancelOnKeyDown = (event: KeyboardEvent) => {
|
|
390
|
+
if (!INTENT_CANCEL_KEYS.has(event.key)) return;
|
|
391
|
+
const target = event.target as Element | null;
|
|
392
|
+
if (target?.closest?.(TEXT_ENTRY_SELECTOR)) return;
|
|
393
|
+
cancelPendingScrollToBottom();
|
|
394
|
+
};
|
|
395
|
+
// Expanding a disclosure inside a message, such as a reasoning block or a
|
|
396
|
+
// tool call card, means the reader is inspecting it, so bottom follow
|
|
397
|
+
// pauses until the reader scrolls or asks for the bottom again. Every
|
|
398
|
+
// other interaction, including copying or selecting text, keeps
|
|
399
|
+
// following. Activation is read from click, which keyboard activation also
|
|
400
|
+
// dispatches, in the capture phase so the state is read before any handler
|
|
401
|
+
// on the disclosure flips it or stops the event.
|
|
402
|
+
const pauseFollowOnExpand = (event: MouseEvent) => {
|
|
403
|
+
const target = event.target as Element | null;
|
|
404
|
+
if (
|
|
405
|
+
!target?.closest?.(MESSAGE_SELECTOR) ||
|
|
406
|
+
target.closest(TEXT_ENTRY_SELECTOR) ||
|
|
407
|
+
!isOpeningDisclosure(target)
|
|
408
|
+
)
|
|
409
|
+
return;
|
|
410
|
+
followPausedRef.current = true;
|
|
411
|
+
scrolledSincePauseRef.current = false;
|
|
412
|
+
cancelPendingScrollToBottom();
|
|
413
|
+
};
|
|
414
|
+
const noteScrollGesture = (event: Event) => {
|
|
415
|
+
if (event instanceof KeyboardEvent) {
|
|
416
|
+
if (!SCROLL_KEYS.has(event.key)) return;
|
|
417
|
+
if (
|
|
418
|
+
(event.target as Element | null)?.closest?.(TEXT_ENTRY_SELECTOR)
|
|
419
|
+
)
|
|
420
|
+
return;
|
|
421
|
+
} else if (event.type === "pointerdown" && event.target !== el) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
scrolledSincePauseRef.current = true;
|
|
425
|
+
};
|
|
426
|
+
const releasePrependAnchor = () => {
|
|
427
|
+
prependAnchorRef.current = null;
|
|
428
|
+
};
|
|
429
|
+
const gestures = [
|
|
430
|
+
"pointerdown",
|
|
431
|
+
"wheel",
|
|
432
|
+
"touchstart",
|
|
433
|
+
"keydown",
|
|
434
|
+
] as const;
|
|
435
|
+
const resumeGestures = [
|
|
436
|
+
"pointerdown",
|
|
437
|
+
"wheel",
|
|
438
|
+
"touchmove",
|
|
439
|
+
"keydown",
|
|
440
|
+
] as const;
|
|
441
|
+
el.addEventListener("scroll", handleScroll);
|
|
442
|
+
el.addEventListener("pointerdown", cancelPendingScrollToBottom);
|
|
443
|
+
el.addEventListener("wheel", cancelPendingScrollToBottom, {
|
|
444
|
+
passive: true,
|
|
445
|
+
});
|
|
446
|
+
el.addEventListener("touchstart", cancelPendingScrollToBottom, {
|
|
447
|
+
passive: true,
|
|
448
|
+
});
|
|
449
|
+
el.addEventListener("keydown", cancelOnKeyDown);
|
|
450
|
+
el.addEventListener("click", pauseFollowOnExpand, { capture: true });
|
|
451
|
+
for (const gesture of resumeGestures) {
|
|
452
|
+
el.addEventListener(gesture, noteScrollGesture, { passive: true });
|
|
453
|
+
}
|
|
454
|
+
for (const gesture of gestures) {
|
|
455
|
+
el.addEventListener(gesture, releasePrependAnchor, { passive: true });
|
|
456
|
+
}
|
|
457
|
+
return () => {
|
|
458
|
+
el.removeEventListener("scroll", handleScroll);
|
|
459
|
+
el.removeEventListener("pointerdown", cancelPendingScrollToBottom);
|
|
460
|
+
el.removeEventListener("wheel", cancelPendingScrollToBottom);
|
|
461
|
+
el.removeEventListener("touchstart", cancelPendingScrollToBottom);
|
|
462
|
+
el.removeEventListener("keydown", cancelOnKeyDown);
|
|
463
|
+
el.removeEventListener("click", pauseFollowOnExpand, {
|
|
464
|
+
capture: true,
|
|
465
|
+
});
|
|
466
|
+
for (const gesture of resumeGestures) {
|
|
467
|
+
el.removeEventListener(gesture, noteScrollGesture);
|
|
468
|
+
}
|
|
469
|
+
for (const gesture of gestures) {
|
|
470
|
+
el.removeEventListener(gesture, releasePrependAnchor);
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
},
|
|
474
|
+
[cancelScheduledFrame, handleScroll],
|
|
475
|
+
),
|
|
476
|
+
);
|
|
223
477
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
478
|
+
// Earlier messages prepended above the reader put the old first message back
|
|
479
|
+
// where the reader saw it, so growth below it in the same commit (a
|
|
480
|
+
// streaming reply) is not mistaken for the page; where the browser already
|
|
481
|
+
// anchored the scroll, this is a no-op.
|
|
482
|
+
const previousRef = useRef({ threadId, firstMessageId });
|
|
483
|
+
useIsomorphicLayoutEffect(() => {
|
|
484
|
+
const previous = previousRef.current;
|
|
485
|
+
previousRef.current = { threadId, firstMessageId };
|
|
486
|
+
if (previous.threadId !== threadId) {
|
|
487
|
+
prependAnchorRef.current = null;
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const div = divRef.current;
|
|
491
|
+
const previousFirstMessageId = previous.firstMessageId;
|
|
492
|
+
if (
|
|
493
|
+
!div ||
|
|
494
|
+
previousFirstMessageId === undefined ||
|
|
495
|
+
previousFirstMessageId === firstMessageId ||
|
|
496
|
+
(autoScroll && followBottomRef.current && !followPausedRef.current) ||
|
|
497
|
+
scrollingToBottomBehaviorRef.current !== null
|
|
498
|
+
)
|
|
499
|
+
return;
|
|
500
|
+
const prepended = aui.thread
|
|
501
|
+
.getState()
|
|
502
|
+
.messages.some(
|
|
503
|
+
(message, index) => index > 0 && message.id === previousFirstMessageId,
|
|
504
|
+
);
|
|
505
|
+
if (!prepended) return;
|
|
506
|
+
|
|
507
|
+
const anchor = firstMessageRef.current;
|
|
508
|
+
if (
|
|
509
|
+
anchor?.id === previousFirstMessageId &&
|
|
510
|
+
keepMessageAt(div, anchor, heldScrollTopRef)
|
|
511
|
+
) {
|
|
512
|
+
prependAnchorRef.current = measureReaderMessage(div) ?? anchor;
|
|
513
|
+
} else {
|
|
514
|
+
// Message roots that render no `data-message-id` leave only the total
|
|
515
|
+
// growth to go on, which counts any growth below the reader too.
|
|
516
|
+
const top =
|
|
517
|
+
div.scrollHeight - (lastScrollHeight.current - lastScrollTop.current);
|
|
518
|
+
if (Math.abs(div.scrollTop - top) >= 1) {
|
|
519
|
+
div.scrollTo({ top, behavior: "instant" });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
lastScrollTop.current = div.scrollTop;
|
|
523
|
+
lastScrollHeight.current = div.scrollHeight;
|
|
524
|
+
}, [aui, autoScroll, firstMessageId, threadId]);
|
|
254
525
|
|
|
255
|
-
|
|
526
|
+
useIsomorphicLayoutEffect(() => {
|
|
256
527
|
if (!scrollToBottomOnInitialize) return;
|
|
257
528
|
if (!hasMessages) {
|
|
258
529
|
initializeScrollRequestedRef.current = false;
|
|
@@ -272,12 +543,15 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
272
543
|
});
|
|
273
544
|
|
|
274
545
|
useAuiEvent("thread.runStart", () => {
|
|
546
|
+
prependAnchorRef.current = null;
|
|
547
|
+
followPausedRef.current = false;
|
|
275
548
|
if (!scrollToBottomOnRunStart) return;
|
|
276
549
|
if (threadViewportStore.getState().turnAnchor === "top") return;
|
|
277
550
|
scheduleScrollToBottom("auto");
|
|
278
551
|
});
|
|
279
552
|
|
|
280
553
|
useAuiEvent("threads.selectionChanged", () => {
|
|
554
|
+
followPausedRef.current = false;
|
|
281
555
|
if (!scrollToBottomOnThreadSwitch) return;
|
|
282
556
|
scheduleScrollToBottom("instant");
|
|
283
557
|
});
|
package/src/primitives/thread.ts
CHANGED
|
@@ -7,7 +7,9 @@ export { ThreadPrimitiveViewportFooter as ViewportFooter } from "./thread/Thread
|
|
|
7
7
|
export { ThreadPrimitiveMessages as Messages } from "./thread/ThreadMessages";
|
|
8
8
|
export { ThreadPrimitiveMessageByIndex as MessageByIndex } from "./thread/ThreadMessages";
|
|
9
9
|
export { ThreadPrimitiveUnstable_MessageById as Unstable_MessageById } from "./thread/ThreadMessages";
|
|
10
|
+
export { ThreadPrimitiveRow as Row } from "./thread/ThreadMessages";
|
|
10
11
|
export { ThreadPrimitiveScrollToBottom as ScrollToBottom } from "./thread/ThreadScrollToBottom";
|
|
12
|
+
export { ThreadPrimitiveLoadEarlier as LoadEarlier } from "./thread/ThreadLoadEarlier";
|
|
11
13
|
export { ThreadPrimitiveSuggestion as Suggestion } from "./thread/ThreadSuggestion";
|
|
12
14
|
export {
|
|
13
15
|
ThreadPrimitiveSuggestions as Suggestions,
|
|
@@ -416,6 +416,40 @@ describe("SandboxHost", () => {
|
|
|
416
416
|
expect(onError).not.toHaveBeenCalled();
|
|
417
417
|
});
|
|
418
418
|
|
|
419
|
+
it("cancels pending renders on replacement and unmount", async () => {
|
|
420
|
+
renderHtmlMock.mockImplementation(() => new Promise(() => {}));
|
|
421
|
+
|
|
422
|
+
await act(async () => {
|
|
423
|
+
root.render(
|
|
424
|
+
<SandboxHost
|
|
425
|
+
content={{ html: "first" }}
|
|
426
|
+
contentKey="first"
|
|
427
|
+
createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
|
|
428
|
+
/>,
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
const firstSignal = renderHtmlMock.mock.calls[0]![2].signal as AbortSignal;
|
|
432
|
+
|
|
433
|
+
await act(async () => {
|
|
434
|
+
root.render(
|
|
435
|
+
<SandboxHost
|
|
436
|
+
content={{ html: "second" }}
|
|
437
|
+
contentKey="second"
|
|
438
|
+
createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
|
|
439
|
+
/>,
|
|
440
|
+
);
|
|
441
|
+
});
|
|
442
|
+
const secondSignal = renderHtmlMock.mock.calls[1]![2].signal as AbortSignal;
|
|
443
|
+
|
|
444
|
+
expect(firstSignal.aborted).toBe(true);
|
|
445
|
+
expect(secondSignal.aborted).toBe(false);
|
|
446
|
+
|
|
447
|
+
await act(async () => {
|
|
448
|
+
root.unmount();
|
|
449
|
+
});
|
|
450
|
+
expect(secondSignal.aborted).toBe(true);
|
|
451
|
+
});
|
|
452
|
+
|
|
419
453
|
it("contains failures thrown by onError", async () => {
|
|
420
454
|
const renderError = new Error("render failed");
|
|
421
455
|
const callbackError = new Error("error callback failed");
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
useEffect,
|
|
6
|
-
useLayoutEffect,
|
|
7
|
-
useRef,
|
|
8
|
-
useState,
|
|
9
|
-
} from "react";
|
|
3
|
+
import { type CSSProperties, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { useIsomorphicLayoutEffect } from "../utils/useIsomorphicLayoutEffect";
|
|
10
5
|
import {
|
|
11
6
|
isShimLoadError,
|
|
12
7
|
type RenderedFrame,
|
|
@@ -95,7 +90,7 @@ export function SandboxHost({
|
|
|
95
90
|
createBridge,
|
|
96
91
|
onError,
|
|
97
92
|
});
|
|
98
|
-
|
|
93
|
+
useIsomorphicLayoutEffect(() => {
|
|
99
94
|
liveRef.current = { content, sandbox, createBridge, onError };
|
|
100
95
|
}, [content, sandbox, createBridge, onError]);
|
|
101
96
|
|
|
@@ -107,6 +102,7 @@ export function SandboxHost({
|
|
|
107
102
|
let frame: RenderedFrame | null = null;
|
|
108
103
|
let bridge: SandboxBridge | null = null;
|
|
109
104
|
let onMessage: ((event: MessageEvent) => void) | null = null;
|
|
105
|
+
const renderController = new AbortController();
|
|
110
106
|
|
|
111
107
|
const { content: liveContent, sandbox: sb } = liveRef.current;
|
|
112
108
|
|
|
@@ -129,10 +125,12 @@ export function SandboxHost({
|
|
|
129
125
|
...(sb?.salt !== undefined && { salt: sb.salt }),
|
|
130
126
|
});
|
|
131
127
|
|
|
132
|
-
const renderOpts =
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
:
|
|
128
|
+
const renderOpts = {
|
|
129
|
+
signal: renderController.signal,
|
|
130
|
+
...(sb?.unsafeDocumentWrite !== undefined && {
|
|
131
|
+
unsafeDocumentWrite: sb.unsafeDocumentWrite,
|
|
132
|
+
}),
|
|
133
|
+
};
|
|
136
134
|
|
|
137
135
|
scf
|
|
138
136
|
.renderHtml(liveContent.html, container, renderOpts)
|
|
@@ -216,6 +214,7 @@ export function SandboxHost({
|
|
|
216
214
|
const bridgeToDispose = bridge;
|
|
217
215
|
bridge = null;
|
|
218
216
|
if (bridgeToDispose) runCleanup(() => bridgeToDispose.dispose());
|
|
217
|
+
runCleanup(() => renderController.abort());
|
|
219
218
|
const frameToDispose = frame;
|
|
220
219
|
frame = null;
|
|
221
220
|
if (frameToDispose) runCleanup(() => frameToDispose.dispose());
|
|
@@ -47,8 +47,15 @@ const ElapsedProbe: FC = () => {
|
|
|
47
47
|
};
|
|
48
48
|
|
|
49
49
|
const StallProbe: FC = () => {
|
|
50
|
-
const { stalled } = unstable_useMessageStallDetection({
|
|
51
|
-
|
|
50
|
+
const { stalled, stalledForMs } = unstable_useMessageStallDetection({
|
|
51
|
+
thresholdMs: 2000,
|
|
52
|
+
});
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
<span data-testid="stalled">{String(stalled)}</span>
|
|
56
|
+
<span data-testid="stalled-for">{stalledForMs}</span>
|
|
57
|
+
</>
|
|
58
|
+
);
|
|
52
59
|
};
|
|
53
60
|
|
|
54
61
|
const RuntimeProvider: FC<
|
|
@@ -224,6 +231,16 @@ describe("unstable_useMessageStallDetection", () => {
|
|
|
224
231
|
});
|
|
225
232
|
|
|
226
233
|
expect(screen.getByTestId("stalled").textContent).toBe("true");
|
|
234
|
+
const stalledFor = () =>
|
|
235
|
+
Number(screen.getByTestId("stalled-for").textContent);
|
|
236
|
+
const atStall = stalledFor();
|
|
237
|
+
expect(atStall).toBeGreaterThanOrEqual(2000);
|
|
238
|
+
|
|
239
|
+
await act(async () => {
|
|
240
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(stalledFor()).toBeGreaterThan(atStall);
|
|
227
244
|
});
|
|
228
245
|
|
|
229
246
|
it("never stalls on settled messages", async () => {
|