@stigmer/react 3.6.0 → 3.8.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.
- package/billing/CreditLedgerTable.d.ts.map +1 -1
- package/billing/CreditLedgerTable.js +2 -7
- package/billing/CreditLedgerTable.js.map +1 -1
- package/channel/AgentChannelsPanel.d.ts.map +1 -1
- package/channel/AgentChannelsPanel.js +21 -2
- package/channel/AgentChannelsPanel.js.map +1 -1
- package/channel/ChannelConversationsDialog.d.ts +12 -4
- package/channel/ChannelConversationsDialog.d.ts.map +1 -1
- package/channel/ChannelConversationsDialog.js +18 -24
- package/channel/ChannelConversationsDialog.js.map +1 -1
- package/channel/providers.d.ts +23 -0
- package/channel/providers.d.ts.map +1 -1
- package/channel/providers.js +4 -0
- package/channel/providers.js.map +1 -1
- package/channel/useSaveAgentChannel.d.ts +5 -4
- package/channel/useSaveAgentChannel.d.ts.map +1 -1
- package/channel/useSaveAgentChannel.js +18 -4
- package/channel/useSaveAgentChannel.js.map +1 -1
- package/composer/ComposerToolbar.d.ts +6 -1
- package/composer/ComposerToolbar.d.ts.map +1 -1
- package/composer/ComposerToolbar.js +2 -2
- package/composer/ComposerToolbar.js.map +1 -1
- package/composer/SessionComposer.d.ts +9 -0
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +13 -2
- package/composer/SessionComposer.js.map +1 -1
- package/conversation/ConversationAttentionBanner.d.ts +22 -0
- package/conversation/ConversationAttentionBanner.d.ts.map +1 -0
- package/conversation/ConversationAttentionBanner.js +25 -0
- package/conversation/ConversationAttentionBanner.js.map +1 -0
- package/conversation/ConversationComposer.d.ts +41 -0
- package/conversation/ConversationComposer.d.ts.map +1 -0
- package/conversation/ConversationComposer.js +57 -0
- package/conversation/ConversationComposer.js.map +1 -0
- package/conversation/ConversationControlBanner.d.ts +52 -0
- package/conversation/ConversationControlBanner.d.ts.map +1 -0
- package/conversation/ConversationControlBanner.js +59 -0
- package/conversation/ConversationControlBanner.js.map +1 -0
- package/conversation/ConversationListPane.d.ts +68 -0
- package/conversation/ConversationListPane.d.ts.map +1 -0
- package/conversation/ConversationListPane.js +120 -0
- package/conversation/ConversationListPane.js.map +1 -0
- package/conversation/ConversationTimelineView.d.ts +46 -0
- package/conversation/ConversationTimelineView.d.ts.map +1 -0
- package/conversation/ConversationTimelineView.js +166 -0
- package/conversation/ConversationTimelineView.js.map +1 -0
- package/conversation/ConversationsWorkbench.d.ts +63 -0
- package/conversation/ConversationsWorkbench.d.ts.map +1 -0
- package/conversation/ConversationsWorkbench.js +165 -0
- package/conversation/ConversationsWorkbench.js.map +1 -0
- package/conversation/conversationPresentation.d.ts +137 -0
- package/conversation/conversationPresentation.d.ts.map +1 -0
- package/conversation/conversationPresentation.js +230 -0
- package/conversation/conversationPresentation.js.map +1 -0
- package/conversation/index.d.ts +26 -0
- package/conversation/index.d.ts.map +1 -0
- package/conversation/index.js +14 -0
- package/conversation/index.js.map +1 -0
- package/conversation/polling.d.ts +39 -0
- package/conversation/polling.d.ts.map +1 -0
- package/conversation/polling.js +39 -0
- package/conversation/polling.js.map +1 -0
- package/conversation/useConversation.d.ts +61 -0
- package/conversation/useConversation.d.ts.map +1 -0
- package/conversation/useConversation.js +153 -0
- package/conversation/useConversation.js.map +1 -0
- package/conversation/useConversationList.d.ts +98 -0
- package/conversation/useConversationList.d.ts.map +1 -0
- package/conversation/useConversationList.js +200 -0
- package/conversation/useConversationList.js.map +1 -0
- package/conversation/useConversationParticipation.d.ts +63 -0
- package/conversation/useConversationParticipation.d.ts.map +1 -0
- package/conversation/useConversationParticipation.js +84 -0
- package/conversation/useConversationParticipation.js.map +1 -0
- package/conversation/useConversationTimeline.d.ts +71 -0
- package/conversation/useConversationTimeline.d.ts.map +1 -0
- package/conversation/useConversationTimeline.js +152 -0
- package/conversation/useConversationTimeline.js.map +1 -0
- package/conversation/useConversationsWantsHumanCount.d.ts +42 -0
- package/conversation/useConversationsWantsHumanCount.d.ts.map +1 -0
- package/conversation/useConversationsWantsHumanCount.js +40 -0
- package/conversation/useConversationsWantsHumanCount.js.map +1 -0
- package/execution/useCreateAgentExecution.d.ts +14 -0
- package/execution/useCreateAgentExecution.d.ts.map +1 -1
- package/execution/useCreateAgentExecution.js +5 -0
- package/execution/useCreateAgentExecution.js.map +1 -1
- package/index.d.ts +6 -2
- package/index.d.ts.map +1 -1
- package/index.js +14 -5
- package/index.js.map +1 -1
- package/internal/Pagination.d.ts +15 -0
- package/internal/Pagination.d.ts.map +1 -0
- package/internal/Pagination.js +20 -0
- package/internal/Pagination.js.map +1 -0
- package/internal/tooltip.d.ts.map +1 -1
- package/internal/tooltip.js +7 -1
- package/internal/tooltip.js.map +1 -1
- package/internal/useAutoScroll.d.ts +25 -4
- package/internal/useAutoScroll.d.ts.map +1 -1
- package/internal/useAutoScroll.js +55 -15
- package/internal/useAutoScroll.js.map +1 -1
- package/internal/useFetch.d.ts.map +1 -1
- package/internal/useFetch.js +14 -4
- package/internal/useFetch.js.map +1 -1
- package/models/ModelSelector.d.ts +45 -1
- package/models/ModelSelector.d.ts.map +1 -1
- package/models/ModelSelector.js +49 -6
- package/models/ModelSelector.js.map +1 -1
- package/models/index.d.ts +2 -0
- package/models/index.d.ts.map +1 -1
- package/models/index.js +1 -0
- package/models/index.js.map +1 -1
- package/models/registry.d.ts +11 -0
- package/models/registry.d.ts.map +1 -1
- package/models/registry.js +3 -0
- package/models/registry.js.map +1 -1
- package/models/service-tier.d.ts +27 -0
- package/models/service-tier.d.ts.map +1 -0
- package/models/service-tier.js +34 -0
- package/models/service-tier.js.map +1 -0
- package/package.json +4 -4
- package/schedule/ScheduleDetailView.d.ts +31 -8
- package/schedule/ScheduleDetailView.d.ts.map +1 -1
- package/schedule/ScheduleDetailView.js +441 -29
- package/schedule/ScheduleDetailView.js.map +1 -1
- package/schedule/ScheduleForm.d.ts +7 -4
- package/schedule/ScheduleForm.d.ts.map +1 -1
- package/schedule/ScheduleForm.js +94 -8
- package/schedule/ScheduleForm.js.map +1 -1
- package/schedule/ScheduleRunsTable.d.ts +72 -0
- package/schedule/ScheduleRunsTable.d.ts.map +1 -0
- package/schedule/ScheduleRunsTable.js +161 -0
- package/schedule/ScheduleRunsTable.js.map +1 -0
- package/schedule/index.d.ts +6 -0
- package/schedule/index.d.ts.map +1 -1
- package/schedule/index.js +3 -0
- package/schedule/index.js.map +1 -1
- package/schedule/useScheduleRuns.d.ts +44 -0
- package/schedule/useScheduleRuns.d.ts.map +1 -0
- package/schedule/useScheduleRuns.js +48 -0
- package/schedule/useScheduleRuns.js.map +1 -0
- package/schedule/useTriggerSchedule.d.ts +23 -10
- package/schedule/useTriggerSchedule.d.ts.map +1 -1
- package/schedule/useTriggerSchedule.js +28 -9
- package/schedule/useTriggerSchedule.js.map +1 -1
- package/schedule/useUpdateScheduleSpec.d.ts +49 -0
- package/schedule/useUpdateScheduleSpec.d.ts.map +1 -0
- package/schedule/useUpdateScheduleSpec.js +70 -0
- package/schedule/useUpdateScheduleSpec.js.map +1 -0
- package/session/useNewSessionFlow.d.ts.map +1 -1
- package/session/useNewSessionFlow.js +1 -0
- package/session/useNewSessionFlow.js.map +1 -1
- package/session/useSessionConversation.d.ts +11 -0
- package/session/useSessionConversation.d.ts.map +1 -1
- package/session/useSessionConversation.js +1 -0
- package/session/useSessionConversation.js.map +1 -1
- package/session/useSessionPageFlow.d.ts.map +1 -1
- package/session/useSessionPageFlow.js +1 -0
- package/session/useSessionPageFlow.js.map +1 -1
- package/sharing/useSaveAgentShare.d.ts +10 -1
- package/sharing/useSaveAgentShare.d.ts.map +1 -1
- package/sharing/useSaveAgentShare.js +9 -0
- package/sharing/useSaveAgentShare.js.map +1 -1
- package/sidebar/WorkspaceSidebar.d.ts +12 -4
- package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
- package/sidebar/WorkspaceSidebar.js +19 -7
- package/sidebar/WorkspaceSidebar.js.map +1 -1
- package/src/billing/CreditLedgerTable.tsx +3 -42
- package/src/channel/AgentChannelsPanel.tsx +33 -2
- package/src/channel/ChannelConversationsDialog.tsx +22 -25
- package/src/channel/__tests__/AgentChannelsPanel.test.tsx +36 -5
- package/src/channel/__tests__/ChannelConversationsDialog.test.tsx +38 -1
- package/src/channel/__tests__/useSaveAgentChannel.test.tsx +26 -0
- package/src/channel/providers.ts +27 -0
- package/src/channel/useSaveAgentChannel.ts +18 -4
- package/src/composer/ComposerToolbar.tsx +9 -0
- package/src/composer/SessionComposer.tsx +24 -1
- package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +157 -0
- package/src/conversation/ConversationAttentionBanner.tsx +77 -0
- package/src/conversation/ConversationComposer.tsx +156 -0
- package/src/conversation/ConversationControlBanner.tsx +193 -0
- package/src/conversation/ConversationListPane.tsx +459 -0
- package/src/conversation/ConversationTimelineView.tsx +435 -0
- package/src/conversation/ConversationsWorkbench.tsx +378 -0
- package/src/conversation/__tests__/ConversationAttentionBanner.test.tsx +82 -0
- package/src/conversation/__tests__/ConversationComposer.test.tsx +160 -0
- package/src/conversation/__tests__/ConversationControlBanner.test.tsx +190 -0
- package/src/conversation/__tests__/ConversationListPane.test.tsx +372 -0
- package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +160 -0
- package/src/conversation/__tests__/ConversationTimelineView.test.tsx +405 -0
- package/src/conversation/__tests__/ConversationsWorkbench.test.tsx +860 -0
- package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +235 -0
- package/src/conversation/__tests__/a11y/harness.tsx +100 -0
- package/src/conversation/__tests__/conversation-tooltips.layout.test.tsx +167 -0
- package/src/conversation/__tests__/conversationPollingComposition.test.tsx +138 -0
- package/src/conversation/__tests__/conversationPresentation.test.ts +289 -0
- package/src/conversation/__tests__/useConversation.test.tsx +243 -0
- package/src/conversation/__tests__/useConversationList.test.tsx +492 -0
- package/src/conversation/__tests__/useConversationParticipation.test.tsx +190 -0
- package/src/conversation/__tests__/useConversationTimeline.test.tsx +258 -0
- package/src/conversation/__tests__/useConversationsWantsHumanCount.test.tsx +102 -0
- package/src/conversation/conversationPresentation.ts +305 -0
- package/src/conversation/index.ts +80 -0
- package/src/conversation/polling.ts +41 -0
- package/src/conversation/useConversation.ts +221 -0
- package/src/conversation/useConversationList.ts +328 -0
- package/src/conversation/useConversationParticipation.ts +178 -0
- package/src/conversation/useConversationTimeline.ts +244 -0
- package/src/conversation/useConversationsWantsHumanCount.ts +86 -0
- package/src/execution/useCreateAgentExecution.ts +18 -0
- package/src/index.ts +76 -4
- package/src/internal/Pagination.tsx +72 -0
- package/src/internal/__tests__/fake-timer-slices.ts +29 -0
- package/src/internal/__tests__/useAutoScroll.layout.test.tsx +143 -0
- package/src/internal/__tests__/useAutoScroll.test.tsx +62 -5
- package/src/internal/__tests__/useFetch-poll-under-render-pressure.test.tsx +79 -0
- package/src/internal/tooltip.tsx +6 -1
- package/src/internal/useAutoScroll.ts +66 -20
- package/src/internal/useFetch.ts +15 -4
- package/src/models/ModelSelector.tsx +133 -4
- package/src/models/__tests__/ModelSelector-serviceTier.test.tsx +256 -0
- package/src/models/__tests__/useModelRegistry.test.tsx +15 -0
- package/src/models/index.ts +2 -0
- package/src/models/registry.ts +17 -0
- package/src/models/service-tier.ts +49 -0
- package/src/schedule/ScheduleDetailView.tsx +1179 -111
- package/src/schedule/ScheduleForm.tsx +218 -7
- package/src/schedule/ScheduleRunsTable.tsx +424 -0
- package/src/schedule/__tests__/ScheduleDetailView.test.tsx +431 -8
- package/src/schedule/__tests__/scheduleCreation.test.tsx +197 -0
- package/src/schedule/__tests__/scheduleHooks.test.tsx +94 -7
- package/src/schedule/index.ts +12 -0
- package/src/schedule/useScheduleRuns.ts +93 -0
- package/src/schedule/useTriggerSchedule.ts +39 -14
- package/src/schedule/useUpdateScheduleSpec.ts +108 -0
- package/src/session/useNewSessionFlow.ts +1 -0
- package/src/session/useSessionConversation.ts +12 -0
- package/src/session/useSessionPageFlow.ts +1 -0
- package/src/sharing/__tests__/useSaveAgentShare.test.tsx +55 -1
- package/src/sharing/__tests__/useShareToolReadiness.test.tsx +1 -0
- package/src/sharing/useSaveAgentShare.ts +19 -1
- package/src/sidebar/WorkspaceSidebar.tsx +62 -3
- package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +56 -1
- package/src/workflow/__tests__/inspector-forms.test.tsx +119 -2
- package/src/workflow/inspector/forms/AgentCallForm.tsx +260 -37
- package/src/workflow/inspector/tabs/RuntimeTab.tsx +72 -33
- package/src/workflow/starter-workflow-yaml.ts +2 -1
- package/styles.css +1 -1
- package/workflow/inspector/forms/AgentCallForm.d.ts +18 -2
- package/workflow/inspector/forms/AgentCallForm.d.ts.map +1 -1
- package/workflow/inspector/forms/AgentCallForm.js +87 -9
- package/workflow/inspector/forms/AgentCallForm.js.map +1 -1
- package/workflow/inspector/tabs/RuntimeTab.d.ts +2 -1
- package/workflow/inspector/tabs/RuntimeTab.d.ts.map +1 -1
- package/workflow/inspector/tabs/RuntimeTab.js +31 -5
- package/workflow/inspector/tabs/RuntimeTab.js.map +1 -1
- package/workflow/starter-workflow-yaml.d.ts +1 -1
- package/workflow/starter-workflow-yaml.d.ts.map +1 -1
- package/workflow/starter-workflow-yaml.js +2 -1
- package/workflow/starter-workflow-yaml.js.map +1 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared prev/next pagination control for paginated SDK tables.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from `CreditLedgerTable` (its original, file-private home)
|
|
7
|
+
* when `ScheduleRunsTable` became the second consumer. Deliberately
|
|
8
|
+
* minimal — Previous / "Page N of M" / Next — because every paginated
|
|
9
|
+
* surface in the SDK so far wants exactly this shape; page-number
|
|
10
|
+
* jumping earns its complexity only when a consumer actually needs it.
|
|
11
|
+
*
|
|
12
|
+
* Internal (not barrel-exported): the public contract is each table
|
|
13
|
+
* component, not this control.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { cn } from "@stigmer/theme";
|
|
17
|
+
|
|
18
|
+
/** Props for {@link Pagination}. */
|
|
19
|
+
export interface PaginationProps {
|
|
20
|
+
/** Current page number, 1-indexed. */
|
|
21
|
+
readonly pageNum: number;
|
|
22
|
+
/** Total number of pages. Render the control only when > 1. */
|
|
23
|
+
readonly totalPages: number;
|
|
24
|
+
/** Called with the new page number when the user navigates. */
|
|
25
|
+
readonly onPageChange: (page: number) => void;
|
|
26
|
+
/** Accessible label naming what is being paginated (e.g. "Run history pagination"). */
|
|
27
|
+
readonly ariaLabel: string;
|
|
28
|
+
/** Additional CSS classes for the root container. */
|
|
29
|
+
readonly className?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const buttonClasses = cn(
|
|
33
|
+
"rounded-md px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors",
|
|
34
|
+
"hover:bg-accent hover:text-foreground",
|
|
35
|
+
"disabled:pointer-events-none disabled:opacity-40",
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
export function Pagination({
|
|
39
|
+
pageNum,
|
|
40
|
+
totalPages,
|
|
41
|
+
onPageChange,
|
|
42
|
+
ariaLabel,
|
|
43
|
+
className,
|
|
44
|
+
}: PaginationProps) {
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
className={cn("flex items-center justify-between", className)}
|
|
48
|
+
role="navigation"
|
|
49
|
+
aria-label={ariaLabel}
|
|
50
|
+
>
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
disabled={pageNum <= 1}
|
|
54
|
+
onClick={() => onPageChange(pageNum - 1)}
|
|
55
|
+
className={buttonClasses}
|
|
56
|
+
>
|
|
57
|
+
Previous
|
|
58
|
+
</button>
|
|
59
|
+
<span className="text-xs tabular-nums text-muted-foreground">
|
|
60
|
+
Page {pageNum} of {totalPages}
|
|
61
|
+
</span>
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
disabled={pageNum >= totalPages}
|
|
65
|
+
onClick={() => onPageChange(pageNum + 1)}
|
|
66
|
+
className={buttonClasses}
|
|
67
|
+
>
|
|
68
|
+
Next
|
|
69
|
+
</button>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { act } from "@testing-library/react";
|
|
2
|
+
import { vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Advance fake time in small slices with an `act` boundary per slice.
|
|
6
|
+
*
|
|
7
|
+
* A single long `advanceTimersByTimeAsync` inside one `act` coalesces
|
|
8
|
+
* every React commit to the END of the block: no re-render lands
|
|
9
|
+
* between timer callbacks, and state updates queued by multiple timer
|
|
10
|
+
* ticks collapse into one commit. Any test about how timers and renders
|
|
11
|
+
* INTERLEAVE (poll phase stability, render-pressure starvation, data
|
|
12
|
+
* arriving "on the next poll tick") silently measures nothing under a
|
|
13
|
+
* single long advance. Slicing lets React commit between timer steps,
|
|
14
|
+
* the way real time does in production.
|
|
15
|
+
*
|
|
16
|
+
* Not a `.test` file and inside `__tests__` deliberately: vitest does
|
|
17
|
+
* not collect it, and the build/typedoc excludes keep it out of the
|
|
18
|
+
* published package.
|
|
19
|
+
*/
|
|
20
|
+
export async function advanceInSlices(
|
|
21
|
+
totalMs: number,
|
|
22
|
+
sliceMs = 100,
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
for (let elapsed = 0; elapsed < totalMs; elapsed += sliceMs) {
|
|
25
|
+
await act(async () => {
|
|
26
|
+
await vi.advanceTimersByTimeAsync(sliceMs);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Scroll-behavior regression suite for useAutoScroll (channel-conversations
|
|
2
|
+
// F-09). Runs in a real Chromium via `vitest.a11y.config.ts` — the defect
|
|
3
|
+
// lives in IntersectionObserver/ResizeObserver timing against real layout,
|
|
4
|
+
// which happy-dom cannot evaluate (there, every scrollHeight is 0 and the
|
|
5
|
+
// observers are mocks).
|
|
6
|
+
//
|
|
7
|
+
// The production shape under test: a chat thread whose items arrive ASYNC
|
|
8
|
+
// after mount, so the content wrapper renders inside a loading branch and
|
|
9
|
+
// does not exist on the first render. The shipped bug: the hook attached
|
|
10
|
+
// its ResizeObserver only at mount, so a late-mounting wrapper was never
|
|
11
|
+
// observed — every conversation opened at the OLDEST message with "Jump to
|
|
12
|
+
// latest" showing, and nothing ever scrolled the view again.
|
|
13
|
+
//
|
|
14
|
+
// Layout is driven by inline styles (not the shipped stylesheet): this
|
|
15
|
+
// suite pins the hook's observer mechanics, not CSS — the real component's
|
|
16
|
+
// rendering is covered by ConversationTimelineView.layout.test.tsx.
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
19
|
+
import { render, cleanup } from "@testing-library/react";
|
|
20
|
+
import { useAutoScroll, type UseAutoScrollReturn } from "../useAutoScroll";
|
|
21
|
+
|
|
22
|
+
const VIEWPORT_PX = 240;
|
|
23
|
+
const ITEM_PX = 48;
|
|
24
|
+
|
|
25
|
+
let latest: UseAutoScrollReturn;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The production thread shape: scroller and sentinel always mounted, the
|
|
29
|
+
* content wrapper only once items exist (the loading branch renders a
|
|
30
|
+
* skeleton instead).
|
|
31
|
+
*/
|
|
32
|
+
function AsyncThread({ items }: { readonly items: readonly string[] }) {
|
|
33
|
+
const hook = useAutoScroll();
|
|
34
|
+
latest = hook;
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
ref={hook.scrollRef}
|
|
38
|
+
data-testid="scroller"
|
|
39
|
+
style={{ height: VIEWPORT_PX, overflowY: "auto" }}
|
|
40
|
+
>
|
|
41
|
+
{items.length === 0 ? (
|
|
42
|
+
<div data-testid="skeleton" style={{ height: 100 }} />
|
|
43
|
+
) : (
|
|
44
|
+
<div ref={hook.contentRef} data-testid="content">
|
|
45
|
+
{items.map((text) => (
|
|
46
|
+
<div key={text} style={{ height: ITEM_PX }}>
|
|
47
|
+
{text}
|
|
48
|
+
</div>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
<div ref={hook.sentinelRef} aria-hidden="true" />
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const messages = (count: number, offset = 0) =>
|
|
58
|
+
Array.from({ length: count }, (_, i) => `message ${i + offset}`);
|
|
59
|
+
|
|
60
|
+
function scroller(): HTMLElement {
|
|
61
|
+
return document.querySelector('[data-testid="scroller"]') as HTMLElement;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const isPinnedToBottom = (el: HTMLElement) =>
|
|
65
|
+
Math.abs(el.scrollTop - (el.scrollHeight - el.clientHeight)) <= 1;
|
|
66
|
+
|
|
67
|
+
/** Wait out rAF-batched scroll writes and async observer callbacks. */
|
|
68
|
+
async function settled(assertion: () => void): Promise<void> {
|
|
69
|
+
await vi.waitFor(assertion, { timeout: 2000, interval: 20 });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Determinism note: every case fills content AFTER mount on purpose. With
|
|
73
|
+
// a late-mounting wrapper the mount-time scroll write is a no-op (the
|
|
74
|
+
// skeleton does not overflow), so the bottom pin can only come from the
|
|
75
|
+
// ResizeObserver pathway — "pinned" therefore PROVES the observer's
|
|
76
|
+
// initial delivery and its rAF write have both run, and no pinning work
|
|
77
|
+
// is pending when a case then simulates the reader's scroll. (Headless
|
|
78
|
+
// Chromium delivers initial observations on its own schedule; waiting a
|
|
79
|
+
// frame count instead was measured flaky.)
|
|
80
|
+
|
|
81
|
+
afterEach(() => cleanup());
|
|
82
|
+
|
|
83
|
+
describe("useAutoScroll under real layout (F-09)", () => {
|
|
84
|
+
it("pins to the newest content when items arrive after mount", async () => {
|
|
85
|
+
const { rerender } = render(<AsyncThread items={[]} />);
|
|
86
|
+
|
|
87
|
+
// The async first fill: 30 × 48px inside a 240px viewport.
|
|
88
|
+
rerender(<AsyncThread items={messages(30)} />);
|
|
89
|
+
|
|
90
|
+
await settled(() => {
|
|
91
|
+
expect(isPinnedToBottom(scroller()), "view must open at the newest message").toBe(true);
|
|
92
|
+
expect(latest.isFollowing).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("keeps following growth that lands after an async first fill", async () => {
|
|
97
|
+
const { rerender } = render(<AsyncThread items={[]} />);
|
|
98
|
+
rerender(<AsyncThread items={messages(30)} />);
|
|
99
|
+
await settled(() => expect(isPinnedToBottom(scroller())).toBe(true));
|
|
100
|
+
|
|
101
|
+
// A new message lands (a poll tick): the pinned view must follow it.
|
|
102
|
+
rerender(<AsyncThread items={messages(31)} />);
|
|
103
|
+
|
|
104
|
+
await settled(() => {
|
|
105
|
+
expect(isPinnedToBottom(scroller())).toBe(true);
|
|
106
|
+
expect(latest.isFollowing).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("leaves a reader who scrolled up alone, and jumpToLatest recovers", async () => {
|
|
111
|
+
const { rerender } = render(<AsyncThread items={[]} />);
|
|
112
|
+
rerender(<AsyncThread items={messages(30)} />);
|
|
113
|
+
await settled(() => {
|
|
114
|
+
expect(isPinnedToBottom(scroller())).toBe(true);
|
|
115
|
+
// Follow-STATE quiescence, not just position: the pin's own IO
|
|
116
|
+
// deliveries (a transient not-visible mid-pin, then visible)
|
|
117
|
+
// must have landed before the reader scrolls up — otherwise the
|
|
118
|
+
// disengage wait below can pass vacuously on the transient FALSE
|
|
119
|
+
// while a queued pre-scroll TRUE re-arms follow behind it, and
|
|
120
|
+
// the growth step yanks the reader (measured ~1-in-40 headless).
|
|
121
|
+
expect(latest.isFollowing).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// The reader scrolls up to read history — follow disengages.
|
|
125
|
+
scroller().scrollTop = 0;
|
|
126
|
+
await settled(() => expect(latest.isFollowing).toBe(false));
|
|
127
|
+
|
|
128
|
+
// Growth while disengaged (an incoming message, or the reader's own
|
|
129
|
+
// send arriving via refetch): the view must NOT move under them.
|
|
130
|
+
// This is the measured scroll-on-send posture — see the F-09 record.
|
|
131
|
+
rerender(<AsyncThread items={messages(31)} />);
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
133
|
+
expect(scroller().scrollTop).toBe(0);
|
|
134
|
+
expect(latest.isFollowing).toBe(false);
|
|
135
|
+
|
|
136
|
+
// The recovery affordance: jumpToLatest re-pins and re-engages.
|
|
137
|
+
latest.jumpToLatest();
|
|
138
|
+
await settled(() => {
|
|
139
|
+
expect(isPinnedToBottom(scroller())).toBe(true);
|
|
140
|
+
expect(latest.isFollowing).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -22,6 +22,29 @@ function Harness() {
|
|
|
22
22
|
);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The conversation-timeline shape (channel-conversations F-09): the content
|
|
27
|
+
* wrapper legitimately sits inside a loading branch, so it does NOT exist on
|
|
28
|
+
* the first render — the hook must attach its ResizeObserver whenever the
|
|
29
|
+
* wrapper appears, not only at mount.
|
|
30
|
+
*/
|
|
31
|
+
function LateContentHarness({ showContent }: { readonly showContent: boolean }) {
|
|
32
|
+
const result = useAutoScroll();
|
|
33
|
+
latestResult = result;
|
|
34
|
+
return (
|
|
35
|
+
<div ref={result.scrollRef} data-testid="scroller">
|
|
36
|
+
{showContent ? (
|
|
37
|
+
<div ref={result.contentRef} data-testid="content">
|
|
38
|
+
<p>Hello</p>
|
|
39
|
+
</div>
|
|
40
|
+
) : (
|
|
41
|
+
<div data-testid="skeleton" />
|
|
42
|
+
)}
|
|
43
|
+
<div ref={result.sentinelRef} data-testid="sentinel" />
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
25
48
|
// ---------------------------------------------------------------------------
|
|
26
49
|
// Observer mocks
|
|
27
50
|
// ---------------------------------------------------------------------------
|
|
@@ -91,12 +114,22 @@ afterEach(() => {
|
|
|
91
114
|
// Helpers
|
|
92
115
|
// ---------------------------------------------------------------------------
|
|
93
116
|
|
|
94
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Deliver an IO notification with the scroller's LIVE geometry staged to
|
|
119
|
+
* match the scenario. The hook measures live geometry at delivery time
|
|
120
|
+
* rather than trusting the entry payload — an entry snapshots
|
|
121
|
+
* observation-time geometry and can be delivered after the reader has
|
|
122
|
+
* scrolled again (the stale-payload race pinned in the browser suite) —
|
|
123
|
+
* so the entry list itself is deliberately empty here.
|
|
124
|
+
*/
|
|
125
|
+
function fireIO(nearBottom: boolean) {
|
|
126
|
+
const scroller = screen.getByTestId("scroller");
|
|
127
|
+
Object.defineProperty(scroller, "scrollHeight", { value: 1000, configurable: true });
|
|
128
|
+
Object.defineProperty(scroller, "clientHeight", { value: 200, configurable: true });
|
|
129
|
+
// Within the 80px near-bottom margin, or far above it.
|
|
130
|
+
scroller.scrollTop = nearBottom ? 800 : 0;
|
|
95
131
|
act(() => {
|
|
96
|
-
ioCallback(
|
|
97
|
-
[{ isIntersecting } as IntersectionObserverEntry],
|
|
98
|
-
{} as IntersectionObserver,
|
|
99
|
-
);
|
|
132
|
+
ioCallback([], {} as IntersectionObserver);
|
|
100
133
|
});
|
|
101
134
|
}
|
|
102
135
|
|
|
@@ -126,6 +159,30 @@ describe("useAutoScroll", () => {
|
|
|
126
159
|
expect(roObserve).toHaveBeenCalledWith(screen.getByTestId("content"));
|
|
127
160
|
});
|
|
128
161
|
|
|
162
|
+
it("observes content that mounts after the first render (F-09: loading-branch consumers)", () => {
|
|
163
|
+
const { rerender } = render(<LateContentHarness showContent={false} />);
|
|
164
|
+
expect(roObserve).not.toHaveBeenCalled();
|
|
165
|
+
|
|
166
|
+
// The async first fill: the skeleton gives way to real content.
|
|
167
|
+
rerender(<LateContentHarness showContent />);
|
|
168
|
+
expect(roObserve).toHaveBeenCalledWith(screen.getByTestId("content"));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("disconnects and re-observes across content unmount/remount round-trips", () => {
|
|
172
|
+
const { rerender } = render(<LateContentHarness showContent />);
|
|
173
|
+
expect(roObserve).toHaveBeenCalledTimes(1);
|
|
174
|
+
|
|
175
|
+
// Back to a loading state (e.g. an identity switch resets the data
|
|
176
|
+
// hook): the observer must let go of the dead node…
|
|
177
|
+
rerender(<LateContentHarness showContent={false} />);
|
|
178
|
+
expect(roDisconnect).toHaveBeenCalled();
|
|
179
|
+
|
|
180
|
+
// …and adopt the replacement when content returns.
|
|
181
|
+
rerender(<LateContentHarness showContent />);
|
|
182
|
+
expect(roObserve).toHaveBeenCalledTimes(2);
|
|
183
|
+
expect(roObserve).toHaveBeenLastCalledWith(screen.getByTestId("content"));
|
|
184
|
+
});
|
|
185
|
+
|
|
129
186
|
it("configures IO with 80px bottom root margin", () => {
|
|
130
187
|
render(<Harness />);
|
|
131
188
|
const IOConstructor = vi.mocked(IntersectionObserver);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { act, render, cleanup } from "@testing-library/react";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useFetch } from "../useFetch";
|
|
5
|
+
import { advanceInSlices } from "./fake-timer-slices";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The F-14 starvation net (channel-conversations T06): a polling
|
|
9
|
+
* `useFetch` must keep its interval's phase across re-renders of its
|
|
10
|
+
* host component.
|
|
11
|
+
*
|
|
12
|
+
* Every real consumer passes an inline closure as `fetchFn`, which is
|
|
13
|
+
* referentially new on each render. If the interval effect keys on that
|
|
14
|
+
* identity, any co-mounted render source with a period shorter than the
|
|
15
|
+
* poll interval tears the timer down before it can ever fire — the
|
|
16
|
+
* interval needs an uninterrupted `refetchInterval` window that
|
|
17
|
+
* structurally never arrives. In production this starved the
|
|
18
|
+
* conversation timeline (5s poll) and inbox (20s poll) under
|
|
19
|
+
* `useConversation`'s 5s render cadence, deterministically.
|
|
20
|
+
*
|
|
21
|
+
* The ticker period here (600ms) is deliberately SHORTER than the poll
|
|
22
|
+
* interval (1000ms) and misaligned with it. Do not "simplify" this to
|
|
23
|
+
* aligned periods: when both timers share a phase under fake timers,
|
|
24
|
+
* both callbacks fire inside the same advanced instant — before React
|
|
25
|
+
* commits the re-render that would tear the poll timer down — and the
|
|
26
|
+
* starvation never manifests, leaving a test that cannot fail.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Calls `useFetch` the way every real consumer does: inline closure. */
|
|
30
|
+
function PollingConsumer({ onFetch }: { readonly onFetch: () => void }) {
|
|
31
|
+
useFetch(
|
|
32
|
+
async () => {
|
|
33
|
+
onFetch();
|
|
34
|
+
return "ok";
|
|
35
|
+
},
|
|
36
|
+
[],
|
|
37
|
+
"",
|
|
38
|
+
{ refetchInterval: 1000 },
|
|
39
|
+
);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Re-renders its subtree every 600ms — the fast co-mounted render source. */
|
|
44
|
+
function RenderPressureHost({ onFetch }: { readonly onFetch: () => void }) {
|
|
45
|
+
const [, setTick] = useState(0);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const id = setInterval(() => setTick((t) => t + 1), 600);
|
|
48
|
+
return () => clearInterval(id);
|
|
49
|
+
}, []);
|
|
50
|
+
return <PollingConsumer onFetch={onFetch} />;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("useFetch — polling under render pressure (F-14)", () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.useFakeTimers();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
cleanup();
|
|
60
|
+
vi.useRealTimers();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("keeps polling while a faster sibling re-renders the consumer every 600ms", async () => {
|
|
64
|
+
const onFetch = vi.fn();
|
|
65
|
+
render(<RenderPressureHost onFetch={onFetch} />);
|
|
66
|
+
|
|
67
|
+
// Flush the initial fetch.
|
|
68
|
+
await act(async () => {
|
|
69
|
+
await Promise.resolve();
|
|
70
|
+
});
|
|
71
|
+
expect(onFetch).toHaveBeenCalledTimes(1);
|
|
72
|
+
|
|
73
|
+
// 3 seconds under render pressure: the ticker re-renders at 600ms,
|
|
74
|
+
// 1200ms, 1800ms, 2400ms, 3000ms. The poll interval must still fire
|
|
75
|
+
// at 1000ms, 2000ms, and 3000ms — its phase survives the renders.
|
|
76
|
+
await advanceInSlices(3000);
|
|
77
|
+
expect(onFetch).toHaveBeenCalledTimes(4);
|
|
78
|
+
});
|
|
79
|
+
});
|
package/src/internal/tooltip.tsx
CHANGED
|
@@ -40,7 +40,12 @@ function TooltipContent({
|
|
|
40
40
|
const portalContainer = useStigmerPortalContainer();
|
|
41
41
|
|
|
42
42
|
return (
|
|
43
|
-
|
|
43
|
+
// `?? undefined`, never a raw null: Base UI treats an EXPLICIT null
|
|
44
|
+
// container as "wait for one" and renders the popup nowhere, while
|
|
45
|
+
// undefined falls back to document.body — the standalone behavior
|
|
46
|
+
// `useStigmerPortalContainer` documents. Without the coalesce, a
|
|
47
|
+
// tooltip outside a StigmerProvider opens invisibly.
|
|
48
|
+
<TooltipPrimitive.Portal container={portalContainer ?? undefined}>
|
|
44
49
|
<TooltipPrimitive.Positioner
|
|
45
50
|
className="isolate z-50 outline-none"
|
|
46
51
|
side={side}
|
|
@@ -11,12 +11,20 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
11
11
|
const NEAR_BOTTOM_MARGIN_PX = 80;
|
|
12
12
|
|
|
13
13
|
export interface UseAutoScrollReturn {
|
|
14
|
-
/** Attach to the scrollable container. */
|
|
14
|
+
/** Attach to the scrollable container. Must be mounted from the first render. */
|
|
15
15
|
readonly scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Attach to a zero-height div as the last child of the scroll container.
|
|
18
|
+
* Must be mounted from the first render.
|
|
19
|
+
*/
|
|
17
20
|
readonly sentinelRef: React.RefObject<HTMLDivElement | null>;
|
|
18
|
-
/**
|
|
19
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Attach to a wrapper div around the thread content (ResizeObserver
|
|
23
|
+
* target). MAY mount late or round-trip through unmount — a callback
|
|
24
|
+
* ref, so consumers whose content sits inside a loading branch (the
|
|
25
|
+
* conversation timeline) are observed the moment the wrapper appears.
|
|
26
|
+
*/
|
|
27
|
+
readonly contentRef: React.RefCallback<HTMLDivElement>;
|
|
20
28
|
/** True when the thread auto-scrolls to follow new content. */
|
|
21
29
|
readonly isFollowing: boolean;
|
|
22
30
|
/** Scroll to the latest content and re-engage follow mode. */
|
|
@@ -39,12 +47,24 @@ export interface UseAutoScrollReturn {
|
|
|
39
47
|
* detects height growth and triggers rAF-batched scroll writes when
|
|
40
48
|
* in the Following state.
|
|
41
49
|
*
|
|
50
|
+
* Ref contract (asymmetric on purpose): the scroller and sentinel must
|
|
51
|
+
* exist from the first render — every thread renders its scroll pane
|
|
52
|
+
* unconditionally, and the observers that need them attach once at
|
|
53
|
+
* mount. The CONTENT wrapper may appear later or round-trip through
|
|
54
|
+
* unmount: chat threads legitimately render a loading skeleton first
|
|
55
|
+
* (the conversation timeline does), so `contentRef` is a callback ref
|
|
56
|
+
* that owns the ResizeObserver's lifecycle — React hands it the node on
|
|
57
|
+
* attach and `null` on detach, so the observer follows the wrapper
|
|
58
|
+
* wherever it goes, with no render cost. Attaching only at mount
|
|
59
|
+
* shipped channel-conversations F-09: a late-mounting wrapper was never
|
|
60
|
+
* observed, so every conversation opened at its oldest message and
|
|
61
|
+
* nothing ever scrolled the view.
|
|
62
|
+
*
|
|
42
63
|
* @internal Not part of the public API.
|
|
43
64
|
*/
|
|
44
65
|
export function useAutoScroll(): UseAutoScrollReturn {
|
|
45
66
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
46
67
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
|
47
|
-
const contentRef = useRef<HTMLDivElement | null>(null);
|
|
48
68
|
|
|
49
69
|
const [isFollowing, setIsFollowing] = useState(true);
|
|
50
70
|
const isFollowingRef = useRef(true);
|
|
@@ -67,10 +87,20 @@ export function useAutoScroll(): UseAutoScrollReturn {
|
|
|
67
87
|
scroller.scrollTop = scroller.scrollHeight;
|
|
68
88
|
|
|
69
89
|
const io = new IntersectionObserver(
|
|
70
|
-
(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
() => {
|
|
91
|
+
// The delivery is the signal; the LIVE geometry is the truth.
|
|
92
|
+
// An entry snapshots geometry at OBSERVATION time and can be
|
|
93
|
+
// delivered after the reader has scrolled again — measured in
|
|
94
|
+
// the F-09 net: a stale "visible" (captured at the pinned
|
|
95
|
+
// instant) landed after a scroll-up, re-engaged follow, and the
|
|
96
|
+
// next growth yanked the reader to the bottom. Measuring at
|
|
97
|
+
// delivery time keeps the observer as a poll-free change
|
|
98
|
+
// detector without trusting its stale payload.
|
|
99
|
+
const el = scrollRef.current;
|
|
100
|
+
if (!el) return;
|
|
101
|
+
const visible =
|
|
102
|
+
el.scrollHeight - el.scrollTop - el.clientHeight <=
|
|
103
|
+
NEAR_BOTTOM_MARGIN_PX;
|
|
74
104
|
isFollowingRef.current = visible;
|
|
75
105
|
setIsFollowing(visible);
|
|
76
106
|
},
|
|
@@ -86,25 +116,41 @@ export function useAutoScroll(): UseAutoScrollReturn {
|
|
|
86
116
|
}, []);
|
|
87
117
|
|
|
88
118
|
// --- ResizeObserver: scroll on content height growth while following ---
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
119
|
+
// Lifecycle lives in the callback ref (not a run-once effect): the
|
|
120
|
+
// observer must adopt a wrapper that mounts after a loading branch and
|
|
121
|
+
// let go of one that unmounts — including on component unmount, where
|
|
122
|
+
// React also calls the ref with null. Its initial delivery on observe()
|
|
123
|
+
// doubles as the first-fill bottom pin. The scroller is read lazily at
|
|
124
|
+
// fire time (deliveries are async, after every commit's refs are set).
|
|
125
|
+
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
|
126
|
+
const contentRef = useCallback((node: HTMLDivElement | null) => {
|
|
127
|
+
resizeObserverRef.current?.disconnect();
|
|
128
|
+
resizeObserverRef.current = null;
|
|
129
|
+
cancelAnimationFrame(rafIdRef.current);
|
|
130
|
+
if (!node) return;
|
|
92
131
|
|
|
93
132
|
const ro = new ResizeObserver(() => {
|
|
94
133
|
if (!isFollowingRef.current) return;
|
|
95
134
|
cancelAnimationFrame(rafIdRef.current);
|
|
135
|
+
// The reader-took-control guard, decided at WRITE time: a frame
|
|
136
|
+
// can lag (headless and busy tabs throttle rAF) long enough for
|
|
137
|
+
// the reader to scroll up between scheduling and writing, and a
|
|
138
|
+
// stale pin then yanks them back to the bottom (the F-09 suite's
|
|
139
|
+
// scrolled-up case, flaking ~1-in-5 under real Chromium). The
|
|
140
|
+
// discriminator is scrollTop itself: content growth never moves
|
|
141
|
+
// it, only the reader does — `isFollowingRef` cannot arbitrate
|
|
142
|
+
// here because growth makes the sentinel leave the viewport
|
|
143
|
+
// transiently, so the flag reads false mid-pin by design.
|
|
144
|
+
const scheduledAt = scrollRef.current?.scrollTop ?? null;
|
|
96
145
|
rafIdRef.current = requestAnimationFrame(() => {
|
|
97
146
|
const el = scrollRef.current;
|
|
98
|
-
if (el)
|
|
147
|
+
if (!el) return;
|
|
148
|
+
if (scheduledAt !== null && el.scrollTop !== scheduledAt) return;
|
|
149
|
+
el.scrollTop = el.scrollHeight;
|
|
99
150
|
});
|
|
100
151
|
});
|
|
101
|
-
|
|
102
|
-
ro
|
|
103
|
-
|
|
104
|
-
return () => {
|
|
105
|
-
ro.disconnect();
|
|
106
|
-
cancelAnimationFrame(rafIdRef.current);
|
|
107
|
-
};
|
|
152
|
+
ro.observe(node);
|
|
153
|
+
resizeObserverRef.current = ro;
|
|
108
154
|
}, []);
|
|
109
155
|
|
|
110
156
|
const jumpToLatest = useCallback(() => {
|
package/src/internal/useFetch.ts
CHANGED
|
@@ -182,18 +182,29 @@ export function useFetch<T>(
|
|
|
182
182
|
};
|
|
183
183
|
}, [...deps, fetchKey]);
|
|
184
184
|
|
|
185
|
+
// The two effects below use `fetchFn` only as an enabled/disabled
|
|
186
|
+
// signal — they trigger fetches through `refetch()`, and the fetch
|
|
187
|
+
// effect above already closes over the current render's `fetchFn`.
|
|
188
|
+
// Keying them on this boolean instead of on `fetchFn` itself is
|
|
189
|
+
// load-bearing: consumers pass inline closures (referentially new
|
|
190
|
+
// every render), so an identity dep tears the interval down on every
|
|
191
|
+
// render — and under any co-mounted faster render source the timer
|
|
192
|
+
// never completes a period and the consumer never polls at all
|
|
193
|
+
// (channel-conversations F-14; pinned by the render-pressure tests).
|
|
194
|
+
const fetchEnabled = fetchFn !== null;
|
|
195
|
+
|
|
185
196
|
const refetchInterval = options?.refetchInterval;
|
|
186
197
|
useEffect(() => {
|
|
187
|
-
if (!refetchInterval || refetchInterval <= 0 || !
|
|
198
|
+
if (!refetchInterval || refetchInterval <= 0 || !fetchEnabled) return;
|
|
188
199
|
const id = setInterval(() => {
|
|
189
200
|
if (!isFetchingRef.current) refetch();
|
|
190
201
|
}, refetchInterval);
|
|
191
202
|
return () => clearInterval(id);
|
|
192
|
-
}, [refetchInterval,
|
|
203
|
+
}, [refetchInterval, fetchEnabled, refetch]);
|
|
193
204
|
|
|
194
205
|
const refetchOnWindowFocus = options?.refetchOnWindowFocus;
|
|
195
206
|
useEffect(() => {
|
|
196
|
-
if (!refetchOnWindowFocus || !
|
|
207
|
+
if (!refetchOnWindowFocus || !fetchEnabled) return;
|
|
197
208
|
if (typeof window === "undefined") return;
|
|
198
209
|
const onActive = () => {
|
|
199
210
|
if (!isFetchingRef.current) refetch();
|
|
@@ -207,7 +218,7 @@ export function useFetch<T>(
|
|
|
207
218
|
window.removeEventListener("focus", onActive);
|
|
208
219
|
document.removeEventListener("visibilitychange", onVisible);
|
|
209
220
|
};
|
|
210
|
-
}, [refetchOnWindowFocus,
|
|
221
|
+
}, [refetchOnWindowFocus, fetchEnabled, refetch]);
|
|
211
222
|
|
|
212
223
|
const isLoading = isFetching && !hasDataRef.current;
|
|
213
224
|
const isRefetching = isFetching && hasDataRef.current;
|