fix(files): unwedge post-stream read-only editor; stop bullet Backspace from destroying the image below (#5608)

* fix(rich-markdown-editor): stop Backspace on an empty bullet from destroying the image below it

Reported: clearing an empty bullet with an image after it "nuked the bullet point and the image".
Reproduced: when the emptied bullet is the doc's first block, removeEmptyWrappedBlock's
Selection.near(resolve(start), -1) finds no text position behind it and silently lands a
NodeSelection on the FOLLOWING image — so the user's next keystroke is destructive (a second
Backspace while clearing deletes the image; typing replaces it). Only-first-block explains why it
wouldn't re-repro. The selection left behind is now always a caret: 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 instead of replacing the image), else the next textblock.

The regression test surfaced two adjacent gap-cursor crashes on Backspace, both reachable on
current staging whenever a gap cursor exists (e.g. between two dividers/images, the
data-gap-between-leaves state):
- our own handler threw RangeError from $from.before(0) on a depth-0 (doc-start) gap cursor
- with that guarded by falling through, TipTap's blockquote Backspace handler crashes on the same
  resolution ($from.node(-1) is undefined) — so a doc-start gap cursor consumes the key instead
  (there is nothing before it for Backspace to act on)

* fix(files): poll the content query while the post-stream reconcile waits, so the editor can't wedge read-only

Reported: images "don't get selected sometimes" (can't grab/drag/resize, doc uneditable) until a
full refresh. Reproduced in a real-Chromium harness driving the actual RichMarkdownEditor + engine:
after an agent stream settles, the reconcile phase exits only when a fetch shows the server content
advanced past the pre-stream baseline — but that exit had no retry. A single refetch racing the
agent's write (or the mothership invalidation never reaching this surface — it's the only place
that invalidates this query) left the editor locked read-only indefinitely: contenteditable=false,
images not grabbable, until refetchOnWindowFocus or a reload happened to run.

Fix: while (and only while) the reducer is in `reconciling`, the content query polls via
react-query's refetchInterval — the same pattern this module already uses for the generated-doc
409 polling, and like it, bounded (45s window; past that the write has almost certainly failed and
refetchOnWindowFocus remains the recovery). The interval is the function form reading the phase
through a ref, re-evaluated by react-query after every fetch, so polling stops the moment a fetch
advances without needing an extra render.

Harness (real Chromium, real editor + engine, in-memory server): pre-fix the editor stays
editable=false with fetches frozen at 1 indefinitely while the server holds the new content;
post-fix it unlocks within one poll (~1.5s), polling stops immediately after finalize, and the
streamed image click-selects. Unit tests drive stream -> settle -> advance through the real engine
and assert the interval flips on/off with the phase (2 of 3 fail pre-fix), plus the bounded window
and a no-polling guard for plain at-rest editing.

* test(files): cover the refetchInterval passthrough against real react-query

Both consumers' test setups (the reconcile unit tests and the browser harness) replace
@/hooks/queries/workspace-files, so the real hook's two changed lines were exercised by nothing but
the type-checker. These render the real useWorkspaceFileContent under a real QueryClientProvider
with a stubbed fetch: no polling by default, polling with a numeric interval, and the function form
re-evaluated so flipping its condition stops the polling — the exact mechanism the reconcile fix
depends on. The two polling tests fail against the pre-fix hook.

* fix(files): degrade reconcile polling to a slow cadence instead of stopping; prove findFrom textOnly never leaf-selects

Greptile round 1:
- Real gap in my bounded window: past 45s the poll stopped outright, leaving the reducer wedged in
  reconciling with only focus-refetch/reload as recovery — the exact failure shape this PR exists
  to remove, just later. Polling now degrades to a 15s cadence instead of stopping, so a write
  landing late (slow job, replica catch-up) is still picked up automatically; react-query pauses
  interval refetches in background tabs by default, so an abandoned doc doesn't poll unattended.
- Refuted with source + an executable test: Selection.findFrom($gap, -1, true) cannot return a
  NodeSelection — prosemirror-state's findSelectionIn skips atoms entirely under textOnly
  (`!text && isSelectable`). New regression test pins the exact scenario (image directly BEFORE the
  emptied bullet): backward search returns null, the gap-cursor/forward-caret branches take over,
  and the image is never silently selected.
