|
1 | 1 | /** |
2 | 2 | * @vitest-environment jsdom |
3 | 3 | */ |
4 | | -import { act } from 'react' |
| 4 | +import { act, type ChangeEvent, useState } from 'react' |
5 | 5 | 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' |
7 | 7 | import { useContextManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-context-management' |
8 | 8 | import type { ChatContext } from '@/stores/panel' |
9 | 9 |
|
@@ -80,3 +80,84 @@ describe('useContextManagement label sync', () => { |
80 | 80 | expect(latest.selectedContexts.map((c) => c.label)).toEqual(['notes.md:12', 'notes.md:12-40']) |
81 | 81 | }) |
82 | 82 | }) |
| 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 | +}) |
0 commit comments