Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* keymap's `isSuggestionMenuOpen` guard reads flips on when a menu opens.
*/
import { Editor } from '@tiptap/core'
import { GapCursor } from '@tiptap/pm/gapcursor'
import { AllSelection, NodeSelection } from '@tiptap/pm/state'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMarkdownEditorExtensions } from './editor-extensions'
Expand Down Expand Up @@ -195,6 +196,103 @@ describe('empty wrapped-block Backspace', () => {
editor.destroy()
}
)

it('leaves a gap cursor — never a NodeSelection — when the removed bullet was followed by an image at doc start', () => {
// Regression: `Selection.near` after the delete silently NodeSelected the following image, so a
// second Backspace while "clearing the bullet" deleted the image (and typing would have replaced
// it). The selection left behind must never make the next keystroke destructive.
const editor = editorWith({
type: 'doc',
content: [
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] },
{ type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } },
],
})
editor.commands.setTextSelection(3)
pressBackspace(editor)

expect(blockShape(editor)).toEqual(['image', 'paragraph'])
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)

pressBackspace(editor)
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
editor.destroy()
})

it('does not crash on Backspace from a gap cursor (top-level selection, depth 0)', () => {
// Regression: `$from.before($from.depth)` with a gap cursor's depth of 0 threw
// "RangeError: There is no position before the top-level node" — reachable whenever a gap
// cursor sits between two leaves (the `data-gap-between-leaves` state) and Backspace is pressed.
const editor = editorWith('<hr><hr>')
const gapPos = editor.state.doc.firstChild ? editor.state.doc.firstChild.nodeSize : 1
editor.view.dispatch(
editor.state.tr.setSelection(new GapCursor(editor.state.doc.resolve(gapPos)))
)
expect(editor.state.selection).toBeInstanceOf(GapCursor)

expect(() => pressBackspace(editor)).not.toThrow()
// TipTap appends a trailing paragraph after the final leaf; the dividers must both survive.
expect(blockShape(editor)).toEqual(['horizontalRule', 'horizontalRule', 'paragraph'])
editor.destroy()
})

it('never NodeSelects a leaf BEFORE the removed bullet either (findFrom textOnly skips atoms)', () => {
// `Selection.findFrom($gap, -1, true)` cannot return a NodeSelection: with textOnly,
// prosemirror-state's findSelectionIn skips atoms entirely (`!text && isSelectable`). With an
// image directly before the emptied bullet and no textblock behind it, the backward search
// returns null and the gap-cursor branch takes over — the image is never silently selected.
const editor = editorWith({
type: 'doc',
content: [
{ type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] },
],
})
editor.commands.setTextSelection(4)
expect(editor.state.selection.$from.parent.type.name).toBe('paragraph')
pressBackspace(editor)

expect(blockShape(editor)).toEqual(['image', 'paragraph'])
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)

pressBackspace(editor)
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
editor.destroy()
})

it('does not crash on Backspace from a gap cursor at the very start of the doc', () => {
// Depth-0 + offset-0 is the worst case: our old code threw before(0), and TipTap's blockquote
// handler crashes on $from.node(-1) if the key falls through — so it must be consumed.
const editor = editorWith({
type: 'doc',
content: [{ type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } }],
})
editor.view.dispatch(editor.state.tr.setSelection(new GapCursor(editor.state.doc.resolve(0))))
expect(editor.state.selection).toBeInstanceOf(GapCursor)

expect(() => pressBackspace(editor)).not.toThrow()
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
editor.destroy()
})

it('still prefers the previous textblock caret when one exists (image after the bullet untouched)', () => {
const editor = editorWith({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] },
{ type: 'bulletList', content: [{ type: 'listItem', content: [{ type: 'paragraph' }] }] },
{ type: 'image', attrs: { src: '/api/files/view/wf_img', alt: 'photo' } },
],
})
editor.commands.setTextSelection(10)
pressBackspace(editor)

expect(blockShape(editor)).toEqual(['paragraph', 'image', 'paragraph'])
expect(editor.state.selection.empty).toBe(true)
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
expect(editor.state.selection.$from.parent.textContent).toBe('hello')
editor.destroy()
})
})

