diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx index 6e360328..871f6bbe 100644 --- a/packages/app/src/App.test.tsx +++ b/packages/app/src/App.test.tsx @@ -116,6 +116,17 @@ import { configureAppRuntime, createDefaultAppRuntime, resetAppRuntimeForTests } import { showAppToast } from "./lib/app-toast"; import { createShardedTest } from "./test/shard"; +const scheduleMarkdownSourceEditorPreloadMock = vi.hoisted(() => vi.fn(() => vi.fn())); + +vi.mock("./components/LazyMarkdownSourceEditor", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + scheduleMarkdownSourceEditorPreload: scheduleMarkdownSourceEditorPreloadMock + }; +}); + installAppTestHarness(); // Vitest shards files only, so CI needs a local registration boundary to split this monolithic suite by test title. @@ -617,6 +628,15 @@ describe("Markra workspace", () => { expect(shell).toHaveClass("overscroll-none"); }); + it("schedules source editor preloading after the visual editor is ready", async () => { + scheduleMarkdownSourceEditorPreloadMock.mockClear(); + + const { container } = renderApp(); + + await waitFor(() => expect(container.querySelector(".cm-editor")).toBeInTheDocument()); + await waitFor(() => expect(scheduleMarkdownSourceEditorPreloadMock).toHaveBeenCalled()); + }); + it("imports local images through the native file menu without replacing manual image insertion", async () => { const localImage = new File([new Uint8Array([1, 2, 3])], "Local Diagram.png", { type: "image/png" }); mockedConsumeWelcomeDocumentState.mockResolvedValue(false); @@ -6531,6 +6551,53 @@ describe("Markra workspace", () => { expect(container.querySelectorAll(".cm-markra-empty-line")).toHaveLength(1); }); + it("preserves the active selection and editor focus across visual and source modes", async () => { + const syntheticContent = "# Synthetic cursor\n\nalpha beta gamma\n\nomega"; + mockOpenMarkdownFile({ + content: syntheticContent, + name: "synthetic.md", + path: mockNativePath + }); + renderApp(); + + fireEvent.keyDown(window, { key: "o", metaKey: true }); + await expectVisibleMarkdownText("Synthetic cursor"); + + const visualEditor = screen.getByRole("textbox", { name: "Markdown document" }); + const visualView = getMarkdownSourceView(visualEditor); + const visualCursor = syntheticContent.indexOf("beta") + 2; + const visualSelection = EditorSelection.single(visualCursor, syntheticContent.indexOf("alpha")); + act(() => { + visualView.dispatch({ selection: visualSelection }); + }); + + await selectEditorViewMode("Source code"); + + const sourceEditor = await screen.findByRole("textbox", { name: "Markdown source" }); + const sourceView = getMarkdownSourceView(sourceEditor); + await waitFor(() => { + expect(sourceView.state.selection.eq(visualSelection)).toBe(true); + expect(sourceEditor).toHaveFocus(); + }); + + const sourceCursor = syntheticContent.indexOf("omega") + 3; + const sourceSelection = EditorSelection.single(sourceCursor, syntheticContent.indexOf("gamma")); + act(() => { + sourceView.dispatch({ selection: sourceSelection }); + }); + const requestMeasureSpy = vi.spyOn(visualView, "requestMeasure"); + requestMeasureSpy.mockClear(); + + await selectEditorViewMode("Preview"); + + await waitFor(() => { + expect(visualView.state.selection.eq(sourceSelection)).toBe(true); + expect(visualEditor).toHaveFocus(); + expect(requestMeasureSpy).toHaveBeenCalled(); + }); + requestMeasureSpy.mockRestore(); + }); + it("commits pending visual IME content before source mode mounts", async () => { renderApp(); diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 1c5f159d..dba74b98 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -28,7 +28,10 @@ import { type MarkdownExportSnapshot } from "./components/MarkdownExportDocument"; import { MarkdownPaper } from "./components/MarkdownPaper"; -import { LazyMarkdownSourceEditor } from "./components/LazyMarkdownSourceEditor"; +import { + LazyMarkdownSourceEditor, + scheduleMarkdownSourceEditorPreload +} from "./components/LazyMarkdownSourceEditor"; import { MarkdownTabsBar, markdownTabDragDataType, @@ -90,6 +93,7 @@ import { useSettingsWindowShortcut } from "./hooks/useNativeBindings"; import type { EditorView } from "@codemirror/view"; +import { EditorSelection } from "@codemirror/state"; import { aiTranslationLanguageName, clampNumber, @@ -289,7 +293,15 @@ export async function refreshImportedAttachmentTree(refreshTree: () => Promise; type EditorMode = "source" | "split" | "visual"; type EditorSurface = "source" | "visual"; +type EditorSelectionSnapshot = { + mainIndex: number; + ranges: Array<{ + anchor: number; + head: number; + }>; +}; type DocumentTabViewState = { + selection?: EditorSelectionSnapshot; sourceScrollTop?: number; visualScrollTop?: number; }; @@ -298,6 +310,29 @@ type PendingEditorModeScroll = { tabId: string; targetSurface: EditorSurface; }; +type PendingEditorModeSelection = { + selection: EditorSelectionSnapshot; + tabId: string; + targetSurface: EditorSurface; +}; + +function boundedEditorSelection(selection: EditorSelectionSnapshot, documentLength: number) { + const ranges = selection.ranges.length > 0 + ? selection.ranges.map((range) => EditorSelection.range( + Math.max(0, Math.min(documentLength, range.anchor)), + Math.max(0, Math.min(documentLength, range.head)) + )) + : [EditorSelection.cursor(0)]; + + return EditorSelection.create(ranges, Math.max(0, Math.min(ranges.length - 1, selection.mainIndex))); +} + +function editorSelectionSnapshot(selection: EditorSelection): EditorSelectionSnapshot { + return { + mainIndex: selection.mainIndex, + ranges: selection.ranges.map(({ anchor, head }) => ({ anchor, head })) + }; +} export { runEditorLinkCommand } from "./app/editor-link-command"; export { globalSearchDebounceMs } from "./hooks/useWorkspaceSearch"; @@ -481,6 +516,7 @@ function WorkspaceApp() { const [splitVisualPanePercent, setSplitVisualPanePercent] = useState(defaultSplitVisualPanePercent); const [sideDocumentMainPanePercent, setSideDocumentMainPanePercent] = useState(defaultSideDocumentMainPanePercent); const [editorTabDropTargetActive, setEditorTabDropTargetActive] = useState(false); + const [sourceEditorReadySequence, setSourceEditorReadySequence] = useState(0); const [visualEditorReadySequence, setVisualEditorReadySequence] = useState(0); const [exportSnapshot, setExportSnapshot] = useState(null); const sourceMode = editorMode === "source"; @@ -505,10 +541,12 @@ function WorkspaceApp() { }); const largeMarkdownVisualBlockedRef = useRef(false); const mainDocumentPaneRef = useRef(null); + const sourceEditorRef = useRef(null); const sourceScrollRef = useRef(null); const visualScrollRef = useRef(null); const mainVisualEditorsRef = useRef(new Map()); const documentTabViewStatesRef = useRef(new Map()); + const pendingEditorModeSelectionRef = useRef(null); const pendingEditorModeScrollRef = useRef(null); const splitSurfaceRef = useRef(null); const sideDocumentSurfaceRef = useRef(null); @@ -902,6 +940,14 @@ function WorkspaceApp() { handleVisualEditorReady, rememberMarkdownTabVisualBaseline ]); + const handleSourceEditorReady = useCallback((readyEditor: EditorView | null, disposedEditor?: EditorView) => { + if (readyEditor) { + sourceEditorRef.current = readyEditor; + setSourceEditorReadySequence((current) => current + 1); + } else if (sourceEditorRef.current === disposedEditor) { + sourceEditorRef.current = null; + } + }, []); useEffect(() => { if (!activeTabId) { handleVisualEditorReady(null); @@ -1285,6 +1331,16 @@ function WorkspaceApp() { if (activeImageFile || !activeTabId) return; const nextState: DocumentTabViewState = {}; + const activeEditor = editorMode === "source" + ? sourceEditorRef.current + : editorMode === "visual" + ? mainVisualEditorsRef.current.get(activeTabId) ?? null + : activeEditorSurface === "source" + ? sourceEditorRef.current + : mainVisualEditorsRef.current.get(activeTabId) ?? null; + if (activeEditor) { + nextState.selection = editorSelectionSnapshot(activeEditor.state.selection); + } if (editorMode === "visual" && visualScrollRef.current) { nextState.visualScrollTop = visualScrollRef.current.scrollTop; } else if (editorMode === "source" && sourceScrollRef.current) { @@ -1297,7 +1353,20 @@ function WorkspaceApp() { } } if (Object.keys(nextState).length > 0) saveDocumentTabViewState(activeTabId, nextState); + return nextState.selection; }, [activeEditorSurface, activeImageFile, activeTabId, editorMode, saveDocumentTabViewState]); + const queueEditorModeSelection = useCallback(( + targetSurface: EditorSurface, + selection: EditorSelectionSnapshot | undefined + ) => { + if (!activeTabId || !selection) return; + + pendingEditorModeSelectionRef.current = { + selection, + tabId: activeTabId, + targetSurface + }; + }, [activeTabId]); const queueEditorModeScroll = useCallback((targetSurface: EditorSurface) => { if (!activeTabId) return; @@ -2931,6 +3000,13 @@ function WorkspaceApp() { const titleDocumentKind = activeImageFile ? "image" : hasOpenDocument ? "file" : "folder"; const sourceModeAvailable = hasOpenDocument && !activeImageFile; const supportsAiThinking = selectedInlineAiModel?.capabilities.includes("reasoning") ?? false; + useEffect(() => { + if (editorMode !== "visual" || !sourceModeAvailable || !activeTabId) return; + // Keep visual editor setup on the startup path; warm the source chunk only after it is usable. + if (!mainVisualEditorsRef.current.has(activeTabId)) return; + + return scheduleMarkdownSourceEditorPreload(); + }, [activeTabId, editorMode, sourceModeAvailable, visualEditorReadySequence]); useEffect(() => { if (activeEditorSurface !== "source") return; @@ -3568,7 +3644,7 @@ function WorkspaceApp() { if (!sourceModeAvailable) return; if (nextMode === editorMode) return; - captureActiveDocumentViewState(); + const selection = captureActiveDocumentViewState(); // IME changes can still be pending in the visual surface when source mode // mounts, so snapshot the originating editor before changing surfaces. commitActiveVisualMarkdown(); @@ -3576,6 +3652,7 @@ function WorkspaceApp() { if (nextMode === "visual") { if (sourceMode) syncSourceEditsToVisualHistory(); queueEditorModeScroll("visual"); + queueEditorModeSelection("visual", selection); setEditorMode("visual"); setActiveEditorSurface("visual"); return; @@ -3585,6 +3662,7 @@ function WorkspaceApp() { updateActiveAiSelection(null); handleAiCommandClose(); queueEditorModeScroll("source"); + queueEditorModeSelection("source", selection); setEditorMode("source"); setActiveEditorSurface("source"); return; @@ -3593,6 +3671,7 @@ function WorkspaceApp() { updateActiveAiSelection(null); handleAiCommandClose(); if (sideDocumentGroup) clearSideDocumentGroup(); + queueEditorModeSelection(sourceMode ? "source" : "visual", selection); setEditorMode("split"); setActiveEditorSurface(sourceMode ? "source" : "visual"); }, [ @@ -3602,6 +3681,7 @@ function WorkspaceApp() { editorMode, handleAiCommandClose, queueEditorModeScroll, + queueEditorModeSelection, sideDocumentGroup, sourceMode, sourceModeAvailable, @@ -3827,6 +3907,23 @@ function WorkspaceApp() { )) { pendingEditorModeScrollRef.current = null; } + + const pendingSelection = pendingEditorModeSelectionRef.current; + const targetSurface = editorMode === "split" ? activeEditorSurface : editorMode; + if (pendingSelection?.tabId === activeTabId && pendingSelection.targetSurface === targetSurface) { + const targetEditor = targetSurface === "source" + ? sourceEditorRef.current + : mainVisualEditorsRef.current.get(activeTabId) ?? null; + if (targetEditor) { + const selection = boundedEditorSelection(pendingSelection.selection, targetEditor.state.doc.length); + // CodeMirror may measure while its visual surface is hidden; refresh it only after React reveals the target. + targetEditor.requestMeasure(); + targetEditor.dispatch({ selection, scrollIntoView: true }); + targetEditor.focus(); + saveDocumentTabViewState(activeTabId, { selection: editorSelectionSnapshot(selection) }); + pendingEditorModeSelectionRef.current = null; + } + } }); return () => { @@ -3834,11 +3931,13 @@ function WorkspaceApp() { }; }, [ activeImageFile, + activeEditorSurface, activeTabId, document.revision, editorMode, hasOpenDocument, saveDocumentTabViewState, + sourceEditorReadySequence, visualEditorReadySequence ]); const aiAgentContext = useMemo(() => ({ @@ -4267,6 +4366,13 @@ function WorkspaceApp() { ] ); const mainVisualEditorTabs = documentTabs.filter((tab) => tab.open); + const pendingSourceSelection = pendingEditorModeSelectionRef.current; + const activeSourceInitialSelection = pendingSourceSelection?.tabId === activeTabId + && pendingSourceSelection.targetSurface === "source" + ? pendingSourceSelection.selection + : activeTabId + ? documentTabViewStatesRef.current.get(activeTabId)?.selection + : undefined; const mainVisualEditors = ( <> {mainVisualEditorTabs.map((tab) => { @@ -4727,6 +4833,7 @@ function WorkspaceApp() { contentWidthPx={activeEditorContentWidthPx} editorFontFamily={editorPreferences.preferences.editorFontFamily} extendedSyntax={editorPreferences.preferences.extendedSyntax} + initialSelection={activeSourceInitialSelection} language={appLanguage.language} lineHeight={editorPreferences.preferences.lineHeight} onChange={(content) => handleSourceMarkdownTabChange( @@ -4738,6 +4845,7 @@ function WorkspaceApp() { )} onContentWidthChange={editorWidthResizerVisible ? handleEditorContentWidthChange : undefined} onContentWidthResizeEnd={editorWidthResizerVisible ? handleEditorContentWidthResizeEnd : undefined} + onEditorReady={handleSourceEditorReady} onScroll={handleSourcePaneScroll} onSelectionTextChange={updateSelectedWordCount} readOnly={readOnlyMode} @@ -4765,6 +4873,7 @@ function WorkspaceApp() { contentWidthPx={activeEditorContentWidthPx} editorFontFamily={editorPreferences.preferences.editorFontFamily} extendedSyntax={editorPreferences.preferences.extendedSyntax} + initialSelection={activeSourceInitialSelection} language={appLanguage.language} lineHeight={editorPreferences.preferences.lineHeight} onChange={(content) => handleSourceMarkdownTabChange( @@ -4776,6 +4885,7 @@ function WorkspaceApp() { )} onContentWidthChange={editorWidthResizerVisible ? handleEditorContentWidthChange : undefined} onContentWidthResizeEnd={editorWidthResizerVisible ? handleEditorContentWidthResizeEnd : undefined} + onEditorReady={handleSourceEditorReady} onScroll={handleSourcePaneScroll} onSelectionTextChange={updateSelectedWordCount} readOnly={readOnlyMode} diff --git a/packages/app/src/components/LazyMarkdownSourceEditor.tsx b/packages/app/src/components/LazyMarkdownSourceEditor.tsx index 6b7fe411..4a8311c9 100644 --- a/packages/app/src/components/LazyMarkdownSourceEditor.tsx +++ b/packages/app/src/components/LazyMarkdownSourceEditor.tsx @@ -6,8 +6,61 @@ import { import { editorFontFamilyCssValue } from "../lib/editor-font"; import type { MarkdownSourceEditorProps } from "./MarkdownSourceEditor"; +type MarkdownSourceEditorModule = typeof import("./MarkdownSourceEditor"); +type MarkdownSourceEditorPreloadTarget = { + cancelIdleCallback?: (handle: number) => unknown; + clearTimeout: (handle: number) => unknown; + requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number; + setTimeout: (callback: () => void, delay: number) => number; +}; + +const markdownSourceEditorPreloadIdleTimeoutMs = 1_500; +const markdownSourceEditorPreloadFallbackDelayMs = 800; +let markdownSourceEditorModulePromise: Promise | null = null; + +function loadMarkdownSourceEditor() { + if (markdownSourceEditorModulePromise) return markdownSourceEditorModulePromise; + + const modulePromise = import("./MarkdownSourceEditor"); + markdownSourceEditorModulePromise = modulePromise; + void modulePromise.catch(() => { + if (markdownSourceEditorModulePromise === modulePromise) markdownSourceEditorModulePromise = null; + }); + + return modulePromise; +} + +export function preloadMarkdownSourceEditor() { + return loadMarkdownSourceEditor(); +} + +export function scheduleMarkdownSourceEditorPreload( + target: MarkdownSourceEditorPreloadTarget = window +): () => void { + if (markdownSourceEditorModulePromise) return () => {}; + + const startPreload = () => { + void preloadMarkdownSourceEditor().catch(() => {}); + }; + if (target.requestIdleCallback) { + const handle = target.requestIdleCallback(startPreload, { + timeout: markdownSourceEditorPreloadIdleTimeoutMs + }); + + return () => { + target.cancelIdleCallback?.(handle); + }; + } + + const handle = target.setTimeout(startPreload, markdownSourceEditorPreloadFallbackDelayMs); + + return () => { + target.clearTimeout(handle); + }; +} + const MarkdownSourceEditor = lazy(async () => { - const module = await import("./MarkdownSourceEditor"); + const module = await loadMarkdownSourceEditor(); return { default: module.MarkdownSourceEditor }; }); @@ -56,7 +109,16 @@ function MarkdownSourceEditorFallback({ className={`markdown-source-paper relative mx-auto min-h-screen w-full max-w-215 px-18 ${markdownSourceTopInsetClassName(topInset)} text-[16px] leading-[1.65] text-(--text-primary) max-[900px]:px-5.25`} data-editor-engine="source-loading" style={paperStyle} - /> + > +
+ + + + + + +
+ ); } diff --git a/packages/app/src/components/MarkdownSourceEditor.tsx b/packages/app/src/components/MarkdownSourceEditor.tsx index a7afcff8..ddcd7c07 100644 --- a/packages/app/src/components/MarkdownSourceEditor.tsx +++ b/packages/app/src/components/MarkdownSourceEditor.tsx @@ -36,12 +36,20 @@ export type MarkdownSourceEditorProps = { contentWidthPx?: number | null; editorFontFamily?: EditorFontFamilyPreference; extendedSyntax?: ExtendedSyntaxPreferences; + initialSelection?: { + mainIndex: number; + ranges: Array<{ + anchor: number; + head: number; + }>; + }; language?: AppLanguage; lineHeight?: number; onChange: (content: string) => unknown; onContentWidthChange?: (width: number) => unknown; onContentWidthResizeEnd?: () => unknown; onContentWidthResizeStart?: () => unknown; + onEditorReady?: (view: EditorView | null, disposedView?: EditorView) => unknown; onScroll?: (event: UIEvent) => unknown; onRedo?: () => unknown; onSelectionTextChange?: (text: string | null) => unknown; @@ -60,6 +68,20 @@ type MarkdownSourcePaperStyle = CSSProperties & { "--source-editor-font-family"?: string; }; +function boundedInitialSelection( + selection: NonNullable, + documentLength: number +) { + const ranges = selection.ranges.length > 0 + ? selection.ranges.map((range) => EditorSelection.range( + Math.max(0, Math.min(documentLength, range.anchor)), + Math.max(0, Math.min(documentLength, range.head)) + )) + : [EditorSelection.cursor(0)]; + + return EditorSelection.create(ranges, Math.max(0, Math.min(ranges.length - 1, selection.mainIndex))); +} + const externalSourceUpdate = Annotation.define(); function markdownSourceContentAttributes(label: string, readOnly: boolean): Extension { @@ -203,12 +225,14 @@ export function MarkdownSourceEditor({ contentWidthMin = editorCustomContentWidthMin, contentWidthPx = null, editorFontFamily = { family: null, source: "theme" }, + initialSelection, language = "en", lineHeight = 1.65, onChange, onContentWidthChange, onContentWidthResizeEnd, onContentWidthResizeStart, + onEditorReady, onScroll, onRedo, onSelectionTextChange, @@ -224,7 +248,9 @@ export function MarkdownSourceEditor({ }: MarkdownSourceEditorProps) { const editorContainerRef = useRef(null); const contentRef = useRef(content); + const initialSelectionRef = useRef(initialSelection); const onChangeRef = useRef(onChange); + const onEditorReadyRef = useRef(onEditorReady); const onRedoRef = useRef(onRedo); const onSelectionTextChangeRef = useRef(onSelectionTextChange); const onUndoRef = useRef(onUndo); @@ -258,6 +284,10 @@ export function MarkdownSourceEditor({ onChangeRef.current = onChange; }, [onChange]); + useEffect(() => { + onEditorReadyRef.current = onEditorReady; + }, [onEditorReady]); + useEffect(() => { onRedoRef.current = onRedo; }, [onRedo]); @@ -329,17 +359,24 @@ export function MarkdownSourceEditor({ parent: container, state: EditorState.create({ doc: contentRef.current, - extensions + extensions, + ...(initialSelectionRef.current + ? { + selection: boundedInitialSelection(initialSelectionRef.current, contentRef.current.length) + } + : {}) }) }); viewRef.current = view; + onEditorReadyRef.current?.(view); if (autoFocus) view.focus(); return () => { onSelectionTextChangeRef.current?.(null); view.destroy(); viewRef.current = null; + onEditorReadyRef.current?.(null, view); }; }, [extensions]); diff --git a/packages/app/src/components/SideDocumentPane.lazy.test.tsx b/packages/app/src/components/SideDocumentPane.lazy.test.tsx index 37f796a8..974e01f7 100644 --- a/packages/app/src/components/SideDocumentPane.lazy.test.tsx +++ b/packages/app/src/components/SideDocumentPane.lazy.test.tsx @@ -1,8 +1,11 @@ -import { render, screen } from "@testing-library/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { scheduleMarkdownSourceEditorPreload } from "./LazyMarkdownSourceEditor"; import { SideDocumentPane } from "./SideDocumentPane"; const sourceEditorModule = vi.hoisted(() => ({ - loads: 0 + loads: 0, + pending: new Promise(() => {}), + suspend: false })); vi.mock("./LargeMarkdownNotice", () => ({ @@ -17,19 +20,42 @@ vi.mock("./MarkdownSourceEditor", () => { sourceEditorModule.loads += 1; return { - MarkdownSourceEditor: ({ content }: { content: string }) => ( -
- {content} -
- ) + MarkdownSourceEditor: ({ content }: { content: string }) => { + if (sourceEditorModule.suspend) throw sourceEditorModule.pending; + + return ( +
+ {content} +
+ ); + } }; }); describe("SideDocumentPane source editor loading", () => { - it("loads the source editor module only when source mode is rendered", async () => { + beforeEach(() => { + sourceEditorModule.suspend = false; + }); + + it("uses a cancellable timeout when idle callbacks are unavailable", () => { + const clearTimeout = vi.fn(); + const setTimeout = vi.fn((_callback: () => void, _delay: number) => 23); + const cancelPreload = scheduleMarkdownSourceEditorPreload({ + clearTimeout, + setTimeout + }); + + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), expect.any(Number)); + + cancelPreload(); + + expect(clearTimeout).toHaveBeenCalledWith(23); + }); + + it("preloads the source editor during idle time and reuses it when source mode renders", async () => { const props = { bodyFontSize: 16, content: "# Source", @@ -47,9 +73,57 @@ describe("SideDocumentPane source editor loading", () => { expect(screen.getByTestId("visual-editor")).toBeInTheDocument(); expect(sourceEditorModule.loads).toBe(0); + let idleCallback: IdleRequestCallback | null = null; + const cancelIdleCallback = vi.fn(); + const requestIdleCallback = vi.fn((callback: IdleRequestCallback) => { + idleCallback = callback; + return 17; + }); + const cancelPreload = scheduleMarkdownSourceEditorPreload({ + cancelIdleCallback, + clearTimeout: vi.fn(), + requestIdleCallback, + setTimeout: vi.fn((_callback: () => void, _delay: number) => 19) + }); + + expect(requestIdleCallback).toHaveBeenCalledWith(expect.any(Function), { timeout: expect.any(Number) }); + expect(sourceEditorModule.loads).toBe(0); + + await act(async () => { + idleCallback?.({ didTimeout: false, timeRemaining: () => 10 }); + await Promise.resolve(); + }); + await waitFor(() => expect(sourceEditorModule.loads).toBe(1)); + rerender(); expect(await screen.findByRole("textbox", { name: "Markdown source" })).toHaveTextContent("# Source"); expect(sourceEditorModule.loads).toBe(1); + + cancelPreload(); + expect(cancelIdleCallback).toHaveBeenCalledWith(17); + }); + + it("shows a visible source-shaped placeholder while the editor loads", () => { + sourceEditorModule.suspend = true; + + const { container } = render( + {}} + revision={0} + /> + ); + + const fallback = container.querySelector('[data-editor-engine="source-loading"]'); + expect(fallback).toBeInTheDocument(); + expect(fallback?.querySelectorAll("[data-source-loading-line]")).toHaveLength(6); }); });