Skip to content

Commit f4855cd

Browse files
authored
fix(mothership): prune composer contexts during render so fast typing cannot trip the update-depth limit (#8702)
1 parent 5f1a29e commit f4855cd

4 files changed

Lines changed: 106 additions & 19 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1175,7 +1175,7 @@ export function usePromptEditor({
11751175

11761176
// Portable chip links (`[label](sim:kind/id)`) re-create their chip on
11771177
// paste-back. Rewrite each link span to its `@label ` token (the trailing
1178-
// space is REQUIRED so useContextManagement's sync effect doesn't purge the
1178+
// space is REQUIRED so useContextManagement's pruning doesn't purge the
11791179
// freshly-added context) and register the contexts directly.
11801180
const pastedText = pastedPlainText
11811181
const links = contextsEnabledRef.current ? parseChipLinks(pastedText) : []
@@ -1198,7 +1198,7 @@ export function usePromptEditor({
11981198
pastedContexts.push(ctx)
11991199
// Insert the kind-correct token (skill EM-SPACE sentinel, slash `/`, `@`
12001200
// else) so the chip re-renders with its proper trigger glyph and the
1201-
// context-sync effect (keyed on the same per-kind prefix) keeps it. Append
1201+
// context pruning (keyed on the same per-kind prefix) keeps it. Append
12021202
// a single separator ONLY when the next source char is non-whitespace
12031203
// (chip→chip / chip→word); existing whitespace and end-of-string already
12041204
// supply the boundary, so re-pasting never accumulates spaces.
@@ -1312,7 +1312,7 @@ export function usePromptEditor({
13121312
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
13131313
// When the selection holds a portable chip (skill or resource) we take over
13141314
// the clipboard, so the selected text must be removed here (default prevented).
1315-
// Either way the context-sync effect prunes contexts whose token is now gone.
1315+
// Either way the context pruning drops contexts whose token is now gone.
13161316
if (!writeSanitizedClipboard(e)) return
13171317
const textarea = e.currentTarget
13181318
const start = textarea.selectionStart ?? 0

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.test.tsx‎

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/**
22
* @vitest-environment jsdom
33
*/
4-
import { act } from 'react'
4+
import { act, type ChangeEvent, useState } from 'react'
55
import { createRoot, type Root } from 'react-dom/client'
6-
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77
import { useContextManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management'
88
import type { ChatContext } from '@/stores/panel'
99

@@ -80,3 +80,84 @@ describe('useContextManagement label sync', () => {
8080
expect(latest.selectedContexts.map((c) => c.label)).toEqual(['notes.md:12', 'notes.md:12-40'])
8181
})
8282
})
83+
84+
describe('useContextManagement while the user types', () => {
85+
beforeEach(() => {
86+
container = document.createElement('div')
87+
document.body.appendChild(container)
88+
root = createRoot(container)
89+
})
90+
91+
afterEach(() => {
92+
act(() => root.unmount())
93+
container.remove()
94+
vi.restoreAllMocks()
95+
})
96+
97+
/**
98+
* Under CPU load the browser runs each keystroke's input task before React's
99+
* scheduler task, so a keystroke commit must leave no render pending. React
100+
* counts every commit that leaves an update pending, and the 51st such commit
101+
* in a row makes the next `setState` anywhere throw "Maximum update depth
102+
* exceeded" (#185); in the chat that next `setState` was the Enter that queues
103+
* the follow-up, which was then lost. Keystrokes here are separate input
104+
* events with only microtasks between them, so the scheduler never runs: the
105+
* same ordering a loaded browser produces.
106+
*/
107+
it('submits a long message typed faster than the React scheduler runs', async () => {
108+
const thrown: unknown[] = []
109+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
110+
let submitted = ''
111+
112+
function Composer() {
113+
const [message, setMessage] = useState('')
114+
const [lastSubmitted, setLastSubmitted] = useState('')
115+
useContextManagement({ message })
116+
submitted = lastSubmitted
117+
const guard = (update: () => void) => {
118+
try {
119+
update()
120+
} catch (error) {
121+
thrown.push(error)
122+
}
123+
}
124+
return (
125+
<textarea
126+
value={message}
127+
onChange={(event: ChangeEvent<HTMLTextAreaElement>) =>
128+
guard(() => setMessage(event.target.value))
129+
}
130+
onKeyDown={(event) => {
131+
if (event.key === 'Enter') guard(() => setLastSubmitted(message))
132+
}}
133+
/>
134+
)
135+
}
136+
137+
act(() => {
138+
root.render(<Composer />)
139+
})
140+
const textarea = container.querySelector('textarea')
141+
if (!textarea) throw new Error('composer did not render')
142+
const setNativeValue = Object.getOwnPropertyDescriptor(
143+
HTMLTextAreaElement.prototype,
144+
'value'
145+
)?.set
146+
if (!setNativeValue) throw new Error('textarea value setter missing')
147+
148+
const followUp = 'please also summarize the second quarter numbers by region and team'
149+
for (let i = 1; i <= followUp.length; i++) {
150+
setNativeValue.call(textarea, followUp.slice(0, i))
151+
textarea.dispatchEvent(new Event('input', { bubbles: true }))
152+
await Promise.resolve()
153+
}
154+
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
155+
await Promise.resolve()
156+
157+
expect(thrown).toEqual([])
158+
expect(
159+
consoleError.mock.calls.filter((call) => String(call[0]).includes('Maximum update depth'))
160+
).toEqual([])
161+
expect(submitted).toBe(followUp)
162+
})
163+
})

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management.ts‎

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useState } from 'react'
1+
import { useCallback, useState } from 'react'
22
import {
33
filterContextsPresentInMessage,
44
filterOutContext,
@@ -21,7 +21,23 @@ interface UseContextManagementProps {
2121
* @returns Context state and management functions
2222
*/
2323
export function useContextManagement({ message, initialContexts }: UseContextManagementProps) {
24-
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(initialContexts ?? [])
24+
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(() =>
25+
filterContextsPresentInMessage(initialContexts ?? [], message)
26+
)
27+
const [prunedForMessage, setPrunedForMessage] = useState(message)
28+
29+
/**
30+
* Drops contexts whose inline @label or /label token left the message, during
31+
* render. An effect here re-rendered after every keystroke; when keystrokes
32+
* outpace React's scheduler (a loaded machine), React counts those commits as
33+
* nested updates and the next `setState` (the Enter that submits) throws
34+
* "Maximum update depth exceeded".
35+
*/
36+
if (prunedForMessage !== message) {
37+
setPrunedForMessage(message)
38+
const present = filterContextsPresentInMessage(selectedContexts, message)
39+
if (present !== selectedContexts) setSelectedContexts(present)
40+
}
2541

2642
/**
2743
* Adds a context to the selected contexts list, avoiding duplicates
@@ -52,16 +68,6 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
5268
setSelectedContexts((prev) => (prev.length === 0 ? prev : []))
5369
}, [])
5470

55-
/**
56-
* Synchronizes selected contexts with inline @label or /label tokens in the message.
57-
* Removes contexts whose labels are no longer present in the message.
58-
*/
59-
useEffect(() => {
60-
setSelectedContexts((prev) => {
61-
return filterContextsPresentInMessage(prev, message)
62-
})
63-
}, [message])
64-
6571
return {
6672
selectedContexts,
6773
setSelectedContexts,

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-tokens.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export interface MentionRange {
2828
* locating the range at a caret, and deleting a token's text atomically.
2929
*
3030
* Context lifetime is intentionally NOT managed here — `useContextManagement`'s
31-
* sync effect owns it, removing a context exactly when its last matching token
31+
* pruning owns it, removing a context exactly when its last matching token
3232
* disappears from the message. That keeps duplicate-label chips (two `@sub` for
3333
* the same resource) correct: deleting one token leaves the other, so the
3434
* shared context survives.
@@ -113,7 +113,7 @@ export function useMentionTokens({
113113

114114
/**
115115
* Atomically deletes a single mention token's text. The context is left to
116-
* `useContextManagement`'s sync effect, which prunes it only once no matching
116+
* `useContextManagement`'s pruning, which drops it only once no matching
117117
* token remains — so deleting one of two duplicate chips keeps the other.
118118
*
119119
* @param range - The range to delete

0 commit comments

Comments
 (0)