|
| 1 | +/** @vitest-environment jsdom */ |
| 2 | +import { act, type ComponentProps, StrictMode, Suspense, startTransition } from 'react' |
| 3 | +import type { Editor } from '@tiptap/core' |
| 4 | +import { createRoot, type Root } from 'react-dom/client' |
| 5 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
| 6 | +import { RichMarkdownField } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field' |
| 7 | + |
| 8 | +vi.mock( |
| 9 | + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention', |
| 10 | + () => ({ useEditorMentions: vi.fn() }) |
| 11 | +) |
| 12 | +vi.mock( |
| 13 | + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu', |
| 14 | + () => ({ EditorBubbleMenu: () => null }) |
| 15 | +) |
| 16 | +vi.mock( |
| 17 | + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/image-menu', |
| 18 | + () => ({ ImageBubbleMenu: () => null }) |
| 19 | +) |
| 20 | +vi.mock( |
| 21 | + '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card', |
| 22 | + () => ({ LinkHoverCard: () => null }) |
| 23 | +) |
| 24 | + |
| 25 | +let host: HTMLDivElement |
| 26 | +let root: Root |
| 27 | +const pending = new Promise<void>(() => {}) |
| 28 | +const suspended = vi.fn() |
| 29 | +interface BlockerProps { |
| 30 | + active: boolean |
| 31 | +} |
| 32 | + |
| 33 | +function Blocker({ active }: BlockerProps) { |
| 34 | + if (active) { |
| 35 | + suspended() |
| 36 | + throw pending |
| 37 | + } |
| 38 | + return null |
| 39 | +} |
| 40 | +beforeEach(() => { |
| 41 | + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) |
| 42 | + vi.useFakeTimers() |
| 43 | + suspended.mockClear() |
| 44 | + host = document.createElement('div') |
| 45 | + document.body.append(host) |
| 46 | + root = createRoot(host) |
| 47 | +}) |
| 48 | +afterEach(async () => { |
| 49 | + await act(async () => root.unmount()) |
| 50 | + await vi.advanceTimersByTimeAsync(10) |
| 51 | + host.remove() |
| 52 | + vi.useRealTimers() |
| 53 | + vi.unstubAllGlobals() |
| 54 | +}) |
| 55 | + |
| 56 | +describe('editability synchronization', () => { |
| 57 | + async function renderField(props: ComponentProps<typeof RichMarkdownField>) { |
| 58 | + await act(async () => |
| 59 | + root.render( |
| 60 | + <StrictMode> |
| 61 | + <RichMarkdownField {...props} /> |
| 62 | + </StrictMode> |
| 63 | + ) |
| 64 | + ) |
| 65 | + await act(async () => vi.advanceTimersByTimeAsync(10)) |
| 66 | + return host.querySelector<HTMLElement & { editor: Editor }>('.tiptap')!.editor |
| 67 | + } |
| 68 | + |
| 69 | + it.each([ |
| 70 | + { label: 'start streaming', initial: {}, next: { isStreaming: true, value: 'streamed' } }, |
| 71 | + { |
| 72 | + label: 'finish streaming', |
| 73 | + initial: { isStreaming: true }, |
| 74 | + next: { isStreaming: false, value: 'final' }, |
| 75 | + }, |
| 76 | + { label: 'disable', initial: {}, next: { disabled: true } }, |
| 77 | + { label: 'enable', initial: { disabled: true }, next: { disabled: false } }, |
| 78 | + ])('does not report a local edit when props $label', async ({ initial, next }) => { |
| 79 | + const props = { value: 'body', onChange: vi.fn(), ...initial } |
| 80 | + const owner = await renderField(props) |
| 81 | + props.onChange.mockClear() |
| 82 | + expect(await renderField({ ...props, ...next })).toBe(owner) |
| 83 | + expect(owner.getText()).toBe(next.value ?? 'body') |
| 84 | + expect(props.onChange).not.toHaveBeenCalled() |
| 85 | + }) |
| 86 | + |
| 87 | + it('continues reporting actual edits after streaming completes', async () => { |
| 88 | + const props = { value: 'body', onChange: vi.fn(), isStreaming: true } |
| 89 | + const owner = await renderField(props) |
| 90 | + await renderField({ ...props, value: 'final', isStreaming: false }) |
| 91 | + props.onChange.mockClear() |
| 92 | + await act(async () => |
| 93 | + owner.commands.insertContentAt(owner.state.doc.content.size - 1, ' edited') |
| 94 | + ) |
| 95 | + expect(props.onChange).toHaveBeenCalledExactlyOnceWith('final edited') |
| 96 | + }) |
| 97 | + |
| 98 | + it('continues reporting successful uploads after editability changes', async () => { |
| 99 | + const pending = Promise.withResolvers<{ url: string; alt: string }>() |
| 100 | + const props = { |
| 101 | + value: 'body', |
| 102 | + onChange: vi.fn(), |
| 103 | + disabled: true, |
| 104 | + uploadImage: vi.fn(() => pending.promise), |
| 105 | + } |
| 106 | + const owner = await renderField(props) |
| 107 | + await renderField({ ...props, disabled: false }) |
| 108 | + props.onChange.mockClear() |
| 109 | + const event = new Event('paste', { bubbles: true, cancelable: true }) |
| 110 | + Object.defineProperty(event, 'clipboardData', { |
| 111 | + value: { |
| 112 | + files: [new File(['image'], 'image.png', { type: 'image/png' })], |
| 113 | + items: [], |
| 114 | + types: ['Files'], |
| 115 | + getData: () => '', |
| 116 | + }, |
| 117 | + }) |
| 118 | + await act(async () => owner.view.dom.dispatchEvent(event)) |
| 119 | + await act(async () => pending.resolve({ url: 'https://sim.ai/valid.png', alt: 'valid' })) |
| 120 | + expect(host.querySelector('img')?.getAttribute('alt')).toBe('valid') |
| 121 | + expect(props.onChange).toHaveBeenCalledOnce() |
| 122 | + expect(props.onChange.mock.calls[0][0]).toContain('https://sim.ai/valid.png') |
| 123 | + }) |
| 124 | +}) |
| 125 | + |
| 126 | +describe('field callbacks remain tied to the committed render', () => { |
| 127 | + for (const action of ['edit', 'upload'] as const) |
| 128 | + for (const suspend of [false, true]) { |
| 129 | + it(`${action}, suspended=${suspend}`, async () => { |
| 130 | + const originalChange = vi.fn() |
| 131 | + const nextChange = vi.fn() |
| 132 | + const originalUpload = vi.fn().mockResolvedValue(null) |
| 133 | + const nextUpload = vi.fn().mockResolvedValue(null) |
| 134 | + const render = (next: boolean) => |
| 135 | + root.render( |
| 136 | + <Suspense fallback='Waiting'> |
| 137 | + <RichMarkdownField |
| 138 | + value='body' |
| 139 | + onChange={next ? nextChange : originalChange} |
| 140 | + uploadImage={next ? nextUpload : originalUpload} |
| 141 | + /> |
| 142 | + <Blocker active={next && suspend} /> |
| 143 | + </Suspense> |
| 144 | + ) |
| 145 | + await act(async () => render(false)) |
| 146 | + await act(async () => vi.advanceTimersByTimeAsync(10)) |
| 147 | + const owner = host.querySelector<HTMLElement & { editor: Editor }>('.tiptap')!.editor |
| 148 | + await act(async () => { |
| 149 | + if (suspend) startTransition(() => render(true)) |
| 150 | + else render(true) |
| 151 | + }) |
| 152 | + if (suspend) expect(suspended).toHaveBeenCalled() |
| 153 | + expect(host.querySelector<HTMLElement & { editor: Editor }>('.tiptap')!.editor).toBe(owner) |
| 154 | + expect(owner.getText()).toBe('body') |
| 155 | + if (action === 'edit') await act(async () => owner.commands.insertContentAt(1, 'typed ')) |
| 156 | + else { |
| 157 | + const event = new Event('paste', { bubbles: true, cancelable: true }) |
| 158 | + Object.defineProperty(event, 'clipboardData', { |
| 159 | + value: { |
| 160 | + files: [new File(['image'], 'image.png', { type: 'image/png' })], |
| 161 | + items: [], |
| 162 | + types: ['Files'], |
| 163 | + getData: () => '', |
| 164 | + }, |
| 165 | + }) |
| 166 | + await act(async () => owner.view.dom.dispatchEvent(event)) |
| 167 | + } |
| 168 | + const original = action === 'edit' ? originalChange : originalUpload |
| 169 | + const next = action === 'edit' ? nextChange : nextUpload |
| 170 | + expect({ committed: original.mock.calls.length, next: next.mock.calls.length }).toEqual( |
| 171 | + suspend ? { committed: 1, next: 0 } : { committed: 0, next: 1 } |
| 172 | + ) |
| 173 | + }) |
| 174 | + } |
| 175 | + |
| 176 | + it('ignores completion after React unmount before TipTap delayed destruction', async () => { |
| 177 | + const change = vi.fn() |
| 178 | + const pendingUpload = Promise.withResolvers<{ url: string; alt: string } | null>() |
| 179 | + await act(async () => |
| 180 | + root.render( |
| 181 | + <RichMarkdownField |
| 182 | + value='body' |
| 183 | + onChange={change} |
| 184 | + uploadImage={() => pendingUpload.promise} |
| 185 | + /> |
| 186 | + ) |
| 187 | + ) |
| 188 | + await act(async () => vi.advanceTimersByTimeAsync(10)) |
| 189 | + const owner = host.querySelector<HTMLElement & { editor: Editor }>('.tiptap')!.editor |
| 190 | + const event = new Event('paste', { bubbles: true, cancelable: true }) |
| 191 | + Object.defineProperty(event, 'clipboardData', { |
| 192 | + value: { |
| 193 | + files: [new File(['image'], 'image.png', { type: 'image/png' })], |
| 194 | + items: [], |
| 195 | + types: ['Files'], |
| 196 | + getData: () => '', |
| 197 | + }, |
| 198 | + }) |
| 199 | + await act(async () => owner.view.dom.dispatchEvent(event)) |
| 200 | + const before = owner.getJSON() |
| 201 | + await act(async () => root.render(null)) |
| 202 | + expect(owner.isDestroyed).toBe(false) |
| 203 | + await act(async () => pendingUpload.resolve({ url: 'https://sim.ai/image.png', alt: 'late' })) |
| 204 | + expect(owner.getJSON()).toEqual(before) |
| 205 | + expect(change).not.toHaveBeenCalled() |
| 206 | + }) |
| 207 | +}) |
0 commit comments