describe('empty list-item Enter', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ function isInsideWrapper($from: ResolvedPos): boolean {
* container and strand an empty paragraph (a visible gap, and markdown that re-parses to a different
* document). Walking up while `childCount === 1` deletes the whole now-empty wrapper (the emptied list
* item, not just its paragraph) so no orphan `<li>` or empty continuation line is left behind.
*
* The selection left behind must be a CARET, never a NodeSelection: `Selection.near` can silently
* land a NodeSelection on an adjacent leaf (deleting the sole bullet at the top of a doc whose next
* block is an image selected that image), turning the user's next keystroke destructive — a second
* Backspace while "clearing the bullet" deleted the image, and typing would have replaced it. So:
* end of the previous textblock first; else a gap cursor at the deletion point when the neighbour is
* a leaf (typing there inserts a new block where the emptied one was, instead of replacing the leaf);
* else the next textblock.
*/
function removeEmptyWrappedBlock(editor: Editor, $from: ResolvedPos): boolean {
let depth = $from.depth
Expand All @@ -44,13 +52,31 @@ function removeEmptyWrappedBlock(editor: Editor, $from: ResolvedPos): boolean {
return editor.commands.command(({ tr, dispatch }) => {
if (dispatch) {
tr.delete(start, end)
tr.setSelection(Selection.near(tr.doc.resolve(start), -1))
const $gap = tr.doc.resolve(start)
tr.setSelection(
Selection.findFrom($gap, -1, true) ??
Comment thread
waleedlatif1 marked this conversation as resolved.
(isLeafGap($gap) ? new GapCursor($gap) : null) ??
Selection.findFrom($gap, 1, true) ??
Selection.near($gap, -1)
)
dispatch(tr.scrollIntoView())
}
return true
})
}

/**
* True when `$pos` is a block boundary a gap cursor is valid at in this schema: the following node is
* a selectable leaf (divider/image) and there is nothing before it, or another leaf — i.e. no textblock
* on either side for a normal caret to land in.
*/
function isLeafGap($pos: ResolvedPos): boolean {
const after = $pos.nodeAfter
if (!after || !SELECTABLE_LEAVES.has(after.type.name)) return false
const before = $pos.nodeBefore
return !before || SELECTABLE_LEAVES.has(before.type.name)
}

/**
* True while a `/` or `@` suggestion menu is open. Arrow keys must reach that menu's own handler, so
* the leaf-selection shortcuts below yield rather than stealing the key to select an adjacent divider.
Expand Down Expand Up @@ -146,6 +172,11 @@ export const RichMarkdownKeymap = Extension.create({
const { selection, doc } = editor.state
if (!selection.empty || selection.$from.parentOffset !== 0) return false
const { $from } = selection
// A gap cursor at the start of the doc resolves at the top level (`depth === 0`, offset 0):
// `$from.before(0)` below throws, and falling through instead is no better — TipTap's
// blockquote Backspace handler crashes on the same resolution (`$from.node(-1)` is
// undefined). There is nothing before the gap for Backspace to act on, so consume the key.
if ($from.depth === 0) return true
if ($from.parent.type.name === 'heading') {
return editor.commands.setParagraph()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* @vitest-environment jsdom
*
* The post-stream reconcile must poll the content query until a fetch shows the server content
* advanced past the pre-stream baseline — its exit is data-driven, and without the poll a single
* refetch racing the agent's write (or an invalidation that never reaches this surface) wedged the
* editor read-only until a window refocus or full reload. These drive the real
* `useEditableFileContent` engine through stream → settle → advance and assert the
* `refetchInterval` handed to the content query flips on and off with the `reconciling` phase.
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { queryState } = vi.hoisted(() => ({
queryState: {
fetched: undefined as string | undefined,
lastOptions: undefined as
| { refetchInterval?: number | false | (() => number | false) }
| undefined,
},
}))

vi.mock('@/hooks/queries/workspace-files', () => ({
useWorkspaceFileContent: (
_workspaceId: string,
_fileId: string,
_key: string,
_raw?: boolean,
options?: { refetchInterval?: number | false | (() => number | false) }
) => {
queryState.lastOptions = options
return { data: queryState.fetched, isLoading: queryState.fetched === undefined, error: null }
},
useUpdateWorkspaceFileContent: () => ({ mutateAsync: vi.fn(async () => ({ success: true })) }),
}))

vi.mock('idb-keyval', () => ({
get: vi.fn(async () => undefined),
set: vi.fn(async () => {}),
del: vi.fn(async () => {}),
}))

import {
RECONCILING_REFETCH_INTERVAL_MS,
RECONCILING_REFETCH_SLOW_INTERVAL_MS,
RECONCILING_REFETCH_WINDOW_MS,
useEditableFileContent,
} from './use-editable-file-content'

const FILE = {
id: 'f1',
key: 'workspace/ws-1/123-abc-doc.md',
name: 'doc.md',
type: 'text/markdown',
folderId: null,
} as any

interface ProbeProps {
streamingContent?: string
isAgentEditing?: boolean
}

let container: HTMLDivElement | null = null
let root: Root | null = null
let latest: ReturnType<typeof useEditableFileContent> | null = null

function Probe(props: ProbeProps) {
latest = useEditableFileContent({
file: FILE,
workspaceId: 'ws-1',
canEdit: true,
streamingContent: props.streamingContent,
isAgentEditing: props.isAgentEditing,
})
return null
}

function render(props: ProbeProps) {
act(() => {
root?.render(<Probe {...props} />)
})
}

/** The interval value react-query would currently be using (resolving the function form). */
function currentInterval(): number | false {
const raw = queryState.lastOptions?.refetchInterval
return typeof raw === 'function' ? raw() : (raw ?? false)
}

beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
queryState.fetched = undefined
queryState.lastOptions = undefined
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})

afterEach(() => {
if (root) act(() => root?.unmount())
container?.remove()
container = null
root = null
latest = null
vi.useRealTimers()
})

describe('reconcile refetch polling', () => {
it('polls only while reconciling, and stops the moment the fetched content advances', () => {
const BASELINE = '# Doc\n\nstart\n'
const STREAMED = '# Doc\n\nstart\n\n![img](/api/files/view/wf_x)\n'

// Mount mid-stream (fetch not resolved yet): no polling.
render({ streamingContent: '', isAgentEditing: true })
expect(currentInterval()).toBe(false)

// Baseline fetch resolves during the stream; chunks arrive: still no polling.
queryState.fetched = BASELINE
render({ streamingContent: '# Doc\n\nstart\n\n![img](', isAgentEditing: true })
render({ streamingContent: STREAMED, isAgentEditing: true })
expect(currentInterval()).toBe(false)
expect(latest?.isStreamInteractionLocked).toBe(true)

// Stream settles but the cached fetch still shows the pre-stream baseline: reconciling → poll.
render({ streamingContent: undefined, isAgentEditing: false })
expect(latest?.isStreamInteractionLocked).toBe(true)
expect(currentInterval()).toBe(RECONCILING_REFETCH_INTERVAL_MS)

// A poll returns the advanced server content: finalize, unlock, polling off.
queryState.fetched = STREAMED
render({ streamingContent: undefined, isAgentEditing: false })
expect(latest?.isStreamInteractionLocked).toBe(false)
expect(latest?.content).toBe(STREAMED)
expect(currentInterval()).toBe(false)
})

it('degrades to the slow cadence after the fast window — never stops outright while reconciling', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000_000)
const BASELINE = '# Doc\n\nstart\n'

render({ streamingContent: '', isAgentEditing: true })
queryState.fetched = BASELINE
render({ streamingContent: `${BASELINE}\nmore`, isAgentEditing: true })
render({ streamingContent: undefined, isAgentEditing: false })
expect(currentInterval()).toBe(RECONCILING_REFETCH_INTERVAL_MS)

vi.setSystemTime(1_000_000 + RECONCILING_REFETCH_WINDOW_MS - 1)
expect(currentInterval()).toBe(RECONCILING_REFETCH_INTERVAL_MS)

// A write landing late (slow job, replica catch-up) must still be picked up automatically —
// the poll degrades in rate but the editor can never end up with no recovery path at all.
vi.setSystemTime(1_000_000 + RECONCILING_REFETCH_WINDOW_MS)
expect(currentInterval()).toBe(RECONCILING_REFETCH_SLOW_INTERVAL_MS)
})

it('never polls during plain at-rest editing (no stream involved)', () => {
queryState.fetched = '# Plain\n\ndoc\n'
render({})
expect(latest?.isStreamInteractionLocked).toBe(false)
expect(currentInterval()).toBe(false)
act(() => latest?.setDraftContent('# Plain\n\ndoc edited\n'))
render({})
expect(currentInterval()).toBe(false)
})
})
Loading
Loading