diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index cd6ba45..acec2ed 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -56,10 +56,10 @@ function SessionGate() { ; } -function BootScreen({ label = 'Finding the local server' }: { label?: string }) { +function BootScreen({ label = 'Connecting to Overroot' }: { label?: string }) { return
; } function OfflineScreen({ message, retry }: { message: string; retry: () => void }) { - return
Server sleeping

Overroot is offline.

{message}. The documents remain safely on the host machine.

; + return
Connection unavailable

Could not connect to Overroot.

{message}. Check your connection and try again.

; } diff --git a/apps/web/src/components/DashboardDialogs.tsx b/apps/web/src/components/DashboardDialogs.tsx index e477251..86a767c 100644 --- a/apps/web/src/components/DashboardDialogs.tsx +++ b/apps/web/src/components/DashboardDialogs.tsx @@ -1,5 +1,5 @@ import { CalendarClock, Check, KeyRound, Link2, Plus, Trash2 } from 'lucide-react'; -import { useEffect, useState, type FormEvent } from 'react'; +import { useEffect, useRef, useState, type FormEvent } from 'react'; import type { ApiClient, CreatedInvite, InviteSummary } from '../lib/api'; import { relativeTime } from '../lib/format'; import type { Template } from '../types'; @@ -12,28 +12,44 @@ export function NewProjectDialog({ open, templates, onClose, - onCreate + onCreate, + onPendingChange }: { open: boolean; templates: Template[]; onClose: () => void; onCreate: (name: string, template: string) => Promise; + onPendingChange?: (pending: boolean) => void; }) { const [name, setName] = useState('Untitled paper'); const [template, setTemplate] = useState('article'); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - useEffect(() => { if (open) { setName('Untitled paper'); setTemplate(templates[0]?.id ?? 'article'); setError(null); } }, [open, templates]); + const active = useRef(false); + const visible = useRef(open); + useEffect(() => { active.current = true; return () => { active.current = false; }; }, []); + useEffect(() => { + visible.current = open; + // Reopening an in-flight operation must show the draft actually being created. + if (open && !busy) { setName('Untitled paper'); setTemplate(templates[0]?.id ?? 'article'); setError(null); } + }, [open]); async function submit(event: FormEvent) { event.preventDefault(); if (busy) return; - setBusy(true); setError(null); - try { await onCreate(name, template); onClose(); } catch (reason) { setError(reason instanceof Error ? reason.message : 'Could not create project'); } finally { setBusy(false); } + setBusy(true); setError(null); onPendingChange?.(true); + try { + await onCreate(name, template); + if (active.current && visible.current) onClose(); + } catch (reason) { + if (active.current) setError(reason instanceof Error ? reason.message : 'Could not create project'); + } finally { + if (active.current) { setBusy(false); onPendingChange?.(false); } + } } return
- -
Choose a starting point + +
Choose a starting point {templates.map((item) => )}
-
+ {busy &&

You can close this dialog while the project is created.

} +
; } diff --git a/apps/web/src/components/PdfViewer.test.tsx b/apps/web/src/components/PdfViewer.test.tsx new file mode 100644 index 0000000..29abb2b --- /dev/null +++ b/apps/web/src/components/PdfViewer.test.tsx @@ -0,0 +1,73 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type { ApiClient } from '../lib/api'; +import { PdfViewer } from './PdfViewer'; + +const parser = vi.hoisted(() => ({ getDocument: vi.fn() })); +vi.mock('pdfjs-dist', () => ({ GlobalWorkerOptions: {}, getDocument: parser.getDocument })); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); + return { promise, resolve, reject }; +} +function document() { + return { numPages: 1, getPage: vi.fn(async () => ({ getViewport: () => ({ width: 200, height: 300 }) })) }; +} +function properties(data = new ArrayBuffer(1)) { + return { + api: {} as ApiClient, projectHash: 'pdf-validation-test', data, revision: 'source', synctexJobId: null, + sourceLocation: null, loading: false, focusMode: false, onFocusMode: vi.fn(), projectName: 'Paper', + onDocumentStatus: vi.fn() + }; +} +beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); return 1; }); +}); +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +it('reports parser rejection with the exact downloaded buffer identity', async () => { + const props = properties(new Uint8Array([0]).buffer); + parser.getDocument.mockImplementation(() => ({ promise: Promise.reject(new Error('Invalid PDF structure')), destroy: vi.fn() })); + render(); + await screen.findByText('Invalid PDF structure'); + expect(props.onDocumentStatus).toHaveBeenCalledExactlyOnceWith(props.data, 'failed'); +}); + +it('confirms usable bytes after pages finish loading', async () => { + const props = properties(); + parser.getDocument.mockImplementation(() => ({ promise: Promise.resolve(document()), destroy: vi.fn() })); + render(); + await waitFor(() => expect(props.onDocumentStatus).toHaveBeenCalledExactlyOnceWith(props.data, 'ready')); + expect(screen.getByLabelText('PDF page 1')).toBeInTheDocument(); +}); + +it('does not report a cancelled old parser failure against replacement bytes', async () => { + const first = deferred>(); + parser.getDocument.mockReturnValueOnce({ promise: first.promise, destroy: vi.fn() }); + parser.getDocument.mockImplementation(() => ({ promise: Promise.resolve(document()), destroy: vi.fn() })); + const props = properties(); + const view = render(); + const replacement = new ArrayBuffer(2); + view.rerender(); + await waitFor(() => expect(props.onDocumentStatus).toHaveBeenCalledWith(replacement, 'ready')); + await act(async () => { first.reject(new Error('Old PDF was invalid')); }); + expect(props.onDocumentStatus).not.toHaveBeenCalledWith(props.data, 'failed'); + expect(screen.queryByText('Old PDF was invalid')).not.toBeInTheDocument(); +}); + +it('uses the latest validation callback without restarting a pending document', async () => { + const pending = deferred>(); + parser.getDocument.mockReturnValue({ promise: pending.promise, destroy: vi.fn() }); + const props = properties(); + const view = render(); + const onDocumentStatus = vi.fn(); + view.rerender(); + await act(async () => { pending.resolve(document()); }); + expect(parser.getDocument).toHaveBeenCalledTimes(1); + expect(onDocumentStatus).toHaveBeenCalledExactlyOnceWith(props.data, 'ready'); + expect(props.onDocumentStatus).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/PdfViewer.tsx b/apps/web/src/components/PdfViewer.tsx index ffe75dc..8c7f58e 100644 --- a/apps/web/src/components/PdfViewer.tsx +++ b/apps/web/src/components/PdfViewer.tsx @@ -21,7 +21,8 @@ export function PdfViewer({ loading, focusMode, onFocusMode, - projectName + projectName, + onDocumentStatus }: { api: ApiClient; projectHash: string; @@ -33,6 +34,7 @@ export function PdfViewer({ focusMode: boolean; onFocusMode: (focused: boolean) => void; projectName: string; + onDocumentStatus?: (data: ArrayBuffer, status: 'ready' | 'failed') => void; }) { const scroller = useRef(null); const pages = useRef(null); @@ -48,6 +50,8 @@ export function PdfViewer({ const [rendering, setRendering] = useState(false); const [error, setError] = useState(null); const [sourceHighlights, setSourceHighlights] = useState([]); + const documentStatus = useRef(onDocumentStatus); + documentStatus.current = onDocumentStatus; useEffect(() => { if (!data || !pages.current) { @@ -95,6 +99,7 @@ export function PdfViewer({ await Promise.all(renderJobs); if (cancelled) return; setRendering(false); + documentStatus.current?.(data, 'ready'); setPage(Math.min(preserved.current.page, pdf.numPages)); afterLayout(() => { if (cancelled) return; @@ -108,6 +113,7 @@ export function PdfViewer({ canPersist.current = Boolean(pages.current?.childElementCount); setRendering(false); setError(reason instanceof Error ? reason.message : 'The PDF could not be displayed'); + documentStatus.current?.(data, 'failed'); }); return () => { diff --git a/apps/web/src/pages/AuthPage.test.tsx b/apps/web/src/pages/AuthPage.test.tsx index 088c768..e65f7a4 100644 --- a/apps/web/src/pages/AuthPage.test.tsx +++ b/apps/web/src/pages/AuthPage.test.tsx @@ -24,6 +24,43 @@ beforeEach(() => { afterEach(cleanup); describe('authentication progress', () => { + it('ignores a late sign-in result after switching to registration', async () => { + const pending = deferred<{ token: string; user: { username: string } }>(); + harness.api.login.mockReturnValueOnce(pending.promise); + render(}/>}/>Project dashboard}/>); + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'writer' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'test-password' } }); + fireEvent.submit(screen.getByLabelText('Password').closest('form')!); + fireEvent.click(screen.getByRole('link', { name: 'Create an account' })); + expect(screen.getByRole('button', { name: 'Join Overroot' })).toBeEnabled(); + await act(async () => { pending.resolve({ token: 'stale-token', user: { username: 'writer' } }); }); + expect(harness.setSession).not.toHaveBeenCalled(); + expect(screen.getByRole('heading', { name: 'Create an account' })).toBeInTheDocument(); + expect(screen.queryByText('Project dashboard')).not.toBeInTheDocument(); + }); + + it('keeps current registration progress when the previous sign-in fails late', async () => { + const login = deferred<{ token: string; user: { username: string } }>(); + const register = deferred<{ token: string; user: { username: string } }>(); + harness.api.login.mockReturnValueOnce(login.promise); + harness.api.register.mockReturnValueOnce(register.promise); + render(}/>}/>Project dashboard}/>); + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'writer' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'test-password' } }); + fireEvent.submit(screen.getByLabelText('Password').closest('form')!); + fireEvent.click(screen.getByRole('link', { name: 'Create an account' })); + fireEvent.change(screen.getByLabelText('Invitation'), { target: { value: 'invitation' } }); + fireEvent.submit(screen.getByLabelText('Password').closest('form')!); + await act(async () => { login.reject(new Error('Old login rejected')); }); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Creating account…' })).toBeDisabled(); + expect(screen.getByRole('status')).toHaveTextContent('Creating account…'); + const session = { token: 'current-token', user: { username: 'writer' } }; + await act(async () => { register.resolve(session); }); + expect(harness.setSession).toHaveBeenCalledExactlyOnceWith(session.token, session.user); + expect(screen.getByText('Project dashboard')).toBeInTheDocument(); + }); + it.each(['login', 'register'] as const)('keeps %s progress tied to the request and recovers after failure', async (mode) => { const pending = deferred<{ token: string; user: { username: string } }>(); harness.api[mode].mockReturnValue(pending.promise); diff --git a/apps/web/src/pages/AuthPage.tsx b/apps/web/src/pages/AuthPage.tsx index 008fccb..0108b93 100644 --- a/apps/web/src/pages/AuthPage.tsx +++ b/apps/web/src/pages/AuthPage.tsx @@ -1,5 +1,5 @@ import { ArrowRight, FileText, KeyRound, Layers, Users } from 'lucide-react'; -import { useEffect, useState, type FormEvent } from 'react'; +import { useEffect, useRef, useState, type FormEvent } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Brand } from '../components/Brand'; import { InlineError } from '../components/Feedback'; @@ -19,23 +19,34 @@ export function AuthPage({ mode }: { mode: 'login' | 'register' }) { const [busy, setBusy] = useState(false); const [setupRequired, setSetupRequired] = useState(false); const [error, setError] = useState(null); + const requestId = useRef(0); const progressLabel = mode === 'login' ? 'Signing in…' : 'Creating account…'; useEffect(() => { + let active = true; if (token) navigate('/', { replace: true }); - void api.request<{ setupRequired: boolean }>('/api/v1/status').then((status) => setSetupRequired(status.setupRequired)).catch(() => {}); + void api.request<{ setupRequired: boolean }>('/api/v1/status').then((status) => { if (active) setSetupRequired(status.setupRequired); }).catch(() => {}); + return () => { active = false; }; }, [api, token, navigate]); + useEffect(() => { + requestId.current += 1; + setBusy(false); setError(null); + return () => { requestId.current += 1; }; + }, [api, mode]); + async function submit(event: FormEvent) { event.preventDefault(); if (busy) return; + const currentRequest = ++requestId.current; setBusy(true); setError(null); try { const session = mode === 'login' ? await api.login(username, password) : await api.register(invite, username, password); + if (currentRequest !== requestId.current) return; setSession(session.token, session.user); navigate('/', { replace: true }); - } catch (reason) { setError(reason instanceof Error ? reason.message : 'Authentication failed'); } - finally { setBusy(false); } + } catch (reason) { if (currentRequest === requestId.current) setError(reason instanceof Error ? reason.message : 'Authentication failed'); } + finally { if (currentRequest === requestId.current) setBusy(false); } } return
diff --git a/apps/web/src/pages/DashboardPage.test.tsx b/apps/web/src/pages/DashboardPage.test.tsx index d7fd553..6d3416f 100644 --- a/apps/web/src/pages/DashboardPage.test.tsx +++ b/apps/web/src/pages/DashboardPage.test.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { DashboardPage } from './DashboardPage'; const harness = vi.hoisted(() => ({ - api: { listProjects: vi.fn(), listTemplates: vi.fn(), importProject: vi.fn() }, + api: { listProjects: vi.fn(), listTemplates: vi.fn(), importProject: vi.fn(), createProject: vi.fn() }, user: { username: 'writer', displayName: 'Writer', isAdmin: false } })); vi.mock('../lib/session', () => ({ useSession: () => ({ api: harness.api, user: harness.user, clearSession: vi.fn() }) })); @@ -18,6 +18,106 @@ beforeEach(() => { }); afterEach(cleanup); +it('preserves a dismissed creation draft and its progress when reopened', async () => { + let reject!: (reason: Error) => void; + harness.api.createProject.mockReturnValueOnce(new Promise((_done, fail) => { reject = fail; })); + render(); + await screen.findByText('No projects yet.'); + fireEvent.click(screen.getByRole('button', { name: 'New project' })); + fireEvent.change(screen.getByLabelText('Project name'), { target: { value: 'My pending paper' } }); + fireEvent.submit(screen.getByLabelText('Project name').closest('form')!); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Creating project…' })); + expect(screen.getByLabelText('Project name')).toHaveValue('My pending paper'); + expect(screen.getByLabelText('Project name')).toBeDisabled(); + expect(harness.api.createProject).toHaveBeenCalledTimes(1); + + await act(async () => { reject(new Error('Creation unavailable')); }); + expect(screen.getByRole('alert')).toHaveTextContent('Creation unavailable'); + expect(screen.getByLabelText('Project name')).toBeEnabled(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); +}); + +it('shows a created project on the dashboard instead of navigating after dismissal', async () => { + let resolve!: (value: { project: { hash: string } }) => void; + harness.api.createProject.mockReturnValueOnce(new Promise((done) => { resolve = done; })); + render(}/>Created workspace
}/>); + await screen.findByText('No projects yet.'); + fireEvent.click(screen.getByRole('button', { name: 'New project' })); + fireEvent.submit(screen.getByLabelText('Project name').closest('form')!); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + harness.api.listProjects.mockResolvedValueOnce({ projects: [{ hash: 'created', name: 'Created paper', entryFile: 'main.tex', owner: 'writer', role: 'owner', updatedAt: new Date().toISOString() }] }); + await act(async () => { resolve({ project: { hash: 'created' } }); }); + expect(await screen.findByText('Created paper')).toBeInTheDocument(); + expect(screen.queryByText('Created workspace')).not.toBeInTheDocument(); +}); + +it('does not redirect a user who leaves the dashboard during an import', async () => { + let resolve!: (value: { project: { hash: string } }) => void; + harness.api.importProject.mockReturnValueOnce(new Promise((done) => { resolve = done; })); + harness.api.listProjects.mockResolvedValueOnce({ projects: [{ hash: 'other', name: 'Other paper', entryFile: 'main.tex', owner: 'writer', role: 'owner', updatedAt: new Date().toISOString() }] }); + const current = render(}/>Other workspace}/>Imported workspace}/>); + await screen.findByText('Other paper'); + fireEvent.change(current.container.querySelector('input[type="file"]')!, { target: { files: [new File(['zip fixture'], 'paper.zip')] } }); + fireEvent.click(screen.getByRole('button', { name: /Other paper/ })); + await screen.findByText('Other workspace'); + await act(async () => { resolve({ project: { hash: 'imported' } }); }); + expect(screen.getByText('Other workspace')).toBeInTheDocument(); + expect(screen.queryByText('Imported workspace')).not.toBeInTheDocument(); +}); + +it('reports a dismissed creation failure on the dashboard and permits a fresh draft', async () => { + let reject!: (reason: Error) => void; + harness.api.createProject.mockReturnValueOnce(new Promise((_done, fail) => { reject = fail; })); + render(); + await screen.findByText('No projects yet.'); + fireEvent.click(screen.getByRole('button', { name: 'New project' })); + fireEvent.change(screen.getByLabelText('Project name'), { target: { value: 'Failed paper' } }); + fireEvent.submit(screen.getByLabelText('Project name').closest('form')!); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + await act(async () => { reject(new Error('Creation unavailable')); }); + expect(screen.getByRole('alert')).toHaveTextContent('Creation unavailable'); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'New project' })); + expect(screen.getByLabelText('Project name')).toHaveValue('Untitled paper'); + expect(screen.getByLabelText('Project name')).toBeEnabled(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); +}); + +it('does not redirect a user who leaves the dashboard during creation', async () => { + let resolve!: (value: { project: { hash: string } }) => void; + harness.api.createProject.mockReturnValueOnce(new Promise((done) => { resolve = done; })); + harness.api.listProjects.mockResolvedValueOnce({ projects: [{ hash: 'other', name: 'Other paper', entryFile: 'main.tex', owner: 'writer', role: 'owner', updatedAt: new Date().toISOString() }] }); + render(}/>Other workspace}/>Created workspace}/>); + await screen.findByText('Other paper'); + fireEvent.click(screen.getByRole('button', { name: 'New project' })); + fireEvent.submit(screen.getByLabelText('Project name').closest('form')!); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + fireEvent.click(screen.getByRole('button', { name: /Other paper/ })); + await screen.findByText('Other workspace'); + await act(async () => { resolve({ project: { hash: 'created' } }); }); + expect(screen.getByText('Other workspace')).toBeInTheDocument(); + expect(screen.queryByText('Created workspace')).not.toBeInTheDocument(); +}); + +it('keeps the refreshed project list when the initial dashboard load finishes late', async () => { + let resolveInitial!: (value: { projects: [] }) => void; + let resolveCreate!: (value: { project: { hash: string } }) => void; + harness.api.listProjects.mockReturnValueOnce(new Promise((done) => { resolveInitial = done; })) + .mockResolvedValueOnce({ projects: [{ hash: 'created', name: 'Created paper', entryFile: 'main.tex', owner: 'writer', role: 'owner', updatedAt: new Date().toISOString() }] }); + harness.api.createProject.mockReturnValueOnce(new Promise((done) => { resolveCreate = done; })); + render(); + fireEvent.click(screen.getByRole('button', { name: 'New project' })); + fireEvent.submit(screen.getByLabelText('Project name').closest('form')!); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + await act(async () => { resolveCreate({ project: { hash: 'created' } }); }); + await screen.findByText('Created paper'); + await act(async () => { resolveInitial({ projects: [] }); }); + expect(screen.getByText('Created paper')).toBeInTheDocument(); + expect(screen.queryByText('No projects yet.')).not.toBeInTheDocument(); +}); + it('keeps import progress tied to the upload, then restores the action after an error', async () => { let reject!: (reason: Error) => void; const pending = new Promise((_done, fail) => { reject = fail; }); diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx index 5b708bd..7e609e2 100644 --- a/apps/web/src/pages/DashboardPage.tsx +++ b/apps/web/src/pages/DashboardPage.tsx @@ -16,24 +16,41 @@ export function DashboardPage() { const theme = useTheme(); const navigate = useNavigate(); const importInput = useRef(null); + const generation = useRef(0); + const loadId = useRef(0); + const newVisible = useRef(false); const [projects, setProjects] = useState([]); const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(true); const [importing, setImporting] = useState(false); + const [creating, setCreating] = useState(false); const [error, setError] = useState(null); const [newOpen, setNewOpen] = useState(false); const [invitesOpen, setInvitesOpen] = useState(false); const [accountOpen, setAccountOpen] = useState(false); async function load() { + const requestGeneration = generation.current; + const requestId = ++loadId.current; + const isCurrent = () => requestGeneration === generation.current && requestId === loadId.current; setLoading(true); setError(null); try { const [projectResponse, templateResponse] = await Promise.all([api.listProjects(), api.listTemplates()]); - setProjects(projectResponse.projects); setTemplates(templateResponse.templates); - } catch (reason) { setError(reason instanceof Error ? reason.message : 'Could not load projects'); } - finally { setLoading(false); } + if (isCurrent()) { setProjects(projectResponse.projects); setTemplates(templateResponse.templates); } + } catch (reason) { if (isCurrent()) setError(reason instanceof Error ? reason.message : 'Could not load projects'); } + finally { if (isCurrent()) setLoading(false); } + } + useEffect(() => { + generation.current += 1; + void load(); + return () => { generation.current += 1; }; + }, [api]); + + function showNewProject(open: boolean) { + newVisible.current = open; + if (open) setError(null); + setNewOpen(open); } - useEffect(() => { void load(); }, [api]); async function logout() { try { await api.logout(); } catch { /* Session is cleared locally either way. */ } @@ -41,16 +58,25 @@ export function DashboardPage() { } async function create(name: string, template: string) { - const response = await api.createProject(name, template); - navigate(`/project/${response.project.hash}`); + const requestGeneration = generation.current; + try { + const response = await api.createProject(name, template); + if (requestGeneration !== generation.current) return; + if (newVisible.current) navigate(`/project/${response.project.hash}`); + else void load(); + } catch (reason) { + if (requestGeneration === generation.current && !newVisible.current) setError(reason instanceof Error ? reason.message : 'Could not create project'); + throw reason; + } } async function importZip(file: File) { if (importing) return; + const requestGeneration = generation.current; setImporting(true); setError(null); - try { const response = await api.importProject(file); navigate(`/project/${response.project.hash}`); } - catch (reason) { setError(reason instanceof Error ? reason.message : 'Could not import ZIP'); } - finally { setImporting(false); } + try { const response = await api.importProject(file); if (requestGeneration === generation.current) navigate(`/project/${response.project.hash}`); } + catch (reason) { if (requestGeneration === generation.current) setError(reason instanceof Error ? reason.message : 'Could not import ZIP'); } + finally { if (requestGeneration === generation.current) setImporting(false); } } return
@@ -70,14 +96,14 @@ export function DashboardPage() {
{ const file = event.target.files?.[0]; if (file) void importZip(file); event.target.value = ''; }}/> - +

All projects

{projects.length.toString().padStart(2, '0')}
{loading ? :
- {projects.length === 0 &&

No projects yet.

Begin with a clean template or import an existing ZIP archive.

} + {projects.length === 0 &&

No projects yet.

Begin with a clean template or import an existing ZIP archive.

} {projects.map((project) =>
Overroot · Private open-source studioSource
- setNewOpen(false)} onCreate={create}/> + showNewProject(false)} onCreate={create} onPendingChange={setCreating}/> {user?.isAdmin && setInvitesOpen(false)} api={api}/>} {user && setAccountOpen(false)} api={api} username={user.username}/>}
; diff --git a/apps/web/src/pages/WorkspacePage.pdf.test.tsx b/apps/web/src/pages/WorkspacePage.pdf.test.tsx index 4f6f93c..0582a49 100644 --- a/apps/web/src/pages/WorkspacePage.pdf.test.tsx +++ b/apps/web/src/pages/WorkspacePage.pdf.test.tsx @@ -1,27 +1,56 @@ -import { cleanup, render, screen } from '@testing-library/react'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { WorkspacePage } from './WorkspacePage'; +import type { CompileJob, ProjectEvent } from '../types'; const harness = vi.hoisted(() => ({ - api: { getProject: vi.fn(), listFiles: vi.fn(), latestCompile: vi.fn(), getFile: vi.fn(), compilePdf: vi.fn() } + api: { getProject: vi.fn(), listFiles: vi.fn(), latestCompile: vi.fn(), getFile: vi.fn(), compilePdf: vi.fn(), compile: vi.fn() }, + flush: vi.fn(), + onEvent: null as ((event: Pick & { reconnected?: boolean; cached?: boolean }) => void) | null, + pdfData: null as ArrayBuffer | null, + onDocumentStatus: null as ((data: ArrayBuffer, status: 'ready' | 'failed') => void) | null })); vi.mock('../lib/session', () => ({ useSession: () => ({ api: harness.api, user: { displayName: 'Writer' }, clearSession: vi.fn() }) })); vi.mock('../hooks/useTheme', () => ({ useTheme: () => ({ preference: 'light', resolved: 'light', setPreference: vi.fn() }) })); -vi.mock('../hooks/useProjectEvents', () => ({ useProjectEvents: vi.fn() })); -vi.mock('../components/PdfViewer', () => ({ PdfViewer: ({ data, revision, synctexJobId }: { data: ArrayBuffer | null; revision: string; synctexJobId: string }) =>
{data ? `PDF ${revision} SyncTeX ${synctexJobId}` : 'No PDF loaded'}
})); -vi.mock('../components/ManuscriptPane', () => ({ ManuscriptPane: () =>
Test manuscript
})); +vi.mock('../hooks/useProjectEvents', () => ({ useProjectEvents: (_api: unknown, _hash: string, handler: typeof harness.onEvent) => { harness.onEvent = handler; } })); +vi.mock('../components/PdfViewer', async () => { + const { useEffect } = await import('react'); + return { PdfViewer: function Viewer({ data, revision, synctexJobId, onDocumentStatus }: { data: ArrayBuffer | null; revision: string; synctexJobId: string; onDocumentStatus?: typeof harness.onDocumentStatus }) { + harness.pdfData = data; + harness.onDocumentStatus = onDocumentStatus ?? null; + useEffect(() => { if (data) onDocumentStatus?.(data, 'ready'); }, [data, onDocumentStatus]); + return
{data ? `PDF ${revision} SyncTeX ${synctexJobId}` : 'No PDF loaded'}
; + } }; +}); +vi.mock('../components/ManuscriptPane', async () => { + const { useEffect } = await import('react'); + return { ManuscriptPane: function Pane({ onFlushReady }: { onFlushReady: (flush: (() => Promise) | null) => void }) { + useEffect(() => { onFlushReady(() => harness.flush()); return () => onFlushReady(null); }, [onFlushReady]); + return
Test manuscript
; + } }; +}); vi.mock('../components/EditorPane', () => ({ EditorPane: () => null })); vi.mock('../components/WorkspaceDialogs', () => ({ AgentDialog: () => null, CompileLogDrawer: () => null, FileActionsDialog: () => null, HistoryDrawer: () => null, NewFileDialog: () => null, ProjectSettingsDialog: () => null, ShareDialog: () => null })); -const successful = { id: 'good-job', status: 'succeeded', sourceHash: 'good-revision', hasPdf: true, hasSynctex: true, createdAt: new Date().toISOString() }; +const successful: CompileJob = { id: 'good-job', status: 'succeeded', sourceHash: 'good-revision', hasPdf: true, hasSynctex: true, createdAt: '2026-10-19T10:00:00.000Z', startedAt: '2026-10-19T10:00:00.000Z', finishedAt: '2026-10-19T10:00:01.000Z', entryFile: 'main.tex', log: '', error: null, requestedBy: { type: 'user', id: 'writer', name: 'Writer' } }; +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); + return { promise, resolve, reject }; +} beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); + harness.pdfData = null; + harness.onDocumentStatus = null; + harness.flush.mockResolvedValue(undefined); harness.api.getProject.mockResolvedValue({ project: { hash: 'project', name: 'Paper', entryFile: 'main.tex', role: 'owner', canWrite: true, canManage: true, memberCount: 1 } }); harness.api.listFiles.mockResolvedValue({ files: [] }); harness.api.compilePdf.mockResolvedValue({ data: new ArrayBuffer(1) }); + harness.api.latestCompile.mockResolvedValue({ job: null, lastSuccessfulJob: null }); }); -afterEach(cleanup); +afterEach(() => { cleanup(); vi.restoreAllMocks(); }); function openWorkspace() { render(}/>); } @@ -40,3 +69,257 @@ it('opens projects without any successful compile without fetching a PDF', async await screen.findByText('Test manuscript'); expect(harness.api.compilePdf).not.toHaveBeenCalled(); }); + +it('shares one PDF download when readiness arrives before the compile response', async () => { + openWorkspace(); + await screen.findByText('Test manuscript'); + const response = deferred<{ job: CompileJob }>(); + const download = deferred<{ data: ArrayBuffer }>(); + harness.api.compile.mockReturnValue(response.promise); + harness.api.compilePdf.mockReturnValue(download.promise); + fireEvent.click(screen.getByRole('button', { name: /^Compile$/ })); + await waitFor(() => expect(harness.api.compile).toHaveBeenCalledTimes(1)); + act(() => harness.onEvent!({ type: 'compile.ready', job: successful })); + await act(async () => { response.resolve({ job: successful }); }); + expect(harness.api.compilePdf).toHaveBeenCalledTimes(1); + await act(async () => { download.resolve({ data: new ArrayBuffer(1) }); }); + await screen.findByText('PDF good-revision SyncTeX good-job'); +}); + +it('reuses validated bytes for a confirmed cache hit and updates its SyncTeX job', async () => { + harness.api.latestCompile.mockResolvedValue({ job: successful }); + openWorkspace(); + await screen.findByText('PDF good-revision SyncTeX good-job'); + const cached = { ...successful, id: 'cached-job', createdAt: '2026-10-19T10:00:02.000Z' }; + const response = deferred<{ job: CompileJob }>(); + harness.api.compile.mockReturnValue(response.promise); + fireEvent.click(screen.getByRole('button', { name: /^Compile$/ })); + await waitFor(() => expect(harness.api.compile).toHaveBeenCalledTimes(1)); + await act(async () => { harness.onEvent!({ type: 'compile.ready', job: cached, cached: true }); }); + await act(async () => { response.resolve({ job: cached }); }); + await screen.findByText('PDF good-revision SyncTeX cached-job'); + expect(harness.api.compilePdf).toHaveBeenCalledTimes(1); +}); + +it('fetches a new job after reconnecting without cache provenance and deduplicates its next snapshot', async () => { + harness.api.latestCompile.mockResolvedValue({ job: successful }); + openWorkspace(); + await screen.findByText('PDF good-revision SyncTeX good-job'); + const cached = { ...successful, id: 'reconnected-job', createdAt: '2026-10-19T10:00:02.000Z' }; + harness.api.latestCompile.mockResolvedValue({ job: cached }); + await act(async () => { harness.onEvent!({ type: 'connected', reconnected: true }); }); + await screen.findByText('PDF good-revision SyncTeX reconnected-job'); + expect(harness.api.compilePdf).toHaveBeenCalledTimes(2); + await act(async () => { harness.onEvent!({ type: 'connected', reconnected: true }); }); + expect(harness.api.compilePdf).toHaveBeenCalledTimes(2); +}); + +it('replaces rejected downloaded bytes after an uncached repair with the same source hash', async () => { + const invalid = new Uint8Array([0]).buffer; + const repaired = new Uint8Array([1, 2]).buffer; + harness.api.latestCompile.mockResolvedValue({ job: successful }); + harness.api.compilePdf.mockResolvedValueOnce({ data: invalid }).mockResolvedValue({ data: repaired }); + openWorkspace(); + await screen.findByText('PDF good-revision SyncTeX good-job'); + // A successful HTTP response can still be rejected by the PDF parser. + act(() => { harness.onDocumentStatus?.(invalid, 'failed'); }); + const repair = { ...successful, id: 'repaired-job', createdAt: '2026-10-19T10:00:02.000Z' }; + await act(async () => { harness.onEvent!({ type: 'compile.ready', job: repair, cached: false }); }); + expect(harness.api.compilePdf).toHaveBeenCalledTimes(2); + expect(harness.api.compilePdf).toHaveBeenLastCalledWith('project', 'repaired-job'); + expect(harness.pdfData).toBe(repaired); +}); + +it('supersedes an older pending same-hash download when an uncached repair is published', async () => { + const oldDownload = deferred<{ data: ArrayBuffer }>(); + const repaired = new Uint8Array([1, 2]).buffer; + openWorkspace(); + await screen.findByText('Test manuscript'); + harness.api.compilePdf.mockReturnValueOnce(oldDownload.promise).mockResolvedValue({ data: repaired }); + act(() => { harness.onEvent!({ type: 'compile.ready', job: successful, cached: false }); }); + const repair = { ...successful, id: 'repaired-job', createdAt: '2026-10-19T10:00:02.000Z' }; + await act(async () => { harness.onEvent!({ type: 'compile.ready', job: repair, cached: false }); }); + expect(harness.api.compilePdf).toHaveBeenCalledTimes(2); + expect(harness.pdfData).toBe(repaired); + await act(async () => { oldDownload.resolve({ data: new ArrayBuffer(1) }); }); + expect(harness.pdfData).toBe(repaired); + expect(screen.getByText('PDF good-revision SyncTeX repaired-job')).toBeInTheDocument(); +}); + +it('invalidates only bytes rejected by the current viewer and retries even on a confirmed cache hit', async () => { + const invalid = new Uint8Array([0]).buffer; + const repaired = new Uint8Array([1, 2]).buffer; + harness.api.latestCompile.mockResolvedValue({ job: successful }); + harness.api.compilePdf.mockResolvedValueOnce({ data: invalid }).mockResolvedValue({ data: repaired }); + openWorkspace(); + await screen.findByText('PDF good-revision SyncTeX good-job'); + act(() => { harness.onDocumentStatus?.(invalid, 'failed'); }); + const repair = { ...successful, id: 'repaired-job', createdAt: '2026-10-19T10:00:02.000Z' }; + await act(async () => { harness.onEvent!({ type: 'compile.ready', job: repair, cached: true }); }); + expect(harness.api.compilePdf).toHaveBeenCalledTimes(2); + expect(harness.pdfData).toBe(repaired); + act(() => { harness.onDocumentStatus?.(invalid, 'failed'); }); + const cached = { ...repair, id: 'cached-job', createdAt: '2026-10-19T10:00:03.000Z' }; + await act(async () => { harness.onEvent!({ type: 'compile.ready', job: cached, cached: true }); }); + expect(harness.api.compilePdf).toHaveBeenCalledTimes(2); + expect(screen.getByText('PDF good-revision SyncTeX cached-job')).toBeInTheDocument(); +}); + +it('keeps a completed compile when its queued POST response arrives late', async () => { + openWorkspace(); + await screen.findByText('Test manuscript'); + const response = deferred<{ job: CompileJob }>(); + harness.api.compile.mockReturnValue(response.promise); + fireEvent.click(screen.getByRole('button', { name: /^Compile$/ })); + await waitFor(() => expect(harness.api.compile).toHaveBeenCalledTimes(1)); + await act(async () => { harness.onEvent!({ type: 'compile.ready', job: successful }); }); + await screen.findByText('PDF good-revision SyncTeX good-job'); + await act(async () => { response.resolve({ job: { ...successful, status: 'queued', hasPdf: false, hasSynctex: false, finishedAt: null } }); }); + expect(screen.getByRole('button', { name: /^succeeded/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Compile$/ })).toBeEnabled(); +}); + +it('disables compilation while source synchronization and submission are pending', async () => { + openWorkspace(); + await screen.findByText('Test manuscript'); + await act(async () => {}); + const flush = deferred(); + const response = deferred<{ job: CompileJob }>(); + harness.flush.mockReturnValue(flush.promise); + harness.api.compile.mockReturnValue(response.promise); + const button = screen.getByRole('button', { name: /^Compile$/ }); + fireEvent.click(button); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('aria-label', 'Saving changes…'); + expect(button).toHaveAttribute('aria-busy', 'true'); + fireEvent.click(button); + expect(harness.flush).toHaveBeenCalledTimes(1); + expect(harness.api.compile).not.toHaveBeenCalled(); + await act(async () => { flush.resolve(); }); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('aria-label', 'Starting compilation…'); + await act(async () => { response.resolve({ job: successful }); }); + expect(button).toBeEnabled(); + expect(button).toHaveAttribute('aria-label', 'Compile'); + expect(button).toHaveAttribute('aria-busy', 'false'); + expect(harness.api.compile).toHaveBeenCalledTimes(1); +}); + +it('finishes submission when the server succeeds while its PDF download is still pending', async () => { + openWorkspace(); + await screen.findByText('Test manuscript'); + const download = deferred<{ data: ArrayBuffer }>(); + harness.api.compile.mockResolvedValue({ job: successful }); + harness.api.compilePdf.mockReturnValue(download.promise); + fireEvent.click(screen.getByRole('button', { name: /^Compile$/ })); + await waitFor(() => expect(harness.api.compilePdf).toHaveBeenCalledTimes(1)); + expect(screen.getByRole('button', { name: /^succeeded/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Compile$/ })).toBeEnabled(); + expect(screen.queryByText('Starting compilation…')).not.toBeInTheDocument(); + await act(async () => { download.resolve({ data: new ArrayBuffer(1) }); }); + await screen.findByText('PDF good-revision SyncTeX good-job'); +}); + +it('re-enables compilation after source synchronization fails without submitting stale source', async () => { + openWorkspace(); + await screen.findByText('Test manuscript'); + harness.flush.mockRejectedValueOnce(new Error('Source is still synchronizing')); + fireEvent.click(screen.getByRole('button', { name: /^Compile$/ })); + await screen.findByText('Source is still synchronizing'); + expect(harness.api.compile).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: /^Compile$/ })).toBeEnabled(); +}); + +it('ignores a pending poll snapshot after a newer readiness event', async () => { + const interval = vi.spyOn(window, 'setInterval'); + const pollTick = () => (interval.mock.calls.find(([, delay]) => delay === 1400)![0] as () => void)(); + const queued = { ...successful, status: 'queued', hasPdf: false, hasSynctex: false, finishedAt: null }; + harness.api.latestCompile.mockResolvedValueOnce({ job: queued }); + const poll = deferred<{ job: CompileJob }>(); + harness.api.latestCompile.mockReturnValue(poll.promise); + openWorkspace(); + await screen.findByText('Test manuscript'); + await waitFor(() => expect(interval.mock.calls.some(([, delay]) => delay === 1400)).toBe(true)); + act(() => { pollTick(); }); + expect(harness.api.latestCompile).toHaveBeenCalledTimes(2); + await act(async () => { harness.onEvent!({ type: 'compile.ready', job: successful }); }); + await act(async () => { poll.resolve({ job: { ...successful, status: 'compiling', hasPdf: false, hasSynctex: false, finishedAt: null } }); }); + expect(screen.getByRole('button', { name: /^succeeded/ })).toBeInTheDocument(); + expect(harness.api.compilePdf).toHaveBeenCalledTimes(1); +}); + +it('does not overlap slow polls and recovers from a failed status check without resubmitting', async () => { + const interval = vi.spyOn(window, 'setInterval'); + const pollTick = () => (interval.mock.calls.find(([, delay]) => delay === 1400)![0] as () => void)(); + const queued = { ...successful, status: 'queued', hasPdf: false, hasSynctex: false, finishedAt: null }; + const failedPoll = deferred<{ job: CompileJob }>(); + harness.api.latestCompile.mockResolvedValueOnce({ job: queued }).mockReturnValueOnce(failedPoll.promise).mockResolvedValue({ job: successful }); + openWorkspace(); + await screen.findByText('Test manuscript'); + await waitFor(() => expect(interval.mock.calls.some(([, delay]) => delay === 1400)).toBe(true)); + act(() => { pollTick(); pollTick(); }); + expect(harness.api.latestCompile).toHaveBeenCalledTimes(2); + await act(async () => { failedPoll.reject(new Error('Status connection interrupted')); }); + expect(screen.getByText('Status connection interrupted')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^queued/ })).toBeInTheDocument(); + await act(async () => { pollTick(); }); + expect(screen.getByRole('button', { name: /^succeeded/ })).toBeInTheDocument(); + expect(screen.queryByText('Status connection interrupted')).not.toBeInTheDocument(); + expect(harness.api.compile).not.toHaveBeenCalled(); + expect(harness.api.compilePdf).toHaveBeenCalledTimes(1); +}); + +it('retries a failed PDF download on the next readiness notification', async () => { + harness.api.compilePdf.mockRejectedValueOnce(new Error('PDF download interrupted')); + openWorkspace(); + await screen.findByText('Test manuscript'); + await act(async () => { harness.onEvent!({ type: 'compile.ready', job: successful }); }); + expect(screen.getByText('PDF download interrupted')).toBeInTheDocument(); + await act(async () => { harness.onEvent!({ type: 'compile.ready', job: successful }); }); + expect(screen.getByText('PDF good-revision SyncTeX good-job')).toBeInTheDocument(); + expect(screen.queryByText('PDF download interrupted')).not.toBeInTheDocument(); + expect(harness.api.compilePdf).toHaveBeenCalledTimes(2); +}); + +it('preserves the latest PDF when an older download finishes afterward', async () => { + openWorkspace(); + await screen.findByText('Test manuscript'); + const oldDownload = deferred<{ data: ArrayBuffer }>(); + harness.api.compilePdf.mockReturnValueOnce(oldDownload.promise); + act(() => { harness.onEvent!({ type: 'compile.ready', job: successful }); }); + const newer = { ...successful, id: 'new-good-job', sourceHash: 'new-revision', createdAt: '2026-10-19T10:00:02.000Z' }; + await act(async () => { harness.onEvent!({ type: 'compile.ready', job: newer }); }); + expect(screen.getByText('PDF new-revision SyncTeX new-good-job')).toBeInTheDocument(); + await act(async () => { oldDownload.resolve({ data: new ArrayBuffer(1) }); }); + expect(screen.getByText('PDF new-revision SyncTeX new-good-job')).toBeInTheDocument(); +}); + +it('isolates PDF and compile state when navigating directly between projects', async () => { + harness.api.latestCompile.mockResolvedValueOnce({ job: successful }); + function Navigation() { + const navigate = useNavigate(); + return ; + } + render(}/>); + await screen.findByText('PDF good-revision SyncTeX good-job'); + fireEvent.click(screen.getByRole('button', { name: 'Open another project' })); + await screen.findByText('Test manuscript'); + expect(screen.getByText('No PDF loaded')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Not compiled' })).toBeInTheDocument(); + expect(harness.api.compilePdf).toHaveBeenCalledTimes(1); +}); + +it('keeps the newest good preview when an older project snapshot arrives after a later failure', async () => { + const initial = deferred<{ job: CompileJob; lastSuccessfulJob: CompileJob }>(); + harness.api.latestCompile.mockReturnValueOnce(initial.promise); + openWorkspace(); + const newer = { ...successful, id: 'new-good-job', sourceHash: 'new-revision', createdAt: '2026-10-19T10:00:02.000Z' }; + await act(async () => { harness.onEvent!({ type: 'compile.ready', job: newer }); }); + const failed = { ...newer, id: 'latest-failed-job', status: 'failed' as const, hasPdf: false, hasSynctex: false, createdAt: '2026-10-19T10:00:04.000Z' }; + act(() => { harness.onEvent!({ type: 'compile.failed', job: failed }); }); + await act(async () => { initial.resolve({ job: { ...successful, status: 'failed', hasPdf: false }, lastSuccessfulJob: successful }); }); + await screen.findByText('PDF new-revision SyncTeX new-good-job'); + expect(screen.getByRole('button', { name: /^failed/ })).toBeInTheDocument(); + expect(harness.api.compilePdf).toHaveBeenCalledTimes(1); + expect(harness.api.compilePdf).toHaveBeenCalledWith('project', 'new-good-job'); +}); diff --git a/apps/web/src/pages/WorkspacePage.tsx b/apps/web/src/pages/WorkspacePage.tsx index 80d333d..7b2e774 100644 --- a/apps/web/src/pages/WorkspacePage.tsx +++ b/apps/web/src/pages/WorkspacePage.tsx @@ -10,6 +10,7 @@ import { FileTree } from '../components/FileTree'; import { InlineError, LoadingBlock } from '../components/Feedback'; import { ThemeControl } from '../components/ThemeControl'; import { Modal } from '../components/Modal'; +import { PendingLabel } from '../components/RootMark'; import { AgentDialog, CompileLogDrawer, FileActionsDialog, HistoryDrawer, NewFileDialog, ProjectSettingsDialog, ShareDialog @@ -31,6 +32,10 @@ const PdfViewer = lazy(() => import('../components/PdfViewer').then((module) => export function WorkspacePage() { const { hash = '' } = useParams(); + return ; +} + +function ProjectWorkspace({ hash }: { hash: string }) { const navigate = useNavigate(); const { api, user, clearSession } = useSession(); const theme = useTheme(); @@ -39,6 +44,7 @@ export function WorkspacePage() { const [selected, setSelected] = useState(null); const [editorMode, setEditorMode] = useState<'manuscript' | 'files'>('manuscript'); const [job, setJob] = useState(null); + const [submission, setSubmission] = useState<'saving' | 'starting' | null>(null); const [pdf, setPdf] = useState(null); const [pdfRevision, setPdfRevision] = useState(null); const [pdfJobId, setPdfJobId] = useState(null); @@ -46,6 +52,8 @@ export function WorkspacePage() { const [pdfLoading, setPdfLoading] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [pollError, setPollError] = useState(null); + const [pdfError, setPdfError] = useState(null); const [activity, setActivity] = useState(null); const [presence, setPresence] = useState([]); const [historyKey, setHistoryKey] = useState(0); @@ -64,10 +72,30 @@ export function WorkspacePage() { const workspaceRef = useRef(null); const activityTimer = useRef(null); const pdfRequest = useRef(0); + const pdfContext = useRef(api); + pdfContext.current = api; + const pdfArtifact = useRef<{ api: typeof api; projectHash: string; sourceHash: string; jobId: string; data: ArrayBuffer; validated: boolean } | null>(null); + const pendingPdf = useRef<{ api: typeof api; projectHash: string; job: CompileJob; promise: Promise } | null>(null); + const latestPdfJob = useRef<{ api: typeof api; job: CompileJob } | null>(null); + const currentJob = useRef(null); + const jobVersion = useRef(0); + const submitting = useRef(false); const selectionRequest = useRef(0); const navigationRequest = useRef(0); const editorFlush = useRef<(() => Promise) | null>(null); + const updateJob = useCallback((next: CompileJob | null) => { + const current = currentJob.current; + // WebSocket completion can beat the POST response or an in-flight poll. + if (current && (!next || next.createdAt < current.createdAt || + (next.id === current.id && compileStage(next) < compileStage(current)))) return false; + currentJob.current = next; + jobVersion.current += 1; + setJob(next); + setPollError(null); + return true; + }, []); + const flashActivity = useCallback((text: string) => { setActivity(text); if (activityTimer.current != null) clearTimeout(activityTimer.current); @@ -91,21 +119,62 @@ export function WorkspacePage() { } else setSelected(null); }, [api, hash, project?.entryFile, selected?.id]); - const loadPdf = useCallback(async (nextJob: CompileJob) => { - if (!nextJob.hasPdf) return; - const requestId = ++pdfRequest.current; - setPdfLoading(true); - try { - const result = await api.compilePdf(hash, nextJob.id); - if (requestId !== pdfRequest.current) return; - setPdf(result.data); - setPdfRevision(nextJob.sourceHash); + const onPdfDocumentStatus = useCallback((data: ArrayBuffer, status: 'ready' | 'failed') => { + const artifact = pdfArtifact.current; + if (artifact?.api !== api || pdfContext.current !== api || artifact.data !== data) return; + if (status === 'failed') pdfArtifact.current = null; + else artifact.validated = true; + }, [api]); + + const loadPdf = useCallback((nextJob: CompileJob, cached = false): Promise => { + if (!nextJob.hasPdf) return Promise.resolve(); + // A delayed reload/reconnect fallback must not replace a newer good preview, + // even when an even newer compile has since failed. + if (latestPdfJob.current?.api === api && nextJob.createdAt < latestPdfJob.current.job.createdAt) return Promise.resolve(); + latestPdfJob.current = { api, job: nextJob }; + const artifact = pdfArtifact.current; + if (artifact?.api === api && artifact.projectHash === hash && artifact.sourceHash === nextJob.sourceHash && + (artifact.jobId === nextJob.id || (cached && artifact.validated))) { + // Only an explicit cache hit can associate validated bytes with another job. + artifact.jobId = nextJob.id; + ++pdfRequest.current; + pendingPdf.current = null; + setPdfLoading(false); + setPdfError(null); setPdfJobId(nextJob.hasSynctex ? nextJob.id : null); - } catch (reason) { - if (requestId === pdfRequest.current) setError(reason instanceof Error ? reason.message : 'Could not load PDF'); - } finally { - if (requestId === pdfRequest.current) setPdfLoading(false); + return Promise.resolve(); + } + const pending = pendingPdf.current; + if (pending?.api === api && pending.projectHash === hash && pending.job.id === nextJob.id) { + pending.job = nextJob; + return pending.promise; } + const requestId = ++pdfRequest.current; + // A rebuilt artifact can have the same source hash. Keep the old preview on + // screen, but do not let it satisfy later cache-hit events during repair. + pdfArtifact.current = null; + const download = { api, projectHash: hash, job: nextJob, promise: Promise.resolve() }; + pendingPdf.current = download; + setPdfLoading(true); + setPdfError(null); + download.promise = (async () => { + try { + const result = await api.compilePdf(hash, nextJob.id); + if (requestId !== pdfRequest.current || pdfContext.current !== api) return; + pdfArtifact.current = { api, projectHash: hash, sourceHash: nextJob.sourceHash, jobId: nextJob.id, data: result.data, validated: false }; + setPdf(result.data); + setPdfRevision(nextJob.sourceHash); + setPdfJobId(download.job.hasSynctex ? download.job.id : null); + } catch (reason) { + if (requestId === pdfRequest.current && pdfContext.current === api) setPdfError(reason instanceof Error ? reason.message : 'Could not load PDF'); + } finally { + if (requestId === pdfRequest.current) { + pendingPdf.current = null; + setPdfLoading(false); + } + } + })(); + return download.promise; }, [api, hash]); const loadProject = useCallback(async () => { @@ -116,30 +185,30 @@ export function WorkspacePage() { ]); setProject(projectResponse.project); setFiles(fileResponse.files); - setJob(compileResponse.job); + const accepted = updateJob(compileResponse.job); const initial = fileResponse.files.find((file) => file.path === projectResponse.project.entryFile) ?? fileResponse.files[0]; if (initial) setSelected((await api.getFile(hash, initial.id)).file); const previewJob = compileResponse.job?.status === 'succeeded' ? compileResponse.job : compileResponse.lastSuccessfulJob; - if (previewJob?.hasPdf) await loadPdf(previewJob); + if (previewJob?.hasPdf && (accepted || !currentJob.current?.hasPdf)) await loadPdf(previewJob); } catch (reason) { setError(reason instanceof Error ? reason.message : 'Could not open project'); } finally { setLoading(false); } - }, [api, hash, loadPdf]); + }, [api, hash, loadPdf, updateJob]); const reconcile = useCallback(async () => { try { const [projectResponse, compileResponse] = await Promise.all([api.getProject(hash), api.latestCompile(hash)]); setProject(projectResponse.project); - setJob(compileResponse.job); + const accepted = updateJob(compileResponse.job); await loadFiles(); const previewJob = compileResponse.job?.status === 'succeeded' ? compileResponse.job : compileResponse.lastSuccessfulJob; - if (previewJob?.hasPdf && previewJob.sourceHash !== pdfRevision) { + if (previewJob?.hasPdf && (accepted || !currentJob.current?.hasPdf)) { await loadPdf(previewJob); } } catch (reason) { if (reason instanceof ApiError && (reason.status === 403 || reason.status === 404)) navigate('/'); else setError(reason instanceof Error ? reason.message : 'Could not refresh the project'); } - }, [api, hash, loadFiles, loadPdf, navigate, pdfRevision]); + }, [api, hash, loadFiles, loadPdf, navigate, updateJob]); useEffect(() => { void loadProject(); }, [loadProject]); useEffect(() => () => { if (activityTimer.current != null) clearTimeout(activityTimer.current); }, []); @@ -150,9 +219,9 @@ export function WorkspacePage() { return; } if (event.type.startsWith('compile.') && event.job) { - setJob(event.job); - if (event.type === 'compile.ready') void loadPdf(event.job); - if (event.type === 'compile.failed') setLogOpen(true); + const accepted = updateJob(event.job); + if (accepted && event.type === 'compile.ready') void loadPdf(event.job, event.cached === true); + if (accepted && event.type === 'compile.failed') setLogOpen(true); } if (event.type === 'file.created' || event.type === 'file.deleted' || event.type === 'file.renamed') { void loadFiles(); @@ -170,21 +239,27 @@ export function WorkspacePage() { } } if (event.type === 'project.updated' || event.type === 'members.changed') void reconcile(); - }, [api, hash, loadFiles, loadPdf, flashActivity, project?.canWrite, reconcile, selected?.id]); + }, [api, hash, loadFiles, loadPdf, flashActivity, project?.canWrite, reconcile, selected?.id, updateJob]); useProjectEvents(api, hash, handleEvent); useEffect(() => { if (!job || (job.status !== 'queued' && job.status !== 'compiling')) return; + let stopped = false; + let polling = false; const timer = window.setInterval(() => { + if (polling) return; + polling = true; + const version = jobVersion.current; void api.latestCompile(hash).then(({ job: latest }) => { - if (!latest) return; - setJob(latest); + if (stopped || version !== jobVersion.current || !latest || !updateJob(latest)) return; if (latest.status === 'succeeded' && latest.hasPdf) void loadPdf(latest); if (latest.status === 'failed') setLogOpen(true); - }); + }).catch((reason: unknown) => { + if (!stopped && version === jobVersion.current) setPollError(reason instanceof Error ? reason.message : 'Could not check compilation. Retrying…'); + }).finally(() => { polling = false; }); }, 1400); - return () => window.clearInterval(timer); - }, [api, hash, job?.status, loadPdf]); + return () => { stopped = true; window.clearInterval(timer); }; + }, [api, hash, job?.status, loadPdf, updateJob]); const onPresence = useCallback((users: PresenceUser[]) => setPresence(users), []); const onCursorChange = useCallback((location: SourceLocation | null) => setSourceLocation(location), []); @@ -251,13 +326,17 @@ export function WorkspacePage() { } async function compile() { + if (submitting.current || (currentJob.current && compileStage(currentJob.current) < 2)) return; + submitting.current = true; + setSubmission('saving'); setError(null); try { await editorFlush.current?.(); + setSubmission('starting'); const response = await api.compile(hash); - setJob(response.job); - if (response.job.status === 'succeeded') await loadPdf(response.job); + if (updateJob(response.job) && response.job.status === 'succeeded') void loadPdf(response.job); } catch (reason) { setError(reason instanceof Error ? reason.message : 'Could not compile'); } + finally { submitting.current = false; setSubmission(null); } } async function exportZip() { @@ -308,7 +387,8 @@ export function WorkspacePage() { if (loading) return
; if (!project || !user) return
; - const compiling = job?.status === 'queued' || job?.status === 'compiling'; + const compiling = submission != null || job?.status === 'queued' || job?.status === 'compiling'; + const compileLabel = submission === 'saving' ? 'Saving changes…' : submission === 'starting' ? 'Starting compilation…' : 'Compiling…'; return
@@ -323,12 +403,12 @@ export function WorkspacePage() { {project.canManage && } {project.canManage && } - +
-
+
@@ -352,14 +432,14 @@ export function WorkspacePage() { : }
-
}>
+
}>
{editorMode === 'manuscript' ? `Manuscript${sourceLocation ? ` · ${sourceLocation.path}:${sourceLocation.line}` : ''}` : selected ? `${selected.path} · ${selected.kind}` : 'No file selected'}{project.memberCount} member{project.memberCount === 1 ? '' : 's'}
- + setNewFileOpen(false)} onCreate={createFile}/> setFileActions(null)} onRename={renameFile} onDelete={async () => { const id = fileActions!.id; await api.deleteFile(hash, id); if (selected?.id === id) setSelected(null); await loadFiles(); }} onSetEntry={async () => { const response = await api.updateProject(hash, { entryFile: fileActions!.path }); setProject(response.project); }}/> @@ -381,4 +461,5 @@ export function WorkspacePage() { } function isTextUpload(file: File) { return file.type.startsWith('text/') || /\.(tex|bib|sty|cls|txt|md|csv|json|ya?ml|xml|svg|tikz)$/i.test(file.name); } +function compileStage(job: CompileJob) { return job.status === 'queued' ? 0 : job.status === 'compiling' ? 1 : 2; } function toBase64(buffer: ArrayBuffer) { const bytes = new Uint8Array(buffer); let binary = ''; const chunk = 0x8000; for (let offset = 0; offset < bytes.length; offset += chunk) binary += String.fromCharCode(...bytes.subarray(offset, offset + chunk)); return btoa(binary); }