import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, it, expect, vi } from 'vitest'
import {
makeAppForTest,
provisionMarkdownFile,
provisionBinaryFile,
TestView,
} from '../../plugin/__tests__/test-helpers'
import { MainAreaViewContainer, LeafMount } from '../MainAreaViewContainer'
import { MainAreaContext, type MainAreaContextValue } from '../MainAreaContext'
import type { App } from '../../plugin/app'
import type { WorkspaceLeaf } from '../../plugin/workspace'
import type { View } from '../../plugin/views/view'
import { ItemView, type ReactItemView } from '../../plugin/views/item-view'
import { createElement, type ReactNode } from 'react'
// Stub provider value adequate for component-mount assertions. The full
// MarkdownLeafBinding consumes useDocumentSync; for component tests that only
// assert the dispatcher's behavior, a no-op handlers value suffices.
function makeStubContextValue(app: App): MainAreaContextValue {
return {
wikilinkHandlers: { onNavigateWikilink: () => {}, resolveLink: () => true },
editorMode: 'live-preview',
formattingActionManager: app.reamsFormattingActions,
vault: app.vault,
pluginApp: app,
}
}
describe('MainAreaViewContainer', () => {
afterEach(async () => {
// Drain binding teardown updates before Vitest disposes the jsdom window.
await act(async () => { cleanup() })
})
it('renders the React component wrapper for a ReactItemView leaf', async () => {
const ctx = await makeAppForTest()
const file = await provisionMarkdownFile(ctx, 'note.md', '# hi')
const leaf = ctx.workspace.getLeaf(true)
await leaf.openFile(file)
// Silence any console.error from CM6 initialization in jsdom; the dispatcher
// assertion below targets the wrapper element only.
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const { container } = render(
,
)
expect(container.querySelector('[data-reams-view="markdown"]')).not.toBeNull()
} finally {
errSpy.mockRestore()
}
})
it('mounts containerEl for a DOM-mounted ItemView leaf', async () => {
const ctx = await makeAppForTest()
ctx.workspace.registerViewType('demo-view', (l) => {
const v = new TestView(l, 'demo-view')
v.containerEl.dataset.testview = 'demo'
return v
})
ctx.workspace.registerViewTypeForContentType('public.data', 'demo-view')
const file = await provisionBinaryFile(
ctx,
'blob.unknownext',
'application/octet-stream',
new Uint8Array([0]),
)
const leaf = ctx.workspace.getLeaf(true)
await leaf.openFile(file)
const ready = vi.fn(() => {
expect(document.querySelector('[data-testview="demo"]')).not.toBeNull()
})
const { container } = render(
,
)
expect(container.querySelector('[data-testview="demo"]')).not.toBeNull()
expect(container.querySelector('[data-reams-view="demo-view"]')).not.toBeNull()
expect(ready).toHaveBeenCalledWith(leaf)
})
it('renders the empty state when no leaves exist', async () => {
const ctx = await makeAppForTest()
const { getByText } = render()
expect(getByText(/Open a document to start editing/i)).toBeInTheDocument()
})
// Rules-of-Hooks regression (R4). Toggle `leaf.view` between a real view
// and null while the same `LeafMount` instance stays mounted. React throws
// "Rendered fewer hooks than expected" when an early return shrinks the
// hook count between renders; this test catches a regression where the
// pre-fix LeafMount placed `if (!view) return null` between useRef and
// useMemo/useEffect.
it('tolerates leaf.view toggling between non-null and null without unmounting', async () => {
const ctx = await makeAppForTest()
ctx.workspace.registerViewType('demo-toggle', (l) => {
const v = new TestView(l, 'demo-toggle')
v.containerEl.dataset.toggleTestview = 'demo-toggle'
return v
})
ctx.workspace.registerViewTypeForContentType('public.data', 'demo-toggle')
const file = await provisionBinaryFile(
ctx,
'toggle.unknownext',
'application/octet-stream',
new Uint8Array([0]),
)
const leaf = ctx.workspace.getLeaf(true) as WorkspaceLeaf
await leaf.openFile(file)
const initialView = leaf.view
expect(initialView).not.toBeNull()
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const { rerender, container } = render()
expect(container.querySelector('[data-reams-view="demo-toggle"]')).not.toBeNull()
// Clear leaf.view (mimics the unregisterViewType cascade in
// workspace.ts:2028) and re-render. The hook count must not change
// between the two renders.
;(leaf as { view: View | null }).view = null
rerender()
expect(container.querySelector('[data-reams-view="demo-toggle"]')).toBeNull()
// Restore and re-render again; no hook order errors should be logged.
;(leaf as { view: View | null }).view = initialView
rerender()
expect(container.querySelector('[data-reams-view="demo-toggle"]')).not.toBeNull()
const hookErrors = errSpy.mock.calls
.map((args) => String(args[0] ?? ''))
.filter((msg) => /Rendered fewer hooks|hook order/i.test(msg))
expect(hookErrors).toEqual([])
} finally {
errSpy.mockRestore()
}
})
it('returns from an editor tab to a React query tab with fresh view ownership', async () => {
const ctx = await makeAppForTest()
class QueryView extends ItemView implements ReactItemView {
getViewType(): string { return 'query-view' }
getDisplayText(): string { return 'Exercise' }
getReactComponent(): ReactNode { return createElement('div', { 'data-testid': 'query-surface' }, 'Exercise query') }
}
const queryLeaf = ctx.workspace.getLeaf(true)
queryLeaf.view = new QueryView(queryLeaf)
const editorLeaf = ctx.workspace.getLeaf(true)
ctx.workspace.setActiveLeaf(editorLeaf)
ctx.workspace.registerViewType('query-view', leaf => new QueryView(leaf))
const { container } = render()
expect(container.querySelector('.app-shell-empty')).not.toBeNull()
act(() => ctx.workspace.setActiveLeaf(queryLeaf))
expect(container.querySelector('[data-testid="query-surface"]')).toHaveTextContent('Exercise query')
act(() => ctx.workspace.setActiveLeaf(editorLeaf))
expect(container.querySelector('.app-shell-empty')).not.toBeNull()
act(() => ctx.workspace.setActiveLeaf(queryLeaf))
expect(container.querySelector('[data-testid="query-surface"]')).toHaveTextContent('Exercise query')
})
it('refreshes a React view element when its active leaf commits a file', async () => {
const ctx = await makeAppForTest()
const file = await provisionMarkdownFile(ctx, 'dates.md', 'Dates')
function FilePanel({ leaf }: { leaf: WorkspaceLeaf }) {
return
{leaf.file?.path ?? 'Loading file'}
}
class FileView extends ItemView implements ReactItemView {
getViewType(): string { return 'file-handshake' }
getDisplayText(): string { return 'Dates' }
getReactComponent(): ReactNode { return createElement(FilePanel, { leaf: this.leaf }) }
}
const leaf = ctx.workspace.getLeaf(true)
leaf.view = new FileView(leaf)
ctx.workspace.setActiveLeaf(leaf)
const rendered = render()
expect(rendered.getByTestId('file-handshake')).toHaveTextContent('Loading file')
act(() => { leaf.file = file; ctx.workspace.trigger('active-leaf-change', leaf) })
expect(rendered.getByTestId('file-handshake')).toHaveTextContent('dates.md')
})
})