(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() {
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) =>
;
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() {
: }
-
+
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); }