This commit is contained in:
Waleed
2026-07-11 16:56:18 -07:00
committed by GitHub
parent d165712d54
commit 0aa23090e9
8 changed files with 461 additions and 6 deletions
@@ -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'
@@ -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', () => {
@@ -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
@@ -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) ??
(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.
@@ -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()
}
@@ -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)
})
})
@@ -28,6 +28,25 @@ const GENERATED_SOURCE_FILE_TYPES = new Set([
'text/x-python-xlsx',
])
/**
* Poll cadence for the content query while the post-stream reconcile waits for a fetch showing the
* server content advanced past the pre-stream baseline. Only active during `reconciling` — a short
* window ending the moment a fetch advances — so the cost is a few small GETs after an agent edit.
*/
export const RECONCILING_REFETCH_INTERVAL_MS = 1500
/**
* How long the reconcile polls at the fast cadence after a stream settles. A write that hasn't
* landed within this window has almost certainly failed or is badly delayed, so polling degrades
* to {@link RECONCILING_REFETCH_SLOW_INTERVAL_MS} — never stopping outright, so the editor can't
* end up locked read-only with no automatic recovery (react-query pauses interval refetches in
* background tabs by default, so a wedged doc left open does not poll unattended).
*/
export const RECONCILING_REFETCH_WINDOW_MS = 45_000
/** Slow-poll cadence once the fast window has elapsed without the server content advancing. */
export const RECONCILING_REFETCH_SLOW_INTERVAL_MS = 15_000
interface UseEditableFileContentOptions {
file: WorkspaceFileRecord
workspaceId: string
@@ -99,6 +118,7 @@ function useFileContentState(options: SyncTextEditorContentStateOptions) {
savedContent: state.savedContent,
isInitialized: state.phase !== 'uninitialized',
isStreamInteractionLocked: state.phase === 'streaming' || state.phase === 'reconciling',
isReconciling: state.phase === 'reconciling',
setDraftContent,
markSavedContent,
}
@@ -127,6 +147,25 @@ export function useEditableFileContent({
onDirtyChangeRef.current = onDirtyChange
onSaveStatusChangeRef.current = onSaveStatusChange
/**
* Mirrors the reducer's `reconciling` phase (assigned below the reducer hook; read here through a
* stable function that react-query re-evaluates after every fetch and options pass, so polling
* starts and stops with the phase, no extra re-render required). While reconciling — the stream
* ended but no fetch has shown the server content advancing past the pre-stream baseline yet —
* the content query polls. The reconcile's exit is data-driven and this is its only retry: a
* single refetch that races the agent's write (or an invalidation that never reaches this
* surface) would otherwise leave the editor read-only until a window refocus or a full reload.
*/
const isReconcilingRef = useRef(false)
const reconcilingSinceRef = useRef(0)
const reconcileRefetchInterval = useCallback(() => {
if (!isReconcilingRef.current) return false
if (Date.now() - reconcilingSinceRef.current >= RECONCILING_REFETCH_WINDOW_MS) {
return RECONCILING_REFETCH_SLOW_INTERVAL_MS
}
return RECONCILING_REFETCH_INTERVAL_MS
}, [])
const {
data: fetchedContent,
isLoading,
@@ -135,7 +174,8 @@ export function useEditableFileContent({
workspaceId,
file.id,
file.key,
GENERATED_SOURCE_FILE_TYPES.has(file.type)
GENERATED_SOURCE_FILE_TYPES.has(file.type),
{ refetchInterval: reconcileRefetchInterval }
)
/**
@@ -165,6 +205,7 @@ export function useEditableFileContent({
savedContent,
isInitialized,
isStreamInteractionLocked: isStreamPhaseLocked,
isReconciling,
setDraftContent,
markSavedContent,
} = useFileContentState({
@@ -172,6 +213,8 @@ export function useEditableFileContent({
fetchedContent: baselineContent,
streamingContent,
})
if (isReconciling && !isReconcilingRef.current) reconcilingSinceRef.current = Date.now()
isReconcilingRef.current = isReconciling
const isStreamInteractionLocked = isStreamPhaseLocked || Boolean(isAgentEditing)
+5 -2
View File
@@ -1044,7 +1044,10 @@ export const BrexBlock: BlockConfig<BrexResponse> = {
routingNumber: { type: 'string', description: 'Bank routing number of the cash account' },
primary: { type: 'boolean', description: 'Whether the cash account is primary' },
accountId: { type: 'string', description: 'Account ID of the budget or spend limit' },
description: { type: 'string', description: 'Description of the budget or spend limit' },
description: {
type: 'string',
description: 'Description of the budget, spend limit, or transfer',
},
parentBudgetId: { type: 'string', description: 'Parent budget ID' },
ownerUserIds: { type: 'json', description: 'Owner user IDs of the budget or spend limit' },
memberUserIds: { type: 'json', description: 'Member user IDs of the spend limit' },
@@ -1052,7 +1055,7 @@ export const BrexBlock: BlockConfig<BrexResponse> = {
spendType: { type: 'string', description: 'Spend type of the spend limit' },
startDate: { type: 'string', description: 'Start date of the budget or spend limit' },
endDate: { type: 'string', description: 'End date of the budget or spend limit' },
amount: { type: 'json', description: 'Amount of the budget' },
amount: { type: 'json', description: 'Amount of the budget or transfer' },
spendBudgetStatus: { type: 'string', description: 'Status of the budget' },
limitType: { type: 'string', description: 'Limit type of the budget' },
currentPeriodBalance: {
@@ -0,0 +1,104 @@
/**
* @vitest-environment jsdom
*
* `useWorkspaceFileContent` against REAL react-query (no module mocks): the `refetchInterval`
* option must reach the query — the editor's post-stream reconcile depends on it to poll until the
* server content advances (see `use-editable-file-content.ts`), and both its consumers' test
* setups replace this module, so without this file the passthrough itself would be exercised by
* nothing but the type-checker.
*/
import { act, type ReactNode } from 'react'
import { sleep } from '@sim/utils/helpers'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkspaceFileContent } from '@/hooks/queries/workspace-files'
let fetchCount = 0
beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
fetchCount = 0
vi.stubGlobal(
'fetch',
vi.fn(async () => {
fetchCount += 1
return new Response('# content', { status: 200 })
})
)
})
afterEach(() => {
vi.unstubAllGlobals()
})
function renderContentHook(options?: {
refetchInterval?: number | false | (() => number | false)
}): { unmount: () => void } {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const container = document.createElement('div')
const root: Root = createRoot(container)
function Probe() {
useWorkspaceFileContent('ws-1', 'file-1', 'workspace/ws-1/123-abc-doc.md', false, options)
return null
}
function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
act(() => {
root.render(
<Wrapper>
<Probe />
</Wrapper>
)
})
return {
unmount: () => {
act(() => root.unmount())
queryClient.clear()
},
}
}
describe('useWorkspaceFileContent refetchInterval passthrough', () => {
it('fetches once and does not poll by default', async () => {
const { unmount } = renderContentHook()
await act(async () => {
await sleep(150)
})
expect(fetchCount).toBe(1)
unmount()
})
it('polls when a numeric refetchInterval is passed', async () => {
const { unmount } = renderContentHook({ refetchInterval: 30 })
await act(async () => {
await sleep(200)
})
expect(fetchCount).toBeGreaterThanOrEqual(3)
unmount()
})
it('function form is re-evaluated so flipping its condition stops the polling', async () => {
let polling = true
const { unmount } = renderContentHook({ refetchInterval: () => (polling ? 30 : false) })
await act(async () => {
await sleep(200)
})
expect(fetchCount).toBeGreaterThanOrEqual(3)
polling = false
await act(async () => {
await sleep(100)
})
const settled = fetchCount
await act(async () => {
await sleep(150)
})
expect(fetchCount).toBe(settled)
unmount()
})
})
+10 -1
View File
@@ -137,12 +137,20 @@ async function fetchWorkspaceFileContent(url: string, signal?: AbortSignal): Pro
* Hook to fetch workspace file content as text.
* `key` (the storage object key) is forwarded into the query key factory so that a new
* storage key (e.g. after a file is re-uploaded) correctly busts the cache.
*
* `refetchInterval` lets a caller poll while waiting for the server content to advance — the
* editor's post-stream reconcile (see `use-editable-file-content.ts`) exits only when a fetch
* returns content that moved past its baseline, and would otherwise wedge read-only forever if
* its single refetch raced the agent's write. The function form is re-evaluated by react-query
* after every fetch and options pass, so a condition read through a ref stops the polling as soon
* as it flips — no re-render required.
*/
export function useWorkspaceFileContent(
workspaceId: string,
fileId: string,
key: string,
raw?: boolean
raw?: boolean,
options?: { refetchInterval?: number | false | (() => number | false) }
) {
const source = useFileContentSource()
return useQuery({
@@ -152,6 +160,7 @@ export function useWorkspaceFileContent(
enabled: !!workspaceId && !!fileId && !!key,
staleTime: WORKSPACE_FILE_CONTENT_STALE_TIME,
refetchOnWindowFocus: 'always',
refetchInterval: options?.refetchInterval ?? false,
})
}
+1 -1
View File
@@ -31,7 +31,7 @@ export const brexGetCompanyTool: ToolConfig<BrexApiKeyParams, BrexGetCompanyResp
id: data.id ?? '',
legalName: data.legal_name ?? '',
mailingAddress: data.mailing_address ?? null,
accountType: data.accountType ?? null,
accountType: data.account_type ?? null,
},
}
},