From 2551f2a865d765494f4871cf141176bc1900fde6 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Thu, 30 Apr 2026 14:38:39 -0500 Subject: [PATCH 01/10] prototype and disable react compiler lint since we arent using it and it complicates things --- .claude/CLAUDE.md | 2 +- eslint.config.js | 3 +- packages/docs/audits/yjs-reactive-hooks.md | 231 ++++++++++++++++++ packages/web/package.json | 2 + .../components/admin/ui/AdminDataTable.tsx | 2 +- .../checklist/ChecklistYjsWrapper.tsx | 8 +- .../checklist/SplitScreenLayout.tsx | 2 +- .../src/components/dev/DevImportProject.tsx | 6 +- .../react/src/components/page-controls.tsx | 2 +- .../react/src/components/search-sidebar.tsx | 2 +- .../components/project/CreateProjectModal.tsx | 4 +- .../src/components/project/ProjectContext.tsx | 4 +- .../src/components/project/ProjectHeader.tsx | 4 +- .../src/components/project/ProjectView.tsx | 6 +- .../src/components/project/SlidingPanel.tsx | 2 +- .../project/add-studies/AddStudiesForm.tsx | 4 +- .../project/all-studies-tab/AllStudiesTab.tsx | 24 +- .../all-studies-tab/AssignReviewersModal.tsx | 8 +- .../all-studies-tab/EditPdfMetadataModal.tsx | 4 +- .../all-studies-tab/study-card/StudyCard.tsx | 10 +- .../GoogleDrivePickerLauncher.tsx | 2 +- .../google-drive/GoogleDrivePickerModal.tsx | 4 +- .../project/overview-tab/AddMemberModal.tsx | 2 +- .../project/overview-tab/ChartSection.tsx | 2 +- .../reconcile-tab/ReconciliationWrapper.tsx | 23 +- .../MultiPartQuestionPage.tsx | 6 +- .../ReconciliationQuestionPage.tsx | 6 +- .../engine/useReconciliationEngine.ts | 8 +- .../components/settings/BillingSettings.tsx | 2 +- .../settings/LinkedAccountsSection.tsx | 2 +- .../settings/MergeAccountsDialog.tsx | 4 +- .../src/components/settings/PlansSettings.tsx | 2 +- .../settings/ProfileInfoSection.tsx | 2 +- .../components/settings/SecuritySettings.tsx | 2 +- .../src/hooks/useReconciliationPresence.ts | 6 +- packages/web/src/hooks/useYText.ts | 2 +- .../checklists/useChecklistViewModel.ts | 9 +- .../web/src/primitives/useProject/sync.ts | 13 + packages/web/src/routes/_auth/check-email.tsx | 2 +- .../web/src/routes/_auth/complete-profile.tsx | 4 +- packages/web/src/stores/projectAtoms.ts | 106 ++++++++ pnpm-lock.yaml | 72 ++++++ 42 files changed, 517 insertions(+), 94 deletions(-) create mode 100644 packages/docs/audits/yjs-reactive-hooks.md create mode 100644 packages/web/src/stores/projectAtoms.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0239362d4..d7b5a9f31 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -157,7 +157,7 @@ retries += 1; - **Import stores directly** - Use Zustand stores from `@/stores/` instead of prop-drilling shared state - Shared state lives in Zustand stores under `packages/web/src/stores/` -- Avoid `useMemo` or `useCallback` - let the React Compiler handle memoization +- Avoid `useMemo` or `useCallback` when not necessary - Prefer newer React primitives where possible, we are always on the latest version - Use `useEffectEvent` for stable event handler references inside effects - Use `useLayoutEffect` for DOM measurements before paint diff --git a/eslint.config.js b/eslint.config.js index ce5dde527..1a6778840 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -319,7 +319,8 @@ export default [ }, }, rules: { - ...reactHooks.configs.recommended.rules, + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', }, }, { diff --git a/packages/docs/audits/yjs-reactive-hooks.md b/packages/docs/audits/yjs-reactive-hooks.md new file mode 100644 index 000000000..6880e5584 --- /dev/null +++ b/packages/docs/audits/yjs-reactive-hooks.md @@ -0,0 +1,231 @@ +Reactive Yjs Hooks Prototype -- @tldraw/state + +Status: Proposal +Last updated: 2026-04-30 + + +Problem + +The sync manager (sync.ts) already does per-study dirty tracking via studyCache. +But the final step -- sortedStudies = [...studyCache.values()] -- creates a new +array every sync, which replaces the entire studies array in Zustand via immer. +Every component that reads any study re-renders, even if their specific study +didn't change. Forms re-initialize, modals lose state, selectors fire for +unrelated updates. + +This is a structural problem, not a bug-by-bug fix. The Zustand array is the +wrong data structure for per-entity collaborative state. + + +Approach + +Use @tldraw/state (npm package, ~3,800 lines, only depends on @tldraw/utils) as +the reactive primitive layer between Yjs and React. Keep Yjs for sync/CRDT. Keep +Zustand for non-collaborative UI state (tab selection, modal open/close, etc). + +@tldraw/state provides: + - atom(name, value, options?) -- mutable cell with equality-gated set() + - computed(name, fn) -- derived value that only recomputes when dependencies change + - AtomMap -- reactive Map> with per-key subscriptions + - useValue(atom) -- React hook via useSyncExternalStore, re-renders only when + the atom's value actually changes (by equality check) + - transact(fn) -- batch multiple atom writes into one notification pass + +The key property: atom.set(newValue) is a no-op if newValue equals the current +value (shallow equality by default, configurable). This is the missing gate that +studyCache already computes but Zustand discards. + + +Architecture + + Y.Doc (Yjs) + | + | observe / observeDeep + v + Sync Manager (sync.ts) + | + | atom.set(serializedStudy) <-- equality gate here + v + AtomMap <-- one atom per study + | + | useValue(atom) <-- React subscribes to individual atoms + v + React components <-- only re-render when THEIR study changes + + + Zustand stays for: + - Connection lifecycle state + - UI state (active tab, modal open/close, selection) + - Non-collaborative derived state (project stats, preferences) + + Zustand does NOT keep: + - Project metadata (name, settings) -- same referential instability problem + - Members list -- same problem, just less frequent + - Studies -- the primary motivation for this work + + All collaborative data from Y.Doc goes through atoms. This avoids having + two read patterns for the same class of data. + + +What to prototype + +Phase 1: StudyAtomMap + useStudy hook + + Install @tldraw/state and @tldraw/state-react. + + Create a StudyAtomMap in the sync manager: + + const studyAtoms = new AtomMap() + + In handleReviewsEvents, instead of rebuilding the full array and calling + setProjectData, write individual atoms: + + for (const [studyId, study] of studyCache) { + if (dirtyStudyIds.has(studyId)) { + studyAtoms.set(studyId, study) + } + } + + Expose a React hook: + + function useStudy(studyId: string): StudyInfo | undefined { + const atom = studyAtoms.getAtom(studyId) + return useValue(atom) + } + + Expose a study order atom for list rendering: + + const studyOrder = atom('studyOrder', []) + + function useStudyIds(): string[] { + return useValue(studyOrder) + } + + List components use useStudyIds() to get the array of IDs, then each row + uses useStudy(id). Adding/removing studies changes the order atom. Editing + a study's fields only touches that study's atom -- other rows don't re-render. + +Phase 1b: Meta and members atoms + + Apply the same pattern to project metadata and members. These have the same + referential instability problem as studies -- just triggered less often. Since + the atom infrastructure exists from Phase 1, this is trivial: + + const projectMeta = atom('projectMeta', defaultMeta) + const membersAtom = atom('members', []) + + This ensures all collaborative Y.Doc data flows through one read pattern + (atoms + useValue), with Zustand reserved for genuinely non-collaborative + state. + +Phase 2: Snapshot isolation hook + + Even with per-study atoms solving most re-render problems, same-entity + conflicts still need isolation. If two users are both editing study-5's + reviewers simultaneously, the atom for study-5 will update from the remote + peer. A modal editing that same study needs to capture-and-hold during the + editing session. + + Atoms reduce the blast radius (unrelated studies no longer trigger it), but + same-entity remote updates still can. Generalize the useEffectEvent pattern + from the reviewer modal fix: + + function useSnapshotValue(liveValue: T, isEditing: boolean): T { + const snapshotRef = useRef(liveValue); + if (!isEditing) snapshotRef.current = liveValue; + return isEditing ? snapshotRef.current : liveValue; + } + + Every modal/form that edits collaborative data uses useSnapshotValue with + the atom-backed live value. The atom provides referential stability across + unrelated syncs; the snapshot provides isolation from same-entity syncs + during edits. + +Phase 3: Computed selectors + + Replace derived Zustand selectors with computed(): + + const studiesForTab = computed('studiesForTab', () => { + return studyOrder.get() + .map(id => studyAtoms.get(id)) + .filter(s => matchesTab(s, activeTab)) + }) + + These only recompute when their input atoms change. If a study that isn't + in the current tab gets updated, the tab's computed doesn't fire. + +Phase 4: Migrate one component end-to-end + + Pick ChecklistYjsWrapper or AllStudiesTab. Replace the Zustand + selectStudies selector with useStudy / useStudyIds. Verify: + + - Opening a modal and editing doesn't get interrupted by unrelated syncs + - Assigning reviewers in the modal survives background Y.Doc updates + - The component only re-renders when its specific study changes + - No regressions in E2E tests + +Phase 5: Evaluate and expand + + If Phase 3 validates the approach: + + - Migrate remaining study consumers + - Consider AtomMaps for checklist answers (useChecklistAnswers currently + observes the entire reviews Y.Map -- same broad-observer problem) + - Remove studies array from Zustand projectStore + - Delete the reactive-yjs-hooks-plan.md predecessor doc + + +What NOT to prototype + + - Don't replace Yjs sync -- keep y-websocket / Durable Object sync as-is + - Don't build a custom sync protocol -- Yjs CRDTs work fine for this use case + - Don't remove Zustand -- it stays for connection lifecycle, UI state, and + non-collaborative derived state. All collaborative Y.Doc data moves to atoms. + - Don't vendor / fork @tldraw/state yet -- use the npm package first, only + vendor if the dependency becomes a problem + - Don't add tldraw's HistoryBuffer or rollback transactions -- not needed + for this use case + + +Dependencies + + @tldraw/state (4.5.10) -- core atoms, computed, transactions + @tldraw/state-react (4.5.10) -- useValue, useComputed, useAtom hooks + @tldraw/utils (4.5.10) -- transitive dep, 5 utility functions + + All are MIT licensed. Combined footprint is ~4,400 lines. No other transitive + dependencies. + + +Risk assessment + + Low risk: + - Additive change -- new hooks alongside existing Zustand, migrate gradually + - @tldraw/state is well-tested, used in production by tldraw + - Existing E2E tests validate behavior, not implementation + + Medium risk: + - Two state systems during migration (atoms + Zustand) -- need clear ownership + boundaries per data type + + Watch for: + - Interaction between tldraw's transact() and Yjs transactions + - Whether @tldraw/state-react's useValue plays well with React Compiler -- + useValue uses useSyncExternalStore (which the compiler handles fine) but + tldraw may wrap it with patterns the compiler doesn't optimize well. + Worth a 10-minute check: install the packages, write a test component, + run the compiler, see what it emits. + + +Success criteria + + - AssignReviewersModal no longer needs useEffectEvent guard (the underlying + atom doesn't change reference when an unrelated study syncs) + - useChecklistAnswers uses the study atom for reading finalized state (study + status, reviewer assignments), but retains direct Y.Doc access for the live + editing path (Y.Text instances for collaborative checklist editing). Both + read patterns coexist -- atoms for serialized state, direct Yjs for live + collaborative types + - Opening a modal during active collaboration doesn't re-initialize form state + - No increase in total re-render count (measure with React DevTools profiler) + - All existing E2E tests pass without modification diff --git a/packages/web/package.json b/packages/web/package.json index 971d3bb72..d3deb42d1 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -71,6 +71,8 @@ "@tanstack/react-router": "^1.168.25", "@tanstack/react-start": "^1.167.50", "@tanstack/react-table": "^8.21.3", + "@tldraw/state": "4.5.10", + "@tldraw/state-react": "4.5.10", "better-auth": "^1.6.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/packages/web/src/components/admin/ui/AdminDataTable.tsx b/packages/web/src/components/admin/ui/AdminDataTable.tsx index 94e1a0600..32c9afbe7 100644 --- a/packages/web/src/components/admin/ui/AdminDataTable.tsx +++ b/packages/web/src/components/admin/ui/AdminDataTable.tsx @@ -37,7 +37,7 @@ export function AdminDataTable({ }: AdminDataTableProps) { const [sorting, setSorting] = useState([]); - // eslint-disable-next-line react-hooks/incompatible-library -- TanStack Table is not compatible with React Compiler memoization + const table = useReactTable({ data: data || [], columns: columns || [], diff --git a/packages/web/src/components/checklist/ChecklistYjsWrapper.tsx b/packages/web/src/components/checklist/ChecklistYjsWrapper.tsx index fab98ab3c..0e31882ab 100644 --- a/packages/web/src/components/checklist/ChecklistYjsWrapper.tsx +++ b/packages/web/src/components/checklist/ChecklistYjsWrapper.tsx @@ -96,7 +96,7 @@ export function ChecklistYjsWrapper({ projectId, studyId, checklistId }: Checkli // Auto-select primary PDF useEffect(() => { if (defaultPdf && !selectedPdfId) { - setSelectedPdfId(defaultPdf.id); // eslint-disable-line react-hooks/set-state-in-effect -- one-time sync from derived data + setSelectedPdfId(defaultPdf.id); } }, [defaultPdf, selectedPdfId]); @@ -105,7 +105,7 @@ export function ChecklistYjsWrapper({ projectId, studyId, checklistId }: Checkli const fileName = currentPdf?.fileName; if (!fileName || !orgId || attemptedPdfFile === fileName || pdfLoading) return; - setAttemptedPdfFile(fileName); // eslint-disable-line react-hooks/set-state-in-effect -- guards duplicate fetches + setAttemptedPdfFile(fileName); setPdfLoading(true); setPdfData(null); @@ -143,7 +143,7 @@ export function ChecklistYjsWrapper({ projectId, studyId, checklistId }: Checkli setAttemptedPdfFile(null); }, []); - /* eslint-disable react-hooks/preserve-manual-memoization -- async callback with complex closure */ + const handlePdfChange = useCallback( async (data: ArrayBuffer, fileName: string) => { if (!orgId) { @@ -184,7 +184,7 @@ export function ChecklistYjsWrapper({ projectId, studyId, checklistId }: Checkli }, [orgId, projectId, studyId, studyPdfs, user?.id, addPdfToStudy], ); - /* eslint-enable react-hooks/preserve-manual-memoization */ + const isChecklistValid = useMemo(() => { if (!checklistForUI) return false; diff --git a/packages/web/src/components/checklist/SplitScreenLayout.tsx b/packages/web/src/components/checklist/SplitScreenLayout.tsx index 9e9ac72af..be2f86a61 100644 --- a/packages/web/src/components/checklist/SplitScreenLayout.tsx +++ b/packages/web/src/components/checklist/SplitScreenLayout.tsx @@ -36,7 +36,7 @@ export function SplitScreenLayout({ // Sync showSecondPanel with prop changes useEffect(() => { - setShowSecondPanel(showSecondPanelProp ?? false); // eslint-disable-line react-hooks/set-state-in-effect -- syncing from prop + setShowSecondPanel(showSecondPanelProp ?? false); }, [showSecondPanelProp]); // Extract exactly two panels from children diff --git a/packages/web/src/components/dev/DevImportProject.tsx b/packages/web/src/components/dev/DevImportProject.tsx index a0fe434ac..6fcb136dc 100644 --- a/packages/web/src/components/dev/DevImportProject.tsx +++ b/packages/web/src/components/dev/DevImportProject.tsx @@ -97,7 +97,7 @@ export function DevImportProject() { useEffect(() => { if (orgs.length > 0 && !selectedOrgId) { - setSelectedOrgId(orgs[0].id); // eslint-disable-line react-hooks/set-state-in-effect -- one-time default + setSelectedOrgId(orgs[0].id); } }, [orgs, selectedOrgId]); @@ -109,7 +109,7 @@ export function DevImportProject() { useEffect(() => { if (selectedTemplate) { const tmpl = TEMPLATES.find(t => t.name === selectedTemplate); - if (tmpl) setProjectName(tmpl.description); // eslint-disable-line react-hooks/set-state-in-effect -- derived default + if (tmpl) setProjectName(tmpl.description); } }, [selectedTemplate]); @@ -548,7 +548,7 @@ function UserSearchField({ useEffect(() => { if (debouncedQuery.length < 2) { - setResults([]); // eslint-disable-line react-hooks/set-state-in-effect -- clearing results when query is too short + setResults([]); return; } let cancelled = false; diff --git a/packages/web/src/components/pdf/embedpdf/react/src/components/page-controls.tsx b/packages/web/src/components/pdf/embedpdf/react/src/components/page-controls.tsx index aaf3c2431..fc8b88a0b 100644 --- a/packages/web/src/components/pdf/embedpdf/react/src/components/page-controls.tsx +++ b/packages/web/src/components/pdf/embedpdf/react/src/components/page-controls.tsx @@ -19,7 +19,7 @@ export function PageControls({ documentId }: PageControlsProps) { const [inputValue, setInputValue] = useState(currentPage.toString()); useEffect(() => { - setInputValue(currentPage.toString()); // eslint-disable-line react-hooks/set-state-in-effect -- syncing from external scroll state + setInputValue(currentPage.toString()); }, [currentPage]); const startHideTimer = useCallback(() => { diff --git a/packages/web/src/components/pdf/embedpdf/react/src/components/search-sidebar.tsx b/packages/web/src/components/pdf/embedpdf/react/src/components/search-sidebar.tsx index 9adc6d03f..577856c29 100644 --- a/packages/web/src/components/pdf/embedpdf/react/src/components/search-sidebar.tsx +++ b/packages/web/src/components/pdf/embedpdf/react/src/components/search-sidebar.tsx @@ -59,7 +59,7 @@ export function SearchSidebar({ documentId, onClose }: SearchSidebarProps) { // Sync inputValue with persisted state.query when state loads useEffect(() => { - setInputValue(state.query || ''); // eslint-disable-line react-hooks/set-state-in-effect -- syncing from external search state + setInputValue(state.query || ''); }, [state.query, documentId]); useEffect(() => { diff --git a/packages/web/src/components/project/CreateProjectModal.tsx b/packages/web/src/components/project/CreateProjectModal.tsx index f47b681b4..2d4c0d079 100644 --- a/packages/web/src/components/project/CreateProjectModal.tsx +++ b/packages/web/src/components/project/CreateProjectModal.tsx @@ -51,7 +51,7 @@ export function CreateProjectModal({ open, onOpenChange }: CreateProjectModalPro // Auto-select first org when orgs load and user has multiple useEffect(() => { if (orgs.length > 1 && !selectedOrgId) { - setSelectedOrgId(orgs[0].id); // eslint-disable-line react-hooks/set-state-in-effect -- one-time default from loaded data + setSelectedOrgId(orgs[0].id); } }, [orgs, selectedOrgId]); @@ -64,7 +64,7 @@ export function CreateProjectModal({ open, onOpenChange }: CreateProjectModalPro // Reset form when dialog closes useEffect(() => { if (!open) { - setProjectName(''); // eslint-disable-line react-hooks/set-state-in-effect -- resetting form on dialog close + setProjectName(''); setProjectDescription(''); setSelectedOrgId(null); } diff --git a/packages/web/src/components/project/ProjectContext.tsx b/packages/web/src/components/project/ProjectContext.tsx index 8b15ff9ee..0e7916a2b 100644 --- a/packages/web/src/components/project/ProjectContext.tsx +++ b/packages/web/src/components/project/ProjectContext.tsx @@ -5,9 +5,9 @@ */ import { createContext, useContext, useMemo, useCallback } from 'react'; -import { useProjectStore, selectMembers } from '@/stores/projectStore'; import { useAuthStore, selectUser } from '@/stores/authStore'; import { useProjectOrgId } from '@/hooks/useProjectOrgId'; +import { useProjectMembers } from '@/stores/projectAtoms'; export interface ProjectMember { userId: string; @@ -39,7 +39,7 @@ interface ProjectProviderProps { export function ProjectProvider({ projectId, children }: ProjectProviderProps) { const user = useAuthStore(selectUser); const orgId = useProjectOrgId(projectId); - const members = useProjectStore(s => selectMembers(s, projectId)) as ProjectMember[]; + const members = useProjectMembers(projectId) as ProjectMember[]; const userRole = useMemo(() => { if (!user) return null; diff --git a/packages/web/src/components/project/ProjectHeader.tsx b/packages/web/src/components/project/ProjectHeader.tsx index a23806b8c..631686e5d 100644 --- a/packages/web/src/components/project/ProjectHeader.tsx +++ b/packages/web/src/components/project/ProjectHeader.tsx @@ -39,11 +39,11 @@ export function ProjectHeader({ // Sync local state when external data loads useEffect(() => { - if (name) setLocalName(name); // eslint-disable-line react-hooks/set-state-in-effect -- syncing from prop + if (name) setLocalName(name); }, [name]); useEffect(() => { - setLocalDescription(description || ''); // eslint-disable-line react-hooks/set-state-in-effect -- syncing from prop + setLocalDescription(description || ''); }, [description]); const handleNameCommit = useCallback( diff --git a/packages/web/src/components/project/ProjectView.tsx b/packages/web/src/components/project/ProjectView.tsx index f139d7f0f..921692a4c 100644 --- a/packages/web/src/components/project/ProjectView.tsx +++ b/packages/web/src/components/project/ProjectView.tsx @@ -99,7 +99,7 @@ function ProjectViewInner({ projectId }: ProjectViewProps) { ) return; const pdfs = pendingPdfs; - setPendingPdfs(null); // eslint-disable-line react-hooks/set-state-in-effect -- one-time consumption + setPendingPdfs(null); for (const pdf of pdfs) { const studyName = pdf.fileName ? pdf.fileName.replace(/\.pdf$/i, '') : 'Untitled Study'; @@ -158,7 +158,7 @@ function ProjectViewInner({ projectId }: ProjectViewProps) { ) return; const refs = pendingRefs; - setPendingRefs(null); // eslint-disable-line react-hooks/set-state-in-effect -- one-time consumption + setPendingRefs(null); for (const ref of refs) { project.study.create(ref.title, ref.metadata?.abstract || '', ref.metadata || {}); } @@ -174,7 +174,7 @@ function ProjectViewInner({ projectId }: ProjectViewProps) { ) return; const driveFiles = pendingDriveFiles; - setPendingDriveFiles(null); // eslint-disable-line react-hooks/set-state-in-effect -- one-time consumption + setPendingDriveFiles(null); for (const file of driveFiles) { const title = file.title || file.name.replace(/\.pdf$/i, ''); const metadata = { diff --git a/packages/web/src/components/project/SlidingPanel.tsx b/packages/web/src/components/project/SlidingPanel.tsx index 6469910b1..31160b50e 100644 --- a/packages/web/src/components/project/SlidingPanel.tsx +++ b/packages/web/src/components/project/SlidingPanel.tsx @@ -37,7 +37,7 @@ export function SlidingPanel({ // Mount/animate lifecycle useEffect(() => { if (open) { - setMounted(true); // eslint-disable-line react-hooks/set-state-in-effect -- intentional mount-then-animate pattern + setMounted(true); requestAnimationFrame(() => { requestAnimationFrame(() => { setVisible(true); diff --git a/packages/web/src/components/project/add-studies/AddStudiesForm.tsx b/packages/web/src/components/project/add-studies/AddStudiesForm.tsx index ee8e92fe9..eb2e43783 100644 --- a/packages/web/src/components/project/add-studies/AddStudiesForm.tsx +++ b/packages/web/src/components/project/add-studies/AddStudiesForm.tsx @@ -80,7 +80,7 @@ export function AddStudiesForm({ const isExpanded = alwaysExpanded || expanded || studies.hasAnyStudies(); - /* eslint-disable react-hooks/refs -- intentional ref-sync for event handler closures */ + const hasExistingStudiesRef = useRef(hasExistingStudies); hasExistingStudiesRef.current = hasExistingStudies; const isExpandedRef = useRef(isExpanded); @@ -89,7 +89,7 @@ export function AddStudiesForm({ isDraggingOverRef.current = isDraggingOver; const handlePdfSelectRef = useRef(studies.handlePdfSelect); handlePdfSelectRef.current = studies.handlePdfSelect; - /* eslint-enable react-hooks/refs */ + // Restore state from OAuth redirect. // Expand unconditionally since restoreState enqueues React state updates diff --git a/packages/web/src/components/project/all-studies-tab/AllStudiesTab.tsx b/packages/web/src/components/project/all-studies-tab/AllStudiesTab.tsx index 40be441db..0a9f7b6bf 100644 --- a/packages/web/src/components/project/all-studies-tab/AllStudiesTab.tsx +++ b/packages/web/src/components/project/all-studies-tab/AllStudiesTab.tsx @@ -14,10 +14,10 @@ import { OutcomeManager } from '../outcomes/OutcomeManager'; import { useProjectStore, selectStudies, - selectMembers, selectConnectionPhase, } from '@/stores/projectStore'; import type { StudyInfo } from '@/stores/projectStore'; +import { useStudyIds, useProjectMembers } from '@/stores/projectAtoms'; import { project } from '@/project'; import { useProjectContext } from '../ProjectContext'; import { @@ -38,10 +38,11 @@ export function AllStudiesTab() { const [showReviewersModal, setShowReviewersModal] = useState(false); const [editingStudy, setEditingStudy] = useState(null); + const studyIds = useStudyIds(projectId); + const members = useProjectMembers(projectId); const studies = useProjectStore(s => selectStudies(s, projectId)); - const members = useProjectStore(s => selectMembers(s, projectId)); const connectionState = useProjectStore(s => selectConnectionPhase(s, projectId)); - const hasData = connectionState.phase === 'synced' || studies.length > 0; + const hasData = connectionState.phase === 'synced' || studyIds.length > 0; // Restore state after OAuth redirect useEffect(() => { @@ -82,7 +83,7 @@ export function AllStudiesTab() { ); const shouldShowReviewerAssignment = - isOwner && studies.length > 0 && unassignedStudies.length > 0; + isOwner && studyIds.length > 0 && unassignedStudies.length > 0; const handleAssignReviewers = useCallback((studyId: string, updates: Record) => { project.study.update(studyId, updates); @@ -151,18 +152,19 @@ export function AllStudiesTab() {

- {studies.length} {studies.length === 1 ? 'study' : 'studies'} in this project + {studyIds.length} {studyIds.length === 1 ? 'study' : 'studies'} in this project

- {studies.length > 0 ? + {studyIds.length > 0 ?
- {studies.map(study => ( + {studyIds.map(studyId => ( toggleStudyExpanded(study.id)} + key={studyId} + projectId={projectId} + studyId={studyId} + expanded={expandedStudies.has(studyId)} + onToggleExpanded={() => toggleStudyExpanded(studyId)} getMember={getMember} onAssignReviewers={s => { setEditingStudy(s); diff --git a/packages/web/src/components/project/all-studies-tab/AssignReviewersModal.tsx b/packages/web/src/components/project/all-studies-tab/AssignReviewersModal.tsx index 9c695df73..40de817ec 100644 --- a/packages/web/src/components/project/all-studies-tab/AssignReviewersModal.tsx +++ b/packages/web/src/components/project/all-studies-tab/AssignReviewersModal.tsx @@ -4,8 +4,8 @@ import { useState, useEffect, useEffectEvent, useMemo, useCallback } from 'react'; import { UserIcon } from 'lucide-react'; -import { useProjectStore, selectMembers } from '@/stores/projectStore'; import type { StudyInfo } from '@/stores/projectStore'; +import { useProjectMembers } from '@/stores/projectAtoms'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Select, @@ -37,7 +37,7 @@ export function AssignReviewersModal({ const [reviewer2, setReviewer2] = useState('_unassigned'); const [saving, setSaving] = useState(false); - const members = useProjectStore(s => selectMembers(s, projectId)); + const members = useProjectMembers(projectId); const memberItems = useMemo( () => [ @@ -60,14 +60,14 @@ export function AssignReviewersModal({ }); useEffect(() => { - /* eslint-disable react-hooks/set-state-in-effect -- one-time form init on modal open/close */ + if (open) { initializeForm(); } else { setReviewer1('_unassigned'); setReviewer2('_unassigned'); } - /* eslint-enable react-hooks/set-state-in-effect */ + }, [open]); const handleSave = useCallback(async () => { diff --git a/packages/web/src/components/project/all-studies-tab/EditPdfMetadataModal.tsx b/packages/web/src/components/project/all-studies-tab/EditPdfMetadataModal.tsx index eb84166e5..a6d023543 100644 --- a/packages/web/src/components/project/all-studies-tab/EditPdfMetadataModal.tsx +++ b/packages/web/src/components/project/all-studies-tab/EditPdfMetadataModal.tsx @@ -34,7 +34,7 @@ export function EditPdfMetadataModal({ const [saving, setSaving] = useState(false); useEffect(() => { - /* eslint-disable react-hooks/set-state-in-effect -- syncing form state from prop on modal open */ + if (pdf && open) { setTitle(pdf.title || ''); setFirstAuthor(pdf.firstAuthor || ''); @@ -42,7 +42,7 @@ export function EditPdfMetadataModal({ setJournal(pdf.journal || ''); setDoi(pdf.doi || ''); } - /* eslint-enable react-hooks/set-state-in-effect */ + }, [pdf, open]); const handleSave = useCallback(async () => { diff --git a/packages/web/src/components/project/all-studies-tab/study-card/StudyCard.tsx b/packages/web/src/components/project/all-studies-tab/study-card/StudyCard.tsx index f42c4ca36..8babb0d2d 100644 --- a/packages/web/src/components/project/all-studies-tab/study-card/StudyCard.tsx +++ b/packages/web/src/components/project/all-studies-tab/study-card/StudyCard.tsx @@ -5,11 +5,13 @@ import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'; import { type ProjectMember } from '@/components/project/ProjectContext'; import type { StudyInfo } from '@/stores/projectStore'; +import { useStudy } from '@/stores/projectAtoms'; import { StudyCardHeader } from './StudyCardHeader'; import { StudyPdfSection } from './StudyPdfSection'; interface StudyCardProps { - study: StudyInfo; + projectId: string; + studyId: string; expanded: boolean; onToggleExpanded: () => void; getMember?: (userId: string) => ProjectMember | null; @@ -19,7 +21,8 @@ interface StudyCardProps { } export function StudyCard({ - study, + projectId, + studyId, expanded, onToggleExpanded, getMember, @@ -27,6 +30,9 @@ export function StudyCard({ onOpenGoogleDrive, readOnly, }: StudyCardProps) { + const study = useStudy(projectId, studyId); + if (!study) return null; + return (
diff --git a/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.tsx b/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.tsx index 41852487f..720e0f986 100644 --- a/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.tsx +++ b/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.tsx @@ -134,7 +134,7 @@ export function GoogleDrivePickerLauncher({ // Check connection status on mount useEffect(() => { if (!active) return; - checkConnectionStatus(); // eslint-disable-line react-hooks/set-state-in-effect -- triggers async status check + checkConnectionStatus(); }, [active, checkConnectionStatus]); const handleConnectGoogle = useCallback(async () => { diff --git a/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.tsx b/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.tsx index d75e6536b..b370956eb 100644 --- a/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.tsx +++ b/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.tsx @@ -32,12 +32,12 @@ export function GoogleDrivePickerModal({ const [importing, setImporting] = useState(false); // Use refs for values that may change between modal open and picker callback - /* eslint-disable react-hooks/refs -- intentional ref-sync for async callback closures */ + const studyIdRef = useRef(studyId); studyIdRef.current = studyId; const onImportSuccessRef = useRef(onImportSuccess); onImportSuccessRef.current = onImportSuccess; - /* eslint-enable react-hooks/refs */ + const handlePicked = useCallback( async (picked: Array<{ id: string; name: string }>, pickerStudyId?: string) => { diff --git a/packages/web/src/components/project/overview-tab/AddMemberModal.tsx b/packages/web/src/components/project/overview-tab/AddMemberModal.tsx index a040fb87b..ba03a4a72 100644 --- a/packages/web/src/components/project/overview-tab/AddMemberModal.tsx +++ b/packages/web/src/components/project/overview-tab/AddMemberModal.tsx @@ -69,7 +69,7 @@ export function AddMemberModal({ // Search users on debounced query change useEffect(() => { if (debouncedQuery.length < 2) { - setSearchResults([]); // eslint-disable-line react-hooks/set-state-in-effect -- clearing results when query is too short + setSearchResults([]); return; } let cancelled = false; diff --git a/packages/web/src/components/project/overview-tab/ChartSection.tsx b/packages/web/src/components/project/overview-tab/ChartSection.tsx index f657f7158..bf62e73b7 100644 --- a/packages/web/src/components/project/overview-tab/ChartSection.tsx +++ b/packages/web/src/components/project/overview-tab/ChartSection.tsx @@ -141,7 +141,7 @@ export function ChartSection({ studies }: ChartSectionProps) { // Sync custom labels when raw data changes useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- syncing labels from data + setCustomLabels(prev => { const currentIds = prev.map(l => l.id).join(','); const newIds = rawChecklistData.map(d => d.id).join(','); diff --git a/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.tsx b/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.tsx index aab97ebf8..9a17f5177 100644 --- a/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.tsx +++ b/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.tsx @@ -8,12 +8,8 @@ import { useNavigate } from '@tanstack/react-router'; import { useProjectContext } from '@/components/project/ProjectContext'; import { connectionPool } from '@/project/ConnectionPool'; import { buildChecklistAnswerInput, type TextRef } from '@/primitives/useProject/checklists'; -import { - useProjectStore, - selectMembers, - selectConnectionPhase, - selectStudy, -} from '@/stores/projectStore'; +import { useProjectStore, selectConnectionPhase } from '@/stores/projectStore'; +import { useStudy, useProjectMembers } from '@/stores/projectAtoms'; import { useAuthStore, selectUser } from '@/stores/authStore'; import { ACCESS_DENIED_ERRORS } from '@/constants/errors.js'; import { @@ -83,10 +79,9 @@ export function ReconciliationWrapper({ }; }, [user]); - // Read data from store (use stable selectors to avoid infinite re-render loops) const connectionState = useProjectStore(s => selectConnectionPhase(s, projectId)); - const currentStudy = useProjectStore(s => selectStudy(s, projectId, studyId)); - const members = useProjectStore(s => selectMembers(s, projectId)); + const currentStudy = useStudy(projectId, studyId); + const members = useProjectMembers(projectId); // Watch for access-denied errors and redirect useEffect(() => { @@ -123,7 +118,7 @@ export function ReconciliationWrapper({ // Auto-select primary PDF when study loads useEffect(() => { if (defaultPdf && !selectedPdfId) { - setSelectedPdfId(defaultPdf.id); // eslint-disable-line react-hooks/set-state-in-effect -- one-time sync from derived data + setSelectedPdfId(defaultPdf.id); } }, [defaultPdf, selectedPdfId]); @@ -132,7 +127,7 @@ export function ReconciliationWrapper({ const fileName = currentPdf?.fileName; if (!fileName || !orgId || attemptedPdfFile === fileName || pdfLoading) return; - setAttemptedPdfFile(fileName); // eslint-disable-line react-hooks/set-state-in-effect -- guards duplicate fetches + setAttemptedPdfFile(fileName); setPdfLoading(true); setPdfData(null); @@ -260,7 +255,7 @@ export function ReconciliationWrapper({ return; } - /* eslint-disable react-hooks/set-state-in-effect -- one-time reconciled checklist initialization */ + setHasCheckedForReconciled(true); setReconciledChecklistLoading(true); @@ -324,7 +319,7 @@ export function ReconciliationWrapper({ setReconciledChecklistId(newChecklistId); setReconciledChecklistLoading(false); - /* eslint-enable react-hooks/set-state-in-effect */ + }, [ currentStudy, connectionState.phase, @@ -360,7 +355,7 @@ export function ReconciliationWrapper({ checklist2Id, reconciledChecklistId: firstCreated.id, }); - setReconciledChecklistId(firstCreated.id); // eslint-disable-line react-hooks/set-state-in-effect -- resolving multi-client race + setReconciledChecklistId(firstCreated.id); } } }, [ diff --git a/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/MultiPartQuestionPage.tsx b/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/MultiPartQuestionPage.tsx index 59984d15a..7c91deec0 100644 --- a/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/MultiPartQuestionPage.tsx +++ b/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/MultiPartQuestionPage.tsx @@ -70,7 +70,7 @@ export function MultiPartQuestionPage({ // Reset auto-fill tracking when question changes useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- reset on question change + setHasAutoFilled(false); }, [questionKey]); @@ -83,7 +83,7 @@ export function MultiPartQuestionPage({ if (finalAnswers && typeof finalAnswers === 'object') { const hasParts = dataKeys.some((dk: string) => finalAnswers[dk]); if (hasParts) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- syncing from Yjs props + setLocalFinal(JSON.parse(JSON.stringify(finalAnswers))); if (multiPartEqual(finalAnswers, reviewer1Answers, dataKeys)) { setSelectedSource('reviewer1'); @@ -126,7 +126,7 @@ export function MultiPartQuestionPage({ ) { const newFinal = JSON.parse(JSON.stringify(reviewer1Answers)); onFinalChange(newFinal); - // eslint-disable-next-line react-hooks/set-state-in-effect -- auto-fill guard + setHasAutoFilled(true); } }, [isAgreement, finalAnswers, reviewer1Answers, dataKeys, hasAutoFilled, onFinalChange]); diff --git a/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/ReconciliationQuestionPage.tsx b/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/ReconciliationQuestionPage.tsx index 776b499c6..a98975b2f 100644 --- a/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/ReconciliationQuestionPage.tsx +++ b/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/ReconciliationQuestionPage.tsx @@ -91,7 +91,7 @@ function SingleQuestionPage({ // Reset auto-fill tracking when question changes useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- reset on question change + setHasAutoFilled(false); }, [questionKey]); @@ -100,7 +100,7 @@ function SingleQuestionPage({ // Initialize local final from props or default to reviewer1 useEffect(() => { if (finalAnswers) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- syncing from Yjs props + setLocalFinal(JSON.parse(JSON.stringify(finalAnswers))); if (answersEqual(finalAnswers, reviewer1Answers)) { setSelectedSource('reviewer1'); @@ -132,7 +132,7 @@ function SingleQuestionPage({ ) { const newFinal = JSON.parse(JSON.stringify(reviewer1Answers)); onFinalChange(newFinal); - // eslint-disable-next-line react-hooks/set-state-in-effect -- auto-fill guard + setHasAutoFilled(true); } }, [isAgreement, finalAnswers, reviewer1Answers, hasAutoFilled, onFinalChange]); diff --git a/packages/web/src/components/project/reconcile-tab/engine/useReconciliationEngine.ts b/packages/web/src/components/project/reconcile-tab/engine/useReconciliationEngine.ts index f1e880af5..c84727fd3 100644 --- a/packages/web/src/components/project/reconcile-tab/engine/useReconciliationEngine.ts +++ b/packages/web/src/components/project/reconcile-tab/engine/useReconciliationEngine.ts @@ -103,11 +103,11 @@ export function useReconciliationEngine({ const stored = localStorage.getItem(storageKey); if (stored) { const parsed = JSON.parse(stored); - /* eslint-disable react-hooks/set-state-in-effect -- restoring persisted nav state on mount */ + if (typeof parsed.currentPage === 'number') setCurrentPage(parsed.currentPage); if (parsed.viewMode === 'questions' || parsed.viewMode === 'summary') setViewModeRaw(parsed.viewMode); - /* eslint-enable react-hooks/set-state-in-effect */ + } } catch { // Silently ignore corrupted storage @@ -198,7 +198,7 @@ export function useReconciliationEngine({ if (totalPages === 0) return; const clamped = Math.max(0, Math.min(currentPage, totalPages - 1)); if (clamped !== currentPage) { - setCurrentPage(clamped); // eslint-disable-line react-hooks/set-state-in-effect -- clamping to valid range after navItems change + setCurrentPage(clamped); } }, [totalPages, currentPage]); @@ -213,7 +213,7 @@ export function useReconciliationEngine({ if (navItems.length > 0) { const item = navItems[currentPage]; if (item?.sectionKey) { - setExpandedDomain(item.sectionKey); // eslint-disable-line react-hooks/set-state-in-effect -- one-time auto-expand on mount + setExpandedDomain(item.sectionKey); hasAutoExpandedRef.current = true; } } diff --git a/packages/web/src/components/settings/BillingSettings.tsx b/packages/web/src/components/settings/BillingSettings.tsx index 919b4e8ad..a950eab1d 100644 --- a/packages/web/src/components/settings/BillingSettings.tsx +++ b/packages/web/src/components/settings/BillingSettings.tsx @@ -71,7 +71,7 @@ export function BillingSettings() { useEffect(() => { const params = new URLSearchParams(window.location.search); if (params.get('success') === 'true') { - setCheckoutOutcome('success'); // eslint-disable-line react-hooks/set-state-in-effect -- one-time URL param consumption + setCheckoutOutcome('success'); // Beat the webhook race: pull canonical subscription state from Stripe // before reading it from the DB. Failure is non-fatal — the webhook will // reconcile eventually. diff --git a/packages/web/src/components/settings/LinkedAccountsSection.tsx b/packages/web/src/components/settings/LinkedAccountsSection.tsx index db7d677e3..c3532a421 100644 --- a/packages/web/src/components/settings/LinkedAccountsSection.tsx +++ b/packages/web/src/components/settings/LinkedAccountsSection.tsx @@ -68,7 +68,7 @@ export function LinkedAccountsSection() { sessionStorage.removeItem('linkingProvider'); if (oauthError.code === 'ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER') { - setMergeConflictProvider(provider); // eslint-disable-line react-hooks/set-state-in-effect -- one-time URL param consumption + setMergeConflictProvider(provider); setTimeout(() => setShowMergeDialog(true), 100); return; } diff --git a/packages/web/src/components/settings/MergeAccountsDialog.tsx b/packages/web/src/components/settings/MergeAccountsDialog.tsx index 97ccac6d9..e9a45d626 100644 --- a/packages/web/src/components/settings/MergeAccountsDialog.tsx +++ b/packages/web/src/components/settings/MergeAccountsDialog.tsx @@ -68,7 +68,7 @@ export function MergeAccountsDialog({ // Reset on open/close useEffect(() => { - /* eslint-disable react-hooks/set-state-in-effect -- resetting form on dialog open */ + if (open) { setStep(STEPS.PROMPT); setTargetEmail(''); @@ -79,7 +79,7 @@ export function MergeAccountsDialog({ setError(null); setLoading(false); } - /* eslint-enable react-hooks/set-state-in-effect */ + }, [open]); const isOrcidConflict = useMemo(() => conflictProvider === 'orcid', [conflictProvider]); diff --git a/packages/web/src/components/settings/PlansSettings.tsx b/packages/web/src/components/settings/PlansSettings.tsx index 871af12e0..e89eadc03 100644 --- a/packages/web/src/components/settings/PlansSettings.tsx +++ b/packages/web/src/components/settings/PlansSettings.tsx @@ -35,7 +35,7 @@ export function PlansSettings() { }, [navigate, refetch]); useEffect(() => { - if (hasPendingPlan()) processPendingPlan(); // eslint-disable-line react-hooks/set-state-in-effect + if (hasPendingPlan()) processPendingPlan(); }, []); // eslint-disable-line react-hooks/exhaustive-deps if (pageState === 'error') { diff --git a/packages/web/src/components/settings/ProfileInfoSection.tsx b/packages/web/src/components/settings/ProfileInfoSection.tsx index 3ed83b759..7d50e98e1 100644 --- a/packages/web/src/components/settings/ProfileInfoSection.tsx +++ b/packages/web/src/components/settings/ProfileInfoSection.tsx @@ -36,7 +36,7 @@ export function ProfileInfoSection() { return name.split(' ')[0] || ''; }, [user?.givenName, user?.name]); - // eslint-disable-next-line react-hooks/preserve-manual-memoization -- nullable optional chaining deps + const lastName = useMemo(() => { if (user?.familyName) return user.familyName as string; const name = (user?.name as string) || ''; diff --git a/packages/web/src/components/settings/SecuritySettings.tsx b/packages/web/src/components/settings/SecuritySettings.tsx index 13ba7c66a..ea50fe37c 100644 --- a/packages/web/src/components/settings/SecuritySettings.tsx +++ b/packages/web/src/components/settings/SecuritySettings.tsx @@ -63,7 +63,7 @@ export function SecuritySettings() { [currentPassword, newPassword, confirmPassword, unmetRequirements, changePassword], ); - // eslint-disable-next-line react-hooks/preserve-manual-memoization -- async callback with conditional deps + const handleSendPasswordSetup = useCallback(async () => { setAddPasswordLoading(true); setPasswordError(''); diff --git a/packages/web/src/hooks/useReconciliationPresence.ts b/packages/web/src/hooks/useReconciliationPresence.ts index c1ddae4cd..b502d8ea8 100644 --- a/packages/web/src/hooks/useReconciliationPresence.ts +++ b/packages/web/src/hooks/useReconciliationPresence.ts @@ -132,14 +132,14 @@ export function useReconciliationPresence({ const [refreshTick, setRefreshTick] = useState(0); // Refs to avoid stale closures in event handlers - /* eslint-disable react-hooks/refs -- intentional ref-sync for useSyncExternalStore callbacks */ + const currentPageRef = useRef(getCurrentPage); currentPageRef.current = getCurrentPage; const checklistTypeRef = useRef(checklistType); checklistTypeRef.current = checklistType; const currentUserRef = useRef(currentUser); currentUserRef.current = currentUser; - /* eslint-enable react-hooks/refs */ + // Periodic refresh for stale cursor detection useEffect(() => { @@ -193,7 +193,7 @@ export function useReconciliationPresence({ x, y, scrollY, - // eslint-disable-next-line react-hooks/purity -- called in throttled callback, not during render + timestamp: Date.now(), }, }); diff --git a/packages/web/src/hooks/useYText.ts b/packages/web/src/hooks/useYText.ts index f43e1bf85..05da83820 100644 --- a/packages/web/src/hooks/useYText.ts +++ b/packages/web/src/hooks/useYText.ts @@ -10,7 +10,7 @@ export function useYText(yText: Y.Text | null): string { useEffect(() => { if (!yText) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- syncing external Y.Text CRDT state + setValue(''); return; } diff --git a/packages/web/src/primitives/useProject/checklists/useChecklistViewModel.ts b/packages/web/src/primitives/useProject/checklists/useChecklistViewModel.ts index 535fdac7a..416e40456 100644 --- a/packages/web/src/primitives/useProject/checklists/useChecklistViewModel.ts +++ b/packages/web/src/primitives/useProject/checklists/useChecklistViewModel.ts @@ -6,8 +6,8 @@ */ import { useMemo } from 'react'; -import { useProjectStore, selectStudies } from '@/stores/projectStore'; import type { StudyInfo, ChecklistEntry } from '@/stores/projectStore'; +import { useStudy } from '@/stores/projectAtoms'; import { getChecklistTypeFromState, scoreChecklistOfType } from '@/checklist-registry/index'; import { useChecklistAnswers } from './useChecklistAnswers'; @@ -24,12 +24,7 @@ export function useChecklistViewModel( studyId: string, checklistId: string, ): ChecklistViewModel { - const studies = useProjectStore(s => selectStudies(s, projectId)); - - const currentStudy = useMemo( - () => studies.find(st => st.id === studyId) ?? null, - [studies, studyId], - ); + const currentStudy = useStudy(projectId, studyId) ?? null; const currentChecklist = useMemo( () => (currentStudy?.checklists ?? []).find(c => c.id === checklistId) ?? null, diff --git a/packages/web/src/primitives/useProject/sync.ts b/packages/web/src/primitives/useProject/sync.ts index fc1eff3aa..1e68d9ee3 100644 --- a/packages/web/src/primitives/useProject/sync.ts +++ b/packages/web/src/primitives/useProject/sync.ts @@ -13,6 +13,7 @@ import type { ProjectMeta, OutcomeEntry, } from '@/stores/projectStore'; +import { getProjectAtoms, cleanupProjectAtoms } from '@/stores/projectAtoms'; import { scoreChecklistOfType } from '@/checklist-registry/index'; import { amstar2 } from '@corates/shared'; import { CHECKLIST_STATUS } from '@corates/shared/checklists'; @@ -167,6 +168,17 @@ export function createSyncManager(projectId: string, getYDoc: () => Y.Doc | null dirtySlices.members = false; dirtySlices.meta = false; + const projectAtoms = getProjectAtoms(projectId); + if (updates.studies !== undefined) { + projectAtoms.setStudies(updates.studies); + } + if (updates.members !== undefined) { + projectAtoms.members.set(updates.members); + } + if (updates.meta !== undefined) { + projectAtoms.meta.set(updates.meta); + } + if ( updates.studies !== undefined || updates.members !== undefined || @@ -237,6 +249,7 @@ export function createSyncManager(projectId: string, getYDoc: () => Y.Doc | null cleanupHandlers.length = 0; studyCache.clear(); sortedStudies = []; + cleanupProjectAtoms(projectId); } function pause(): void { diff --git a/packages/web/src/routes/_auth/check-email.tsx b/packages/web/src/routes/_auth/check-email.tsx index eaef2f6c3..b19c90f0f 100644 --- a/packages/web/src/routes/_auth/check-email.tsx +++ b/packages/web/src/routes/_auth/check-email.tsx @@ -68,7 +68,7 @@ function CheckEmailPage() { // Set up polling and visibility change listener useEffect(() => { intervalRef.current = setInterval(() => checkVerificationStatus(true), POLL_INTERVAL_MS); - checkVerificationStatus(true); // eslint-disable-line react-hooks/set-state-in-effect -- initial check on mount + checkVerificationStatus(true); const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { diff --git a/packages/web/src/routes/_auth/complete-profile.tsx b/packages/web/src/routes/_auth/complete-profile.tsx index bf4455aee..c7b8533b1 100644 --- a/packages/web/src/routes/_auth/complete-profile.tsx +++ b/packages/web/src/routes/_auth/complete-profile.tsx @@ -105,7 +105,7 @@ function CompleteProfilePage() { if (!user && !hasEditedName && !hasAutofilledName) { const pendingName = localStorage.getItem('pendingName'); if (!firstName.trim() && !lastName.trim() && pendingName) { - setFirstName(pendingName); // eslint-disable-line react-hooks/set-state-in-effect -- one-time localStorage consumption + setFirstName(pendingName); setHasAutofilledName(true); } } @@ -149,7 +149,7 @@ function CompleteProfilePage() { useEffect(() => { const pendingPersona = localStorage.getItem('pendingPersona'); if (pendingPersona) { - setPersona(pendingPersona); // eslint-disable-line react-hooks/set-state-in-effect -- one-time localStorage consumption + setPersona(pendingPersona); localStorage.removeItem('pendingPersona'); } }, []); diff --git a/packages/web/src/stores/projectAtoms.ts b/packages/web/src/stores/projectAtoms.ts new file mode 100644 index 000000000..e71d3d905 --- /dev/null +++ b/packages/web/src/stores/projectAtoms.ts @@ -0,0 +1,106 @@ +import { atom, transact } from '@tldraw/state'; +import type { Atom } from '@tldraw/state'; +import { useValue } from '@tldraw/state-react'; +import type { StudyInfo, MemberEntry, ProjectMeta } from './projectStore'; + +function arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +class ProjectAtoms { + private studyAtoms = new Map>(); + readonly studyOrder = atom('studyOrder', [], { isEqual: arraysEqual }); + readonly meta = atom('projectMeta', { outcomes: [] }); + readonly members = atom('members', []); + + getOrCreateStudyAtom(studyId: string): Atom { + let a = this.studyAtoms.get(studyId); + if (!a) { + a = atom(`study:${studyId}`, undefined); + this.studyAtoms.set(studyId, a); + } + return a; + } + + setStudy(studyId: string, study: StudyInfo): void { + this.getOrCreateStudyAtom(studyId).set(study); + } + + deleteStudy(studyId: string): void { + const a = this.studyAtoms.get(studyId); + if (a) { + a.set(undefined); + this.studyAtoms.delete(studyId); + } + } + + setStudies(studies: StudyInfo[]): void { + const incomingIds = new Set(); + + transact(() => { + for (const study of studies) { + incomingIds.add(study.id); + this.setStudy(study.id, study); + } + + for (const [id] of this.studyAtoms) { + if (!incomingIds.has(id)) { + this.deleteStudy(id); + } + } + + this.studyOrder.set(studies.map(s => s.id)); + }); + } + + cleanup(): void { + this.studyAtoms.clear(); + } +} + +const registry = new Map(); + +export function getProjectAtoms(projectId: string): ProjectAtoms { + let atoms = registry.get(projectId); + if (!atoms) { + atoms = new ProjectAtoms(); + registry.set(projectId, atoms); + } + return atoms; +} + +export function cleanupProjectAtoms(projectId: string): void { + const atoms = registry.get(projectId); + if (atoms) { + atoms.cleanup(); + registry.delete(projectId); + } +} + +// -- React hooks -- + +export function useStudy(projectId: string, studyId: string): StudyInfo | undefined { + const atoms = getProjectAtoms(projectId); + const studyAtom = atoms.getOrCreateStudyAtom(studyId); + return useValue(studyAtom); +} + +export function useStudyIds(projectId: string): string[] { + const atoms = getProjectAtoms(projectId); + return useValue(atoms.studyOrder); +} + +export function useProjectMeta(projectId: string): ProjectMeta { + const atoms = getProjectAtoms(projectId); + return useValue(atoms.meta); +} + +export function useProjectMembers(projectId: string): MemberEntry[] { + const atoms = getProjectAtoms(projectId); + return useValue(atoms.members); +} + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40264f2f5..f22c96bc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,6 +236,12 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tldraw/state': + specifier: 4.5.10 + version: 4.5.10 + '@tldraw/state-react': + specifier: 4.5.10 + version: 4.5.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4) better-auth: specifier: ^1.6.9 version: 1.6.9(28080a98632767764183f5cea7f40f08) @@ -3694,6 +3700,18 @@ packages: '@types/react-dom': optional: true + '@tldraw/state-react@4.5.10': + resolution: {integrity: sha512-gT173dPZUmHksVGDFFNlinai/CfIgvY3ECXGGXMEwpISbHpxInn1u6U7E60z/hmG7MQ2e6kKQq1H7w+VkMeQmg==} + peerDependencies: + react: ^18.2.0 || ^19.2.1 + react-dom: ^18.2.0 || ^19.2.1 + + '@tldraw/state@4.5.10': + resolution: {integrity: sha512-c7l3/5T16M0p7EJ2GLjpCA2B69abn1790spjsHsfaIWlEzeo29LmTNa906dtkx8b6qPrSUguX6ibE5mEOw7IzA==} + + '@tldraw/utils@4.5.10': + resolution: {integrity: sha512-aRxpxmx0lIJx/xuwMaBgNxgcYi+okOElWSdZnnV+ZdUrqAfavpe5e1U6qoOdIhn9/iVfOz+PfmEINUNObi/eLg==} + '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -5800,6 +5818,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fractional-indexing@3.2.0: + resolution: {integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==} + engines: {node: ^14.13.1 || >=16.0.0} + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -6234,6 +6256,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jittered-fractional-indexing@1.0.1: + resolution: {integrity: sha512-OpKFkVr4hU5ivd1ZCjZfHvVpWekraJvcePcMusBmgBmCVQK5JiRCA+4TT1vAUTLqGD9MkhqFwO0l3QspvlZgzw==} + engines: {node: '>=18.0.0'} + jose@6.2.0: resolution: {integrity: sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==} @@ -6429,9 +6455,22 @@ packages: lodash-es@4.17.23: resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.isequalwith@4.4.0: + resolution: {integrity: sha512-dcZON0IalGBpRmJBmMkaoV7d3I80R2O+FrzsZyHdNSFrANq/cgDqKQNmAHE8UEj4+QYWwwhkQOVdLHiAopzlsQ==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -11500,6 +11539,25 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@tldraw/state-react@4.5.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tldraw/state': 4.5.10 + '@tldraw/utils': 4.5.10 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@tldraw/state@4.5.10': + dependencies: + '@tldraw/utils': 4.5.10 + + '@tldraw/utils@4.5.10': + dependencies: + jittered-fractional-indexing: 1.0.1 + lodash.isequal: 4.5.0 + lodash.isequalwith: 4.4.0 + lodash.throttle: 4.1.1 + lodash.uniq: 4.5.0 + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -14148,6 +14206,8 @@ snapshots: forwarded@0.2.0: {} + fractional-indexing@3.2.0: {} + fresh@2.0.0: {} fs-constants@1.0.0: {} @@ -14539,6 +14599,10 @@ snapshots: jiti@2.6.1: {} + jittered-fractional-indexing@1.0.1: + dependencies: + fractional-indexing: 3.2.0 + jose@6.2.0: {} jose@6.2.2: {} @@ -14722,8 +14786,16 @@ snapshots: lodash-es@4.17.23: {} + lodash.isequal@4.5.0: {} + + lodash.isequalwith@4.4.0: {} + lodash.merge@4.6.2: {} + lodash.throttle@4.1.1: {} + + lodash.uniq@4.5.0: {} + lodash@4.17.21: optional: true From 32ce990f98b8894e0d5d27ee29821697ead26bdd Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Thu, 30 Apr 2026 14:48:17 -0500 Subject: [PATCH 02/10] use snapshot value and computer selectors --- packages/web/src/components/project/ProjectView.tsx | 12 ++++-------- .../project/all-studies-tab/AllStudiesTab.tsx | 10 +++------- .../project/completed-tab/CompletedTab.tsx | 6 +++--- .../components/project/overview-tab/OverviewTab.tsx | 6 +++--- .../project/reconcile-tab/ReconcileTab.tsx | 6 +++--- .../web/src/components/project/todo-tab/ToDoTab.tsx | 12 ++++-------- packages/web/src/hooks/useSnapshotValue.ts | 12 ++++++++++++ packages/web/src/stores/projectAtoms.ts | 10 ++++++++++ 8 files changed, 42 insertions(+), 32 deletions(-) create mode 100644 packages/web/src/hooks/useSnapshotValue.ts diff --git a/packages/web/src/components/project/ProjectView.tsx b/packages/web/src/components/project/ProjectView.tsx index 921692a4c..6de0a6c32 100644 --- a/packages/web/src/components/project/ProjectView.tsx +++ b/packages/web/src/components/project/ProjectView.tsx @@ -6,12 +6,8 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { useNavigate, useLocation, Outlet } from '@tanstack/react-router'; -import { - useProjectStore, - selectStudies, - selectMeta, - selectConnectionPhase, -} from '@/stores/projectStore'; +import { useProjectStore, selectConnectionPhase } from '@/stores/projectStore'; +import { useAllStudies, useProjectMeta } from '@/stores/projectAtoms'; import { useProjectOrgId } from '@/hooks/useProjectOrgId'; import { useAuthStore, selectUser } from '@/stores/authStore'; import { ProjectGate } from '@/project'; @@ -72,8 +68,8 @@ function ProjectViewInner({ projectId }: ProjectViewProps) { return path.includes('/checklists/') || path.includes('/reconcile/'); }, [location.pathname]); - const studies = useProjectStore(s => selectStudies(s, projectId)); - const meta = useProjectStore(s => selectMeta(s, projectId)); + const studies = useAllStudies(projectId); + const meta = useProjectMeta(projectId); const connectionState = useProjectStore(s => selectConnectionPhase(s, projectId)); // Read pending data exactly once via lazy initializer (safe for StrictMode) diff --git a/packages/web/src/components/project/all-studies-tab/AllStudiesTab.tsx b/packages/web/src/components/project/all-studies-tab/AllStudiesTab.tsx index 0a9f7b6bf..8b986daf2 100644 --- a/packages/web/src/components/project/all-studies-tab/AllStudiesTab.tsx +++ b/packages/web/src/components/project/all-studies-tab/AllStudiesTab.tsx @@ -11,13 +11,9 @@ import { StudyCard } from './study-card/StudyCard'; import { AssignReviewersModal } from './AssignReviewersModal'; import { ReviewerAssignment } from '../overview-tab/ReviewerAssignment'; import { OutcomeManager } from '../outcomes/OutcomeManager'; -import { - useProjectStore, - selectStudies, - selectConnectionPhase, -} from '@/stores/projectStore'; +import { useProjectStore, selectConnectionPhase } from '@/stores/projectStore'; import type { StudyInfo } from '@/stores/projectStore'; -import { useStudyIds, useProjectMembers } from '@/stores/projectAtoms'; +import { useStudyIds, useAllStudies, useProjectMembers } from '@/stores/projectAtoms'; import { project } from '@/project'; import { useProjectContext } from '../ProjectContext'; import { @@ -40,7 +36,7 @@ export function AllStudiesTab() { const studyIds = useStudyIds(projectId); const members = useProjectMembers(projectId); - const studies = useProjectStore(s => selectStudies(s, projectId)); + const studies = useAllStudies(projectId); const connectionState = useProjectStore(s => selectConnectionPhase(s, projectId)); const hasData = connectionState.phase === 'synced' || studyIds.length > 0; diff --git a/packages/web/src/components/project/completed-tab/CompletedTab.tsx b/packages/web/src/components/project/completed-tab/CompletedTab.tsx index ff86c6d58..78aa5092b 100644 --- a/packages/web/src/components/project/completed-tab/CompletedTab.tsx +++ b/packages/web/src/components/project/completed-tab/CompletedTab.tsx @@ -5,8 +5,8 @@ import { useMemo, useCallback } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { CheckCircleIcon } from 'lucide-react'; -import { useProjectStore, selectStudies } from '@/stores/projectStore'; import { useProjectContext } from '../ProjectContext'; +import { useAllStudies, useProjectMeta } from '@/stores/projectAtoms'; import { connectionPool } from '@/project/ConnectionPool'; import { getStudiesForTab, isDualReviewerStudy, getOutcomeKey } from '@corates/shared/checklists'; import { CompletedStudyRow } from './CompletedStudyRow'; @@ -19,8 +19,8 @@ export function CompletedTab() { const conn = connectionPool.getOps(projectId); const getAllReconciliationProgress = conn?.reconciliation.getAllReconciliationProgress; - const studies = useProjectStore(s => selectStudies(s, projectId)); - const meta = useProjectStore(s => s.projects[projectId]?.meta); + const studies = useAllStudies(projectId); + const meta = useProjectMeta(projectId); const getOutcomeName = useCallback( (outcomeId: string) => { diff --git a/packages/web/src/components/project/overview-tab/OverviewTab.tsx b/packages/web/src/components/project/overview-tab/OverviewTab.tsx index 0dbcd27cf..a49ecc5e5 100644 --- a/packages/web/src/components/project/overview-tab/OverviewTab.tsx +++ b/packages/web/src/components/project/overview-tab/OverviewTab.tsx @@ -12,7 +12,7 @@ import { ArrowRightLeftIcon, CheckCircleIcon, } from 'lucide-react'; -import { useProjectStore, selectStudies, selectMembers } from '@/stores/projectStore'; +import { useAllStudies, useProjectMembers } from '@/stores/projectAtoms'; import { project } from '@/project'; import { useAuthStore, selectUser } from '@/stores/authStore'; import { useProjectContext, type ProjectMember } from '../ProjectContext'; @@ -68,8 +68,8 @@ export function OverviewTab() { const { hasQuota, quotas } = useSubscription(); const { members: orgMembers } = useMembers(); - const studies = useProjectStore(s => selectStudies(s, projectId)); - const members = useProjectStore(s => selectMembers(s, projectId)); + const studies = useAllStudies(projectId); + const members = useProjectMembers(projectId); const nonOwnerOrgMemberCount = useMemo( () => orgMembers.filter(m => m.role !== 'owner').length, diff --git a/packages/web/src/components/project/reconcile-tab/ReconcileTab.tsx b/packages/web/src/components/project/reconcile-tab/ReconcileTab.tsx index 3b3c3cb36..3a3355eb7 100644 --- a/packages/web/src/components/project/reconcile-tab/ReconcileTab.tsx +++ b/packages/web/src/components/project/reconcile-tab/ReconcileTab.tsx @@ -6,8 +6,8 @@ import { useMemo, useCallback } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { ArrowRightLeftIcon } from 'lucide-react'; import { ReconcileStudyRow } from './ReconcileStudyRow'; -import { useProjectStore, selectStudies } from '@/stores/projectStore'; import { useProjectContext } from '../ProjectContext'; +import { useAllStudies, useProjectMeta } from '@/stores/projectAtoms'; import { getStudiesForTab } from '@corates/shared/checklists'; import { project } from '@/project'; @@ -15,8 +15,8 @@ export function ReconcileTab() { const { projectId, getAssigneeName, getReconcilePath } = useProjectContext(); const navigate = useNavigate(); - const studies = useProjectStore(s => selectStudies(s, projectId)); - const meta = useProjectStore(s => s.projects[projectId]?.meta); + const studies = useAllStudies(projectId); + const meta = useProjectMeta(projectId); const getOutcomeName = useCallback( (outcomeId: string) => { diff --git a/packages/web/src/components/project/todo-tab/ToDoTab.tsx b/packages/web/src/components/project/todo-tab/ToDoTab.tsx index 935d8ee71..82aa4fc3f 100644 --- a/packages/web/src/components/project/todo-tab/ToDoTab.tsx +++ b/packages/web/src/components/project/todo-tab/ToDoTab.tsx @@ -6,12 +6,8 @@ import { useState, useMemo, useCallback } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { ListTodoIcon } from 'lucide-react'; import { TodoStudyRow } from './TodoStudyRow'; -import { - useProjectStore, - selectStudies, - selectMembers, - selectConnectionPhase, -} from '@/stores/projectStore'; +import { useProjectStore, selectConnectionPhase } from '@/stores/projectStore'; +import { useAllStudies, useProjectMembers } from '@/stores/projectAtoms'; import { useAuthStore, selectUser } from '@/stores/authStore'; import { useProjectContext } from '../ProjectContext'; import { getStudiesForTab } from '@corates/shared/checklists'; @@ -34,8 +30,8 @@ export function ToDoTab() { }); }, []); - const studies = useProjectStore(s => selectStudies(s, projectId)); - const members = useProjectStore(s => selectMembers(s, projectId)); + const studies = useAllStudies(projectId); + const members = useProjectMembers(projectId); const connectionState = useProjectStore(s => selectConnectionPhase(s, projectId)); const hasData = connectionState.phase === 'synced' || studies.length > 0; const currentUserId = user?.id; diff --git a/packages/web/src/hooks/useSnapshotValue.ts b/packages/web/src/hooks/useSnapshotValue.ts new file mode 100644 index 000000000..6568752ad --- /dev/null +++ b/packages/web/src/hooks/useSnapshotValue.ts @@ -0,0 +1,12 @@ +import { useRef } from 'react'; + +/** + * Freezes a live value while `isEditing` is true, so remote updates + * don't clobber an in-progress form or modal. When `isEditing` flips + * back to false, the snapshot catches up to the latest live value. + */ +export function useSnapshotValue(liveValue: T, isEditing: boolean): T { + const snapshotRef = useRef(liveValue); + if (!isEditing) snapshotRef.current = liveValue; + return isEditing ? snapshotRef.current : liveValue; +} diff --git a/packages/web/src/stores/projectAtoms.ts b/packages/web/src/stores/projectAtoms.ts index e71d3d905..1e33d8d08 100644 --- a/packages/web/src/stores/projectAtoms.ts +++ b/packages/web/src/stores/projectAtoms.ts @@ -104,3 +104,13 @@ export function useProjectMembers(projectId: string): MemberEntry[] { return useValue(atoms.members); } +export function useAllStudies(projectId: string): StudyInfo[] { + const atoms = getProjectAtoms(projectId); + return useValue('allStudies:' + projectId, () => { + return atoms.studyOrder.get().flatMap(id => { + const study = atoms.getOrCreateStudyAtom(id).get(); + return study ? [study] : []; + }); + }, [atoms]); +} + From a59c441c6b388414127988859f1058a5322f2a98 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Thu, 30 Apr 2026 15:16:53 -0500 Subject: [PATCH 03/10] migrate everything to atomics --- .../dashboard/LocalAppraisalsSection.tsx | 4 +- packages/web/src/components/dev/DevPanel.tsx | 6 +- .../src/components/dev/DevStudyGenerator.tsx | 9 +- .../web/src/components/layout/Sidebar.tsx | 4 +- .../project/add-studies/AddStudiesForm.tsx | 8 +- .../project/outcomes/OutcomeManager.tsx | 4 +- .../project/todo-tab/ChecklistForm.tsx | 4 +- .../project/todo-tab/TodoStudyRow.tsx | 4 +- packages/web/src/hooks/useProjectData.ts | 28 +--- packages/web/src/hooks/useProjectOrgId.ts | 11 +- .../primitives/__tests__/projectStore.test.ts | 144 ++++-------------- .../web/src/primitives/useProject/studies.ts | 16 +- .../web/src/primitives/useProject/sync.ts | 11 +- packages/web/src/project/actions/pdfs.ts | 6 +- packages/web/src/project/actions/studies.ts | 5 +- packages/web/src/stores/projectStore.ts | 68 +-------- 16 files changed, 82 insertions(+), 250 deletions(-) diff --git a/packages/web/src/components/dashboard/LocalAppraisalsSection.tsx b/packages/web/src/components/dashboard/LocalAppraisalsSection.tsx index cf7b79420..523f2a5ff 100644 --- a/packages/web/src/components/dashboard/LocalAppraisalsSection.tsx +++ b/packages/web/src/components/dashboard/LocalAppraisalsSection.tsx @@ -6,7 +6,7 @@ import { useState, useMemo, useCallback } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { PlusIcon, FileTextIcon, LogInIcon, TriangleAlertIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { useProjectStore, selectStudies } from '@/stores/projectStore'; +import { useAllStudies } from '@/stores/projectAtoms'; import { connectionPool } from '@/project/ConnectionPool'; import { LOCAL_PROJECT_ID } from '@/project/localProject'; import { db } from '@/primitives/db'; @@ -43,7 +43,7 @@ export function LocalAppraisalsSection({ }: LocalAppraisalsSectionProps) { const navigate = useNavigate(); const animation = useAnimation(); - const studies = useProjectStore(s => selectStudies(s, LOCAL_PROJECT_ID)); + const studies = useAllStudies(LOCAL_PROJECT_ID); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [pendingDeleteId, setPendingDeleteId] = useState(null); diff --git a/packages/web/src/components/dev/DevPanel.tsx b/packages/web/src/components/dev/DevPanel.tsx index e74014231..61f2c4339 100644 --- a/packages/web/src/components/dev/DevPanel.tsx +++ b/packages/web/src/components/dev/DevPanel.tsx @@ -11,6 +11,7 @@ import { useState, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import { XIcon, ChevronDownIcon, ChevronUpIcon, BugIcon, BracesIcon } from 'lucide-react'; import { useProjectStore, selectConnectionPhase } from '@/stores/projectStore'; +import { useAllStudies, useProjectMembers, useProjectMeta } from '@/stores/projectAtoms'; import { useProjectOrgId } from '@/hooks/useProjectOrgId'; import { DevStateTree } from './DevStateTree'; import { DevQuickActions } from './DevQuickActions'; @@ -48,7 +49,10 @@ export function DevPanel() { const orgId = useProjectOrgId(projectId); - const projectData = useProjectStore(s => (projectId ? s.projects[projectId] || null : null)); + const studies = useAllStudies(projectId || ''); + const members = useProjectMembers(projectId || ''); + const meta = useProjectMeta(projectId || ''); + const projectData = projectId ? { studies, members, meta } : null; const connectionState = useProjectStore(s => projectId ? selectConnectionPhase(s, projectId) : null, diff --git a/packages/web/src/components/dev/DevStudyGenerator.tsx b/packages/web/src/components/dev/DevStudyGenerator.tsx index aec71695a..b788c1e5f 100644 --- a/packages/web/src/components/dev/DevStudyGenerator.tsx +++ b/packages/web/src/components/dev/DevStudyGenerator.tsx @@ -7,7 +7,7 @@ import { useState } from 'react'; import { PlusIcon, CheckIcon, AlertCircleIcon } from 'lucide-react'; -import { useProjectStore } from '@/stores/projectStore'; +import { useProjectMembers, useProjectMeta } from '@/stores/projectAtoms'; import { addStudy } from '@/server/functions/dev-tools.functions'; interface ActionResult { @@ -45,10 +45,11 @@ interface DevStudyGeneratorProps { } export function DevStudyGenerator({ projectId, orgId }: DevStudyGeneratorProps) { - const projectData = useProjectStore(s => (projectId ? s.projects[projectId] || null : null)); + const atomMembers = useProjectMembers(projectId || ''); + const meta = useProjectMeta(projectId || ''); - const members: MemberEntry[] = (projectData?.members as MemberEntry[]) || []; - const outcomes: OutcomeEntry[] = projectData?.meta?.outcomes ?? []; + const members: MemberEntry[] = (atomMembers as MemberEntry[]) || []; + const outcomes: OutcomeEntry[] = meta?.outcomes ?? []; const [type, setType] = useState('AMSTAR2'); const [fillMode, setFillMode] = useState('random'); diff --git a/packages/web/src/components/layout/Sidebar.tsx b/packages/web/src/components/layout/Sidebar.tsx index 87f03c1cf..f231b0be3 100644 --- a/packages/web/src/components/layout/Sidebar.tsx +++ b/packages/web/src/components/layout/Sidebar.tsx @@ -20,7 +20,7 @@ import { TriangleAlertIcon, } from 'lucide-react'; import { useAuthStore, selectUser, selectIsLoggedIn } from '@/stores/authStore'; -import { useProjectStore, selectStudies } from '@/stores/projectStore'; +import { useAllStudies } from '@/stores/projectAtoms'; import { connectionPool } from '@/project/ConnectionPool'; import { LOCAL_PROJECT_ID } from '@/project/localProject'; import { db } from '@/primitives/db'; @@ -67,7 +67,7 @@ export function Sidebar({ updatedAt?: number; createdAt?: number; } - const localStudies = useProjectStore(s => selectStudies(s, LOCAL_PROJECT_ID)); + const localStudies = useAllStudies(LOCAL_PROJECT_ID); const checklists = useMemo(() => { const out: LocalChecklistSummary[] = []; for (const study of localStudies) { diff --git a/packages/web/src/components/project/add-studies/AddStudiesForm.tsx b/packages/web/src/components/project/add-studies/AddStudiesForm.tsx index eb2e43783..b3ea9d2e5 100644 --- a/packages/web/src/components/project/add-studies/AddStudiesForm.tsx +++ b/packages/web/src/components/project/add-studies/AddStudiesForm.tsx @@ -21,7 +21,7 @@ import { import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'; import { Tabs, TabsList, TabsTrigger, TabsIndicator, TabsContent } from '@/components/ui/tabs'; import { showToast } from '@/components/ui/toast'; -import { useProjectStore } from '@/stores/projectStore'; +import { useStudyIds } from '@/stores/projectAtoms'; import { useAddStudies } from '@/hooks/useAddStudies'; import type { CollectedStudies } from '@/hooks/useAddStudies'; import type { MergedStudy } from '@/hooks/useAddStudies/deduplication'; @@ -72,10 +72,8 @@ export function AddStudiesForm({ onStudiesChange, }); - // Check if project has existing studies via store - const existingStudyCount = useProjectStore(s => - projectId ? (s.projects[projectId]?.studies?.length ?? 0) : 0, - ); + const studyIds = useStudyIds(projectId || ''); + const existingStudyCount = projectId ? studyIds.length : 0; const hasExistingStudies = !collectMode && !!projectId && existingStudyCount > 0; const isExpanded = alwaysExpanded || expanded || studies.hasAnyStudies(); diff --git a/packages/web/src/components/project/outcomes/OutcomeManager.tsx b/packages/web/src/components/project/outcomes/OutcomeManager.tsx index 3528e2fb7..b7c6d83a0 100644 --- a/packages/web/src/components/project/outcomes/OutcomeManager.tsx +++ b/packages/web/src/components/project/outcomes/OutcomeManager.tsx @@ -15,9 +15,9 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; -import { useProjectStore } from '@/stores/projectStore'; import { project } from '@/project'; import { useProjectContext } from '../ProjectContext'; +import { useProjectMeta } from '@/stores/projectAtoms'; import { showToast } from '@/components/ui/toast'; export function OutcomeManager() { @@ -30,7 +30,7 @@ export function OutcomeManager() { const [isSaving, setIsSaving] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); - const meta = useProjectStore(s => s.projects[projectId]?.meta); + const meta = useProjectMeta(projectId); const outcomes = useMemo(() => meta?.outcomes ?? [], [meta]); const handleAdd = useCallback(async () => { diff --git a/packages/web/src/components/project/todo-tab/ChecklistForm.tsx b/packages/web/src/components/project/todo-tab/ChecklistForm.tsx index 1ba9b1c7c..912e0d00c 100644 --- a/packages/web/src/components/project/todo-tab/ChecklistForm.tsx +++ b/packages/web/src/components/project/todo-tab/ChecklistForm.tsx @@ -17,7 +17,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { useProjectStore } from '@/stores/projectStore'; +import { useProjectMeta } from '@/stores/projectAtoms'; import { useProjectContext } from '../ProjectContext'; interface ChecklistFormProps { @@ -42,7 +42,7 @@ export function ChecklistForm({ const typeOptions = useMemo(() => getChecklistTypeOptions(), []); - const meta = useProjectStore(s => s.projects[projectId]?.meta); + const meta = useProjectMeta(projectId); const outcomes = useMemo(() => meta?.outcomes ?? [], [meta?.outcomes]); const requiresOutcome = type === CHECKLIST_TYPES.ROB2 || type === CHECKLIST_TYPES.ROBINS_I; diff --git a/packages/web/src/components/project/todo-tab/TodoStudyRow.tsx b/packages/web/src/components/project/todo-tab/TodoStudyRow.tsx index c99eb28f9..dad4db2b1 100644 --- a/packages/web/src/components/project/todo-tab/TodoStudyRow.tsx +++ b/packages/web/src/components/project/todo-tab/TodoStudyRow.tsx @@ -23,7 +23,7 @@ import { getChecklistMetadata, CHECKLIST_TYPES } from '@/checklist-registry'; import { PdfListItem } from '@/components/pdf/PdfListItem'; import { ChecklistForm } from './ChecklistForm'; import { getStatusLabel, getStatusStyle } from '@corates/shared/checklists'; -import { useProjectStore } from '@/stores/projectStore'; +import { useProjectMeta } from '@/stores/projectAtoms'; import type { StudyInfo, PdfEntry, MemberEntry } from '@/stores/projectStore'; import { useProjectContext } from '../ProjectContext'; @@ -64,7 +64,7 @@ export function TodoStudyRow({ const checklists = study.checklists; const hasChecklists = checklists.length > 0; - const meta = useProjectStore(s => s.projects[projectId]?.meta); + const meta = useProjectMeta(projectId); const outcomes = useMemo(() => meta?.outcomes ?? [], [meta?.outcomes]); const canAddMore = useMemo(() => { diff --git a/packages/web/src/hooks/useProjectData.ts b/packages/web/src/hooks/useProjectData.ts index e1b2665ad..4a70a01f7 100644 --- a/packages/web/src/hooks/useProjectData.ts +++ b/packages/web/src/hooks/useProjectData.ts @@ -1,21 +1,12 @@ /** - * useProjectData - Lightweight hook for reading project data from Zustand store + * useProjectData - Lightweight hook for reading project data from atoms * * Use this hook when you only need to READ project data (studies, members, meta). * For write operations (createStudy, updateChecklist, etc.), use useProject instead. - * - * Note: This hook reads from the Zustand store. Data is populated by useProject - * when it's mounted (typically in the projects.$projectId layout route). */ -import { - useProjectStore, - selectConnectionPhase, - selectStudies, - selectMembers, - selectMeta, - type ProjectMeta, -} from '@/stores/projectStore'; +import { useProjectStore, selectConnectionPhase, type ProjectMeta } from '@/stores/projectStore'; +import { useAllStudies, useProjectMembers, useProjectMeta } from '@/stores/projectAtoms'; const EMPTY_STUDIES: never[] = []; const EMPTY_MEMBERS: never[] = []; @@ -32,17 +23,12 @@ const IDLE_STATE = { }; export function useProjectData(projectId: string | undefined) { - const studies = useProjectStore(state => - projectId ? selectStudies(state, projectId) : EMPTY_STUDIES, - ); - const members = useProjectStore(state => - projectId ? selectMembers(state, projectId) : EMPTY_MEMBERS, - ); - const meta = useProjectStore(state => (projectId ? selectMeta(state, projectId) : EMPTY_META)); + const studies = useAllStudies(projectId || ''); + const members = useProjectMembers(projectId || ''); + const meta = useProjectMeta(projectId || ''); const connectionState = useProjectStore(state => projectId ? selectConnectionPhase(state, projectId) : null, ); - const hasData = useProjectStore(state => (projectId ? !!state.projects[projectId] : false)); if (!projectId) return IDLE_STATE; @@ -56,6 +42,6 @@ export function useProjectData(projectId: string | undefined) { connecting: phase === 'connecting', synced: phase === 'synced', error: connectionState?.error ?? null, - hasData, + hasData: phase !== 'idle', }; } diff --git a/packages/web/src/hooks/useProjectOrgId.ts b/packages/web/src/hooks/useProjectOrgId.ts index c187fb806..6dd1059a1 100644 --- a/packages/web/src/hooks/useProjectOrgId.ts +++ b/packages/web/src/hooks/useProjectOrgId.ts @@ -4,19 +4,18 @@ import { useMemo } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { useProjectStore } from '@/stores/projectStore'; +import { useProjectMeta } from '@/stores/projectAtoms'; import { queryKeys } from '@/lib/queryKeys'; export function useProjectOrgId(projectId: string | null | undefined): string | null { const queryClient = useQueryClient(); - const project = useProjectStore(state => (projectId ? state.projects[projectId] : undefined)); + const meta = useProjectMeta(projectId || ''); return useMemo(() => { if (!projectId) return null; - // Try project meta (Y.js synced data) - if (project?.meta?.orgId) { - return project.meta.orgId; + if (meta?.orgId) { + return meta.orgId; } // Try project list query cache @@ -29,5 +28,5 @@ export function useProjectOrgId(projectId: string | null | undefined): string | } return null; - }, [projectId, project, queryClient]); + }, [projectId, meta, queryClient]); } diff --git a/packages/web/src/primitives/__tests__/projectStore.test.ts b/packages/web/src/primitives/__tests__/projectStore.test.ts index 92915ab99..0a8dff553 100644 --- a/packages/web/src/primitives/__tests__/projectStore.test.ts +++ b/packages/web/src/primitives/__tests__/projectStore.test.ts @@ -1,117 +1,16 @@ /** - * Tests for projectStore - Central store for project data (Zustand) + * Tests for projectStore - Connection state and active project tracking (Zustand) * - * P0 Priority: Core state management - * Tests the store's ability to manage project data, connection states, - * and active project tracking. + * Collaborative data (studies, members, meta) is managed by @tldraw/state atoms + * in projectAtoms.ts, not in this Zustand store. */ import { describe, it, expect, beforeEach } from 'vitest'; import { useProjectStore } from '@/stores/projectStore.ts'; -import type { StudyInfo, MemberEntry } from '@/stores/projectStore.ts'; - -describe('projectStore - Project Data Management', () => { - beforeEach(() => { - // Reset the store to initial state before each test - useProjectStore.setState({ - projects: {}, - activeProjectId: null, - connections: {}, - projectStats: {}, - }); - }); - - describe('setProjectData / getState', () => { - it('should store and retrieve project data', () => { - const projectId = 'test-project-1'; - const data = { - studies: [{ id: 'study-1', name: 'Test Study' } as StudyInfo], - members: [{ userId: 'user-1', role: 'owner' } as MemberEntry], - meta: { name: 'Test Project', description: 'A test project', outcomes: [] }, - }; - - useProjectStore.getState().setProjectData(projectId, data); - const state = useProjectStore.getState(); - - expect(state.projects[projectId]).toBeDefined(); - expect(state.projects[projectId].studies).toEqual(data.studies); - expect(state.projects[projectId].members).toEqual(data.members); - expect(state.projects[projectId].meta).toEqual(data.meta); - }); - - it('should initialize project with empty arrays if not set', () => { - const projectId = 'test-project-2'; - useProjectStore.getState().setProjectData(projectId, {}); - - const project = useProjectStore.getState().projects[projectId]; - expect(project.studies).toEqual([]); - expect(project.members).toEqual([]); - expect(project.meta).toEqual({ outcomes: [] }); - }); - - it('should update existing project data without overwriting unset fields', () => { - const projectId = 'test-project-3'; - - useProjectStore.getState().setProjectData(projectId, { - studies: [{ id: 'study-1', name: 'Study 1' } as StudyInfo], - members: [{ userId: 'user-1', role: 'owner' } as MemberEntry], - meta: { name: 'Original Name', outcomes: [] }, - }); - - // Update only studies - useProjectStore.getState().setProjectData(projectId, { - studies: [ - { id: 'study-1', name: 'Study 1' } as StudyInfo, - { id: 'study-2', name: 'Study 2' } as StudyInfo, - ], - }); - - const project = useProjectStore.getState().projects[projectId]; - expect(project.studies.length).toBe(2); - expect(project.members.length).toBe(1); - expect(project.meta.name).toBe('Original Name'); - }); - }); - - describe('clearProject', () => { - it('should remove project from cache', () => { - const projectId = 'to-clear'; - useProjectStore - .getState() - .setProjectData(projectId, { meta: { name: 'To Clear', outcomes: [] } }); - expect(useProjectStore.getState().projects[projectId]).toBeDefined(); - - useProjectStore.getState().clearProject(projectId); - expect(useProjectStore.getState().projects[projectId]).toBeUndefined(); - }); - - it('should clear active project if it matches', () => { - const projectId = 'active-to-clear'; - useProjectStore.getState().setProjectData(projectId, { meta: { outcomes: [] } }); - useProjectStore.getState().setActiveProject(projectId); - - useProjectStore.getState().clearProject(projectId); - - expect(useProjectStore.getState().activeProjectId).toBeNull(); - }); - - it('should also clear connection state', () => { - const projectId = 'clear-with-connection'; - useProjectStore.getState().setProjectData(projectId, { meta: { outcomes: [] } }); - useProjectStore.getState().dispatchConnectionEvent(projectId, { type: 'CONNECT_REQUESTED' }); - - useProjectStore.getState().clearProject(projectId); - - const connState = useProjectStore.getState().connections[projectId]; - expect(connState).toBeUndefined(); - }); - }); -}); describe('projectStore - Connection State Management', () => { beforeEach(() => { useProjectStore.setState({ - projects: {}, activeProjectId: null, connections: {}, projectStats: {}, @@ -121,7 +20,6 @@ describe('projectStore - Connection State Management', () => { describe('getConnectionState via selector', () => { it('should return default state for unknown project', () => { const state = useProjectStore.getState().connections['unknown']; - // No entry exists, should be undefined (selector handles the default) expect(state).toBeUndefined(); }); }); @@ -172,7 +70,6 @@ describe('projectStore - Connection State Management', () => { describe('projectStore - Active Project', () => { beforeEach(() => { useProjectStore.setState({ - projects: {}, activeProjectId: null, connections: {}, projectStats: {}, @@ -188,19 +85,34 @@ describe('projectStore - Active Project', () => { useProjectStore.getState().setActiveProject('project-123'); expect(useProjectStore.getState().activeProjectId).toBe('project-123'); }); + }); - it('should return active project data when cached', () => { - const projectId = 'active-test'; - useProjectStore.getState().setProjectData(projectId, { - meta: { name: 'Active Project', outcomes: [] }, - studies: [], - members: [], - }); + describe('clearProject', () => { + it('should clear connection state', () => { + const projectId = 'clear-with-connection'; + useProjectStore.getState().dispatchConnectionEvent(projectId, { type: 'CONNECT_REQUESTED' }); + + useProjectStore.getState().clearProject(projectId); + + const connState = useProjectStore.getState().connections[projectId]; + expect(connState).toBeUndefined(); + }); + + it('should clear active project if it matches', () => { + const projectId = 'active-to-clear'; useProjectStore.getState().setActiveProject(projectId); - const state = useProjectStore.getState(); - expect(state.activeProjectId).toBe(projectId); - expect(state.projects[projectId].meta.name).toBe('Active Project'); + useProjectStore.getState().clearProject(projectId); + + expect(useProjectStore.getState().activeProjectId).toBeNull(); + }); + + it('should not clear active project if it does not match', () => { + useProjectStore.getState().setActiveProject('other-project'); + + useProjectStore.getState().clearProject('different-project'); + + expect(useProjectStore.getState().activeProjectId).toBe('other-project'); }); }); }); diff --git a/packages/web/src/primitives/useProject/studies.ts b/packages/web/src/primitives/useProject/studies.ts index 538b19214..ee551afa0 100644 --- a/packages/web/src/primitives/useProject/studies.ts +++ b/packages/web/src/primitives/useProject/studies.ts @@ -3,8 +3,8 @@ */ import * as Y from 'yjs'; -import { useProjectStore } from '@/stores/projectStore'; import { connectionPool } from '@/project/ConnectionPool'; +import { getProjectAtoms } from '@/stores/projectAtoms'; import { queryClient } from '@/lib/queryClient'; import { queryKeys } from '@/lib/queryKeys'; import { updateProject } from '@/server/functions/org-projects.functions'; @@ -176,10 +176,9 @@ export function createStudyOperations( metaMap.set('updatedAt', now); } - const existingMeta = useProjectStore.getState().projects[projectId]?.meta || { outcomes: [] }; - useProjectStore.getState().setProjectData(projectId, { - meta: { ...existingMeta, name: trimmed, updatedAt: now }, - }); + const atoms = getProjectAtoms(projectId); + const existingMeta = atoms.meta.get(); + atoms.meta.set({ ...existingMeta, name: trimmed, updatedAt: now }); // Invalidate project list query to refetch with updated name queryClient.invalidateQueries({ queryKey: queryKeys.projects.all }); @@ -205,10 +204,9 @@ export function createStudyOperations( metaMap.set('updatedAt', now); } - const existingMeta = useProjectStore.getState().projects[projectId]?.meta || { outcomes: [] }; - useProjectStore.getState().setProjectData(projectId, { - meta: { ...existingMeta, description: trimmed || null, updatedAt: now }, - }); + const atoms = getProjectAtoms(projectId); + const existingMeta = atoms.meta.get(); + atoms.meta.set({ ...existingMeta, description: trimmed || null, updatedAt: now }); // Invalidate project list query to refetch with updated description queryClient.invalidateQueries({ queryKey: queryKeys.projects.all }); diff --git a/packages/web/src/primitives/useProject/sync.ts b/packages/web/src/primitives/useProject/sync.ts index 1e68d9ee3..8f3800c84 100644 --- a/packages/web/src/primitives/useProject/sync.ts +++ b/packages/web/src/primitives/useProject/sync.ts @@ -4,7 +4,6 @@ */ import * as Y from 'yjs'; -import { useProjectStore } from '@/stores/projectStore'; import type { StudyInfo, ChecklistEntry, @@ -13,6 +12,7 @@ import type { ProjectMeta, OutcomeEntry, } from '@/stores/projectStore'; +import { useProjectStore } from '@/stores/projectStore'; import { getProjectAtoms, cleanupProjectAtoms } from '@/stores/projectAtoms'; import { scoreChecklistOfType } from '@/checklist-registry/index'; import { amstar2 } from '@corates/shared'; @@ -171,6 +171,7 @@ export function createSyncManager(projectId: string, getYDoc: () => Y.Doc | null const projectAtoms = getProjectAtoms(projectId); if (updates.studies !== undefined) { projectAtoms.setStudies(updates.studies); + useProjectStore.getState().updateProjectStats(projectId, updates.studies); } if (updates.members !== undefined) { projectAtoms.members.set(updates.members); @@ -178,14 +179,6 @@ export function createSyncManager(projectId: string, getYDoc: () => Y.Doc | null if (updates.meta !== undefined) { projectAtoms.meta.set(updates.meta); } - - if ( - updates.studies !== undefined || - updates.members !== undefined || - updates.meta !== undefined - ) { - useProjectStore.getState().setProjectData(projectId, updates); - } } function scheduleSync(): void { diff --git a/packages/web/src/project/actions/pdfs.ts b/packages/web/src/project/actions/pdfs.ts index fbe1a9530..29fa3ca70 100644 --- a/packages/web/src/project/actions/pdfs.ts +++ b/packages/web/src/project/actions/pdfs.ts @@ -7,8 +7,8 @@ import { cachePdf, removeCachedPdf, getCachedPdf } from '@/primitives/pdfCache.j import { bestEffort } from '@/lib/errorLogger.js'; import { extractPdfDoi, extractPdfTitle } from '@/lib/pdfUtils.js'; import { fetchFromDOI } from '@/lib/referenceLookup.js'; -import { useProjectStore } from '@/stores/projectStore'; import type { PdfEntry } from '@/stores/projectStore'; +import { getProjectAtoms } from '@/stores/projectAtoms'; import { usePdfPreviewStore } from '@/stores/pdfPreviewStore'; import { useAuthStore, selectUser } from '@/stores/authStore'; import { connectionPool } from '../ConnectionPool'; @@ -116,7 +116,7 @@ export const pdfActions = { } const study = - useProjectStore.getState().projects[projectId]?.studies.find(s => s.id === studyId) ?? null; + getProjectAtoms(projectId).getOrCreateStudyAtom(studyId).get() ?? null; const existingPdf = study?.pdfs.find(p => p.fileName === file.name); if (existingPdf) { throw new Error(`File "${file.name}" already exists. Rename or remove the existing copy.`); @@ -250,7 +250,7 @@ export const pdfActions = { if (!projectId || !orgId || !ops) throw new Error('No active project connection'); const study = - useProjectStore.getState().projects[projectId]?.studies.find(s => s.id === studyId) ?? null; + getProjectAtoms(projectId).getOrCreateStudyAtom(studyId).get() ?? null; const hasPdfs = (study?.pdfs.length ?? 0) > 0; const effectiveTag = !hasPdfs ? 'primary' : tag; diff --git a/packages/web/src/project/actions/studies.ts b/packages/web/src/project/actions/studies.ts index 7414c1d3a..d3d5edf20 100644 --- a/packages/web/src/project/actions/studies.ts +++ b/packages/web/src/project/actions/studies.ts @@ -9,7 +9,7 @@ import { showToast } from '@/components/ui/toast'; import { importFromGoogleDrive } from '@/api/google-drive'; import { extractPdfDoi, extractPdfTitle } from '@/lib/pdfUtils.js'; import { fetchFromDOI } from '@/lib/referenceLookup.js'; -import { useProjectStore } from '@/stores/projectStore'; +import { getProjectAtoms } from '@/stores/projectAtoms'; import { useAuthStore, selectUser } from '@/stores/authStore'; import { connectionPool, type TypedProjectOps } from '../ConnectionPool'; import type { PdfInfo, PdfTag } from '@/primitives/useProject/pdfs'; @@ -283,8 +283,7 @@ export const studyActions = { } try { - const study = - useProjectStore.getState().projects[projectId]?.studies.find(s => s.id === studyId) ?? null; + const study = getProjectAtoms(projectId).getOrCreateStudyAtom(studyId).get() ?? null; const pdfs = study?.pdfs ?? []; if (pdfs.length > 0) { diff --git a/packages/web/src/stores/projectStore.ts b/packages/web/src/stores/projectStore.ts index 8b7f3de4d..6415b8107 100644 --- a/packages/web/src/stores/projectStore.ts +++ b/packages/web/src/stores/projectStore.ts @@ -132,14 +132,7 @@ export interface StudyInfo { annotations: Record; } -interface ProjectData { - meta: ProjectMeta; - members: MemberEntry[]; - studies: StudyInfo[]; -} - interface ProjectStoreState { - projects: Record; activeProjectId: string | null; connections: Record; projectStats: Record; @@ -147,7 +140,7 @@ interface ProjectStoreState { interface ProjectStoreActions { setActiveProject: (projectId: string | null) => void; - setProjectData: (projectId: string, data: Partial) => void; + updateProjectStats: (projectId: string, studies: StudyInfo[]) => void; dispatchConnectionEvent: (projectId: string, event: ConnectionEvent) => void; clearProject: (projectId: string) => void; } @@ -185,7 +178,6 @@ function computeProjectStats(studies: StudyInfo[]): { studyCount: number; comple export const useProjectStore = create()( immer(set => ({ - projects: {}, activeProjectId: null, connections: {}, projectStats: loadPersistedStats(), @@ -195,32 +187,12 @@ export const useProjectStore = create() state.activeProjectId = projectId; }), - setProjectData: (projectId, data) => { - let studiesChanged = false; + updateProjectStats: (projectId, studies) => { set(state => { - if (!state.projects[projectId]) { - state.projects[projectId] = { meta: { outcomes: [] }, members: [], studies: [] }; - } - const project = state.projects[projectId]; - if (data.meta !== undefined) { - project.meta = data.meta; - } - if (data.members !== undefined) { - project.members = data.members; - } - if (data.studies !== undefined) { - project.studies = data.studies; - const stats = computeProjectStats(data.studies); - state.projectStats[projectId] = { - ...stats, - lastUpdated: Date.now(), - }; - studiesChanged = true; - } + const stats = computeProjectStats(studies); + state.projectStats[projectId] = { ...stats, lastUpdated: Date.now() }; }); - if (studiesChanged) { - persistStats(useProjectStore.getState().projectStats); - } + persistStats(useProjectStore.getState().projectStats); }, dispatchConnectionEvent: (projectId, event) => @@ -231,7 +203,6 @@ export const useProjectStore = create() clearProject: projectId => set(state => { - delete state.projects[projectId]; delete state.connections[projectId]; if (state.activeProjectId === projectId) { state.activeProjectId = null; @@ -240,13 +211,6 @@ export const useProjectStore = create() })), ); -// Stable fallback constants -- must be module-level so they're referentially equal -// across renders. Without these, selectors return new objects/arrays on every call -// when a project doesn't exist in the store, causing infinite re-render loops. -const EMPTY_STUDIES: StudyInfo[] = []; -const EMPTY_MEMBERS: MemberEntry[] = []; -const EMPTY_META: ProjectMeta = { outcomes: [] }; - // Selectors (pure functions, not hooks -- can be used with useProjectStore(selector)) export function selectConnectionPhase( @@ -262,25 +226,3 @@ export function selectProjectStats( ): ProjectStats | null { return state.projectStats[projectId] || null; } - -export function selectStudies(state: ProjectStoreState, projectId: string): StudyInfo[] { - return state.projects[projectId]?.studies || EMPTY_STUDIES; -} - -export function selectMembers(state: ProjectStoreState, projectId: string): MemberEntry[] { - return state.projects[projectId]?.members || EMPTY_MEMBERS; -} - -export function selectMeta(state: ProjectStoreState, projectId: string): ProjectMeta { - return state.projects[projectId]?.meta || EMPTY_META; -} - -export function selectStudy( - state: ProjectStoreState, - projectId: string, - studyId: string, -): StudyInfo | null { - const studies = state.projects[projectId]?.studies; - if (!studies) return null; - return studies.find(s => s.id === studyId) || null; -} From e21a13bf0752d7d62edb80e21108b00937f83adc Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Fri, 1 May 2026 02:51:24 -0500 Subject: [PATCH 04/10] fix vite hmr bug --- packages/web/src/server.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 19c187a4a..7f16e5d36 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -1,4 +1,3 @@ -import { createStartHandler, defaultStreamHandler } from '@tanstack/react-start/server'; import * as Sentry from '@sentry/cloudflare'; import { handleEmailQueue } from '@corates/workers/queue'; import { getProjectDocStub } from '@corates/workers/project-doc-id'; @@ -7,7 +6,26 @@ import { getProjectDocStub } from '@corates/workers/project-doc-id'; // worker's main module. The class implementations live in @corates/workers. export { UserSession, ProjectDoc } from '@corates/workers/durable-objects'; -const startFetch = createStartHandler(defaultStreamHandler); +// Workaround for Vite ModuleRunner bug (vitejs/vite#22293): static top-level +// imports of `createStartHandler` break after HMR due to stale cycle detection +// in `export *` re-export chains. Dynamic import avoids the issue. +let startFetch: ((req: Request, opts?: never) => Response | Promise) | null = null; + +async function getStartFetch() { + if (!startFetch) { + const { createStartHandler, defaultStreamHandler } = await import( + '@tanstack/react-start/server' + ); + startFetch = createStartHandler(defaultStreamHandler); + } + return startFetch!; +} + +if (import.meta.hot) { + import.meta.hot.accept(() => { + startFetch = null; + }); +} // `/api/project-doc/(/<...>)?` — y-websocket appends the room as // the trailing segment; we route by path prefix and forward the original @@ -59,7 +77,8 @@ const workerHandler = { // work like Stripe webhook ledger updates and notification fan-out). // Cast: createStartHandler's RequestOptions.context defaults to a narrow // BaseContext until we register a project-wide requestContext type. - return startFetch(request, { context: { cloudflareCtx: ctx } } as never); + const handler = await getStartFetch(); + return handler(request, { context: { cloudflareCtx: ctx } } as never); }, async queue(batch: MessageBatch, env: unknown): Promise { From e3b61a8359c27548cb4b5bd90df3ca107631c575 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Fri, 1 May 2026 02:52:13 -0500 Subject: [PATCH 05/10] fix potential stale RAF --- packages/web/src/primitives/useProject/sync.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/web/src/primitives/useProject/sync.ts b/packages/web/src/primitives/useProject/sync.ts index 8f3800c84..e70abf57d 100644 --- a/packages/web/src/primitives/useProject/sync.ts +++ b/packages/web/src/primitives/useProject/sync.ts @@ -31,6 +31,8 @@ export interface SyncManager { export function createSyncManager(projectId: string, getYDoc: () => Y.Doc | null): SyncManager { let pendingSync = false; + let rafId: number | null = null; + let detached = false; let paused = false; const cleanupHandlers: (() => void)[] = []; @@ -184,9 +186,10 @@ export function createSyncManager(projectId: string, getYDoc: () => Y.Doc | null function scheduleSync(): void { if (pendingSync) return; pendingSync = true; - requestAnimationFrame(() => { + rafId = requestAnimationFrame(() => { + rafId = null; pendingSync = false; - doSync(); + if (!detached) doSync(); }); } @@ -232,6 +235,12 @@ export function createSyncManager(projectId: string, getYDoc: () => Y.Doc | null } function detach(): void { + detached = true; + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + pendingSync = false; + } for (const cleanup of cleanupHandlers) { try { cleanup(); From 60329b42de8fc83e4654f52494075337d29778dd Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 1 May 2026 07:52:55 +0000 Subject: [PATCH 06/10] Apply Prettier formatting --- packages/docs/audits/yjs-reactive-hooks.md | 196 +++++++-------- packages/web/e2e/amstar2-workflow.spec.ts | 24 +- packages/web/e2e/concurrent-crdt.spec.ts | 52 +++- packages/web/e2e/persistence-recovery.spec.ts | 48 ++-- .../web/e2e/realtime-collaboration.spec.ts | 20 +- packages/web/e2e/rob2-workflow.spec.ts | 24 +- packages/web/e2e/shared-steps.ts | 4 +- .../web/migrations/meta/0001_snapshot.json | 238 +++++------------- packages/web/migrations/meta/_journal.json | 2 +- .../src/components/admin/AnalyticsSection.tsx | 4 +- .../components/admin/ui/AdminDataTable.tsx | 1 - .../checklist/ChecklistYjsWrapper.tsx | 6 +- .../checklist/SplitScreenLayout.tsx | 2 +- .../src/components/dev/DevImportProject.tsx | 27 +- .../react/src/components/page-controls.tsx | 2 +- .../react/src/components/search-sidebar.tsx | 2 +- .../components/project/CreateProjectModal.tsx | 4 +- .../src/components/project/ProjectHeader.tsx | 4 +- .../src/components/project/ProjectView.tsx | 6 +- .../src/components/project/SlidingPanel.tsx | 2 +- .../project/add-studies/AddStudiesForm.tsx | 11 +- .../all-studies-tab/AssignReviewersModal.tsx | 2 - .../all-studies-tab/EditPdfMetadataModal.tsx | 2 - .../GoogleDrivePickerLauncher.tsx | 2 +- .../google-drive/GoogleDrivePickerModal.tsx | 3 +- .../project/overview-tab/AddMemberModal.tsx | 2 +- .../project/overview-tab/ChartSection.tsx | 1 - .../overview-tab/ReviewerAssignment.tsx | 10 +- .../reconcile-tab/ReconciliationWrapper.tsx | 8 +- .../MultiPartQuestionPage.tsx | 4 +- .../ReconciliationQuestionPage.tsx | 4 +- .../engine/useReconciliationEngine.ts | 7 +- .../components/settings/BillingSettings.tsx | 2 +- .../settings/LinkedAccountsSection.tsx | 2 +- .../settings/MergeAccountsDialog.tsx | 2 - .../src/components/settings/PlansSettings.tsx | 2 +- .../settings/ProfileInfoSection.tsx | 1 - .../components/settings/SecuritySettings.tsx | 1 - packages/web/src/config/sentry.ts | 4 +- .../src/hooks/useReconciliationPresence.ts | 5 +- packages/web/src/hooks/useYText.ts | 1 - .../project/__tests__/studyActions.test.ts | 21 +- packages/web/src/project/actions/pdfs.ts | 6 +- packages/web/src/project/actions/studies.ts | 4 +- packages/web/src/routes/__root.tsx | 2 +- .../_app/_protected/admin/billing.ledger.tsx | 4 +- .../_protected/admin/billing.stripe-tools.tsx | 5 +- .../routes/_app/_protected/admin/database.tsx | 2 +- .../routes/_app/_protected/admin/index.tsx | 5 +- .../_app/_protected/admin/orgs.index.tsx | 7 +- .../_app/_protected/admin/projects.index.tsx | 20 +- .../_app/_protected/admin/users.$userId.tsx | 8 +- packages/web/src/routes/_auth/check-email.tsx | 2 +- .../web/src/routes/_auth/complete-profile.tsx | 4 +- packages/web/src/server.ts | 5 +- .../src/server/functions/admin-orgs.server.ts | 5 +- .../server/functions/admin-projects.server.ts | 7 +- .../src/server/functions/billing.server.ts | 4 +- .../web/src/server/functions/users.server.ts | 5 +- packages/web/src/stores/projectAtoms.ts | 17 +- .../commands/invitations/acceptInvitation.ts | 10 +- .../commands/invitations/createInvitation.ts | 5 +- .../workers/src/commands/members/addMember.ts | 5 +- .../src/commands/members/removeMember.ts | 5 +- .../src/commands/members/updateMemberRole.ts | 5 +- .../src/commands/projects/createProject.ts | 5 +- .../src/commands/projects/deleteProject.ts | 10 +- .../src/commands/projects/updateProject.ts | 5 +- .../workers/src/durable-objects/ProjectDoc.ts | 8 +- .../durable-objects/ProjectDocPersistence.ts | 11 +- .../src/durable-objects/dev-handlers.ts | 9 +- packages/workers/src/lib/logger.ts | 14 +- packages/workers/src/lib/mock-templates.ts | 15 +- packages/workers/src/lib/retry.ts | 4 +- packages/workers/src/lib/syncWithRetry.ts | 13 +- 75 files changed, 486 insertions(+), 515 deletions(-) diff --git a/packages/docs/audits/yjs-reactive-hooks.md b/packages/docs/audits/yjs-reactive-hooks.md index 6880e5584..a672e1f88 100644 --- a/packages/docs/audits/yjs-reactive-hooks.md +++ b/packages/docs/audits/yjs-reactive-hooks.md @@ -3,7 +3,6 @@ Reactive Yjs Hooks Prototype -- @tldraw/state Status: Proposal Last updated: 2026-04-30 - Problem The sync manager (sync.ts) already does per-study dirty tracking via studyCache. @@ -16,7 +15,6 @@ unrelated updates. This is a structural problem, not a bug-by-bug fix. The Zustand array is the wrong data structure for per-entity collaborative state. - Approach Use @tldraw/state (npm package, ~3,800 lines, only depends on @tldraw/utils) as @@ -24,61 +22,53 @@ the reactive primitive layer between Yjs and React. Keep Yjs for sync/CRDT. Keep Zustand for non-collaborative UI state (tab selection, modal open/close, etc). @tldraw/state provides: - - atom(name, value, options?) -- mutable cell with equality-gated set() - - computed(name, fn) -- derived value that only recomputes when dependencies change - - AtomMap -- reactive Map> with per-key subscriptions - - useValue(atom) -- React hook via useSyncExternalStore, re-renders only when - the atom's value actually changes (by equality check) - - transact(fn) -- batch multiple atom writes into one notification pass + +- atom(name, value, options?) -- mutable cell with equality-gated set() +- computed(name, fn) -- derived value that only recomputes when dependencies change +- AtomMap -- reactive Map> with per-key subscriptions +- useValue(atom) -- React hook via useSyncExternalStore, re-renders only when + the atom's value actually changes (by equality check) +- transact(fn) -- batch multiple atom writes into one notification pass The key property: atom.set(newValue) is a no-op if newValue equals the current value (shallow equality by default, configurable). This is the missing gate that studyCache already computes but Zustand discards. - Architecture - Y.Doc (Yjs) - | - | observe / observeDeep - v - Sync Manager (sync.ts) - | - | atom.set(serializedStudy) <-- equality gate here - v - AtomMap <-- one atom per study - | - | useValue(atom) <-- React subscribes to individual atoms - v - React components <-- only re-render when THEIR study changes - +Y.Doc (Yjs) +| +| observe / observeDeep +v +Sync Manager (sync.ts) +| +| atom.set(serializedStudy) <-- equality gate here +v +AtomMap <-- one atom per study +| +| useValue(atom) <-- React subscribes to individual atoms +v +React components <-- only re-render when THEIR study changes - Zustand stays for: - - Connection lifecycle state - - UI state (active tab, modal open/close, selection) - - Non-collaborative derived state (project stats, preferences) +Zustand stays for: - Connection lifecycle state - UI state (active tab, modal open/close, selection) - Non-collaborative derived state (project stats, preferences) - Zustand does NOT keep: - - Project metadata (name, settings) -- same referential instability problem - - Members list -- same problem, just less frequent - - Studies -- the primary motivation for this work - - All collaborative data from Y.Doc goes through atoms. This avoids having - two read patterns for the same class of data. +Zustand does NOT keep: - Project metadata (name, settings) -- same referential instability problem - Members list -- same problem, just less frequent - Studies -- the primary motivation for this work +All collaborative data from Y.Doc goes through atoms. This avoids having +two read patterns for the same class of data. What to prototype Phase 1: StudyAtomMap + useStudy hook - Install @tldraw/state and @tldraw/state-react. +Install @tldraw/state and @tldraw/state-react. - Create a StudyAtomMap in the sync manager: +Create a StudyAtomMap in the sync manager: const studyAtoms = new AtomMap() - In handleReviewsEvents, instead of rebuilding the full array and calling - setProjectData, write individual atoms: +In handleReviewsEvents, instead of rebuilding the full array and calling +setProjectData, write individual atoms: for (const [studyId, study] of studyCache) { if (dirtyStudyIds.has(studyId)) { @@ -86,14 +76,14 @@ Phase 1: StudyAtomMap + useStudy hook } } - Expose a React hook: +Expose a React hook: function useStudy(studyId: string): StudyInfo | undefined { const atom = studyAtoms.getAtom(studyId) return useValue(atom) } - Expose a study order atom for list rendering: +Expose a study order atom for list rendering: const studyOrder = atom('studyOrder', []) @@ -101,34 +91,34 @@ Phase 1: StudyAtomMap + useStudy hook return useValue(studyOrder) } - List components use useStudyIds() to get the array of IDs, then each row - uses useStudy(id). Adding/removing studies changes the order atom. Editing - a study's fields only touches that study's atom -- other rows don't re-render. +List components use useStudyIds() to get the array of IDs, then each row +uses useStudy(id). Adding/removing studies changes the order atom. Editing +a study's fields only touches that study's atom -- other rows don't re-render. Phase 1b: Meta and members atoms - Apply the same pattern to project metadata and members. These have the same - referential instability problem as studies -- just triggered less often. Since - the atom infrastructure exists from Phase 1, this is trivial: +Apply the same pattern to project metadata and members. These have the same +referential instability problem as studies -- just triggered less often. Since +the atom infrastructure exists from Phase 1, this is trivial: const projectMeta = atom('projectMeta', defaultMeta) const membersAtom = atom('members', []) - This ensures all collaborative Y.Doc data flows through one read pattern - (atoms + useValue), with Zustand reserved for genuinely non-collaborative - state. +This ensures all collaborative Y.Doc data flows through one read pattern +(atoms + useValue), with Zustand reserved for genuinely non-collaborative +state. Phase 2: Snapshot isolation hook - Even with per-study atoms solving most re-render problems, same-entity - conflicts still need isolation. If two users are both editing study-5's - reviewers simultaneously, the atom for study-5 will update from the remote - peer. A modal editing that same study needs to capture-and-hold during the - editing session. +Even with per-study atoms solving most re-render problems, same-entity +conflicts still need isolation. If two users are both editing study-5's +reviewers simultaneously, the atom for study-5 will update from the remote +peer. A modal editing that same study needs to capture-and-hold during the +editing session. - Atoms reduce the blast radius (unrelated studies no longer trigger it), but - same-entity remote updates still can. Generalize the useEffectEvent pattern - from the reviewer modal fix: +Atoms reduce the blast radius (unrelated studies no longer trigger it), but +same-entity remote updates still can. Generalize the useEffectEvent pattern +from the reviewer modal fix: function useSnapshotValue(liveValue: T, isEditing: boolean): T { const snapshotRef = useRef(liveValue); @@ -136,14 +126,14 @@ Phase 2: Snapshot isolation hook return isEditing ? snapshotRef.current : liveValue; } - Every modal/form that edits collaborative data uses useSnapshotValue with - the atom-backed live value. The atom provides referential stability across - unrelated syncs; the snapshot provides isolation from same-entity syncs - during edits. +Every modal/form that edits collaborative data uses useSnapshotValue with +the atom-backed live value. The atom provides referential stability across +unrelated syncs; the snapshot provides isolation from same-entity syncs +during edits. Phase 3: Computed selectors - Replace derived Zustand selectors with computed(): +Replace derived Zustand selectors with computed(): const studiesForTab = computed('studiesForTab', () => { return studyOrder.get() @@ -151,13 +141,13 @@ Phase 3: Computed selectors .filter(s => matchesTab(s, activeTab)) }) - These only recompute when their input atoms change. If a study that isn't - in the current tab gets updated, the tab's computed doesn't fire. +These only recompute when their input atoms change. If a study that isn't +in the current tab gets updated, the tab's computed doesn't fire. Phase 4: Migrate one component end-to-end - Pick ChecklistYjsWrapper or AllStudiesTab. Replace the Zustand - selectStudies selector with useStudy / useStudyIds. Verify: +Pick ChecklistYjsWrapper or AllStudiesTab. Replace the Zustand +selectStudies selector with useStudy / useStudyIds. Verify: - Opening a modal and editing doesn't get interrupted by unrelated syncs - Assigning reviewers in the modal survives background Y.Doc updates @@ -166,7 +156,7 @@ Phase 4: Migrate one component end-to-end Phase 5: Evaluate and expand - If Phase 3 validates the approach: +If Phase 3 validates the approach: - Migrate remaining study consumers - Consider AtomMaps for checklist answers (useChecklistAnswers currently @@ -174,58 +164,48 @@ Phase 5: Evaluate and expand - Remove studies array from Zustand projectStore - Delete the reactive-yjs-hooks-plan.md predecessor doc - What NOT to prototype - - Don't replace Yjs sync -- keep y-websocket / Durable Object sync as-is - - Don't build a custom sync protocol -- Yjs CRDTs work fine for this use case - - Don't remove Zustand -- it stays for connection lifecycle, UI state, and - non-collaborative derived state. All collaborative Y.Doc data moves to atoms. - - Don't vendor / fork @tldraw/state yet -- use the npm package first, only - vendor if the dependency becomes a problem - - Don't add tldraw's HistoryBuffer or rollback transactions -- not needed - for this use case - +- Don't replace Yjs sync -- keep y-websocket / Durable Object sync as-is +- Don't build a custom sync protocol -- Yjs CRDTs work fine for this use case +- Don't remove Zustand -- it stays for connection lifecycle, UI state, and + non-collaborative derived state. All collaborative Y.Doc data moves to atoms. +- Don't vendor / fork @tldraw/state yet -- use the npm package first, only + vendor if the dependency becomes a problem +- Don't add tldraw's HistoryBuffer or rollback transactions -- not needed + for this use case Dependencies - @tldraw/state (4.5.10) -- core atoms, computed, transactions - @tldraw/state-react (4.5.10) -- useValue, useComputed, useAtom hooks - @tldraw/utils (4.5.10) -- transitive dep, 5 utility functions - - All are MIT licensed. Combined footprint is ~4,400 lines. No other transitive - dependencies. +@tldraw/state (4.5.10) -- core atoms, computed, transactions +@tldraw/state-react (4.5.10) -- useValue, useComputed, useAtom hooks +@tldraw/utils (4.5.10) -- transitive dep, 5 utility functions +All are MIT licensed. Combined footprint is ~4,400 lines. No other transitive +dependencies. Risk assessment - Low risk: - - Additive change -- new hooks alongside existing Zustand, migrate gradually - - @tldraw/state is well-tested, used in production by tldraw - - Existing E2E tests validate behavior, not implementation - - Medium risk: - - Two state systems during migration (atoms + Zustand) -- need clear ownership - boundaries per data type +Low risk: - Additive change -- new hooks alongside existing Zustand, migrate gradually - @tldraw/state is well-tested, used in production by tldraw - Existing E2E tests validate behavior, not implementation - Watch for: - - Interaction between tldraw's transact() and Yjs transactions - - Whether @tldraw/state-react's useValue plays well with React Compiler -- - useValue uses useSyncExternalStore (which the compiler handles fine) but - tldraw may wrap it with patterns the compiler doesn't optimize well. - Worth a 10-minute check: install the packages, write a test component, - run the compiler, see what it emits. +Medium risk: - Two state systems during migration (atoms + Zustand) -- need clear ownership +boundaries per data type +Watch for: - Interaction between tldraw's transact() and Yjs transactions - Whether @tldraw/state-react's useValue plays well with React Compiler -- +useValue uses useSyncExternalStore (which the compiler handles fine) but +tldraw may wrap it with patterns the compiler doesn't optimize well. +Worth a 10-minute check: install the packages, write a test component, +run the compiler, see what it emits. Success criteria - - AssignReviewersModal no longer needs useEffectEvent guard (the underlying - atom doesn't change reference when an unrelated study syncs) - - useChecklistAnswers uses the study atom for reading finalized state (study - status, reviewer assignments), but retains direct Y.Doc access for the live - editing path (Y.Text instances for collaborative checklist editing). Both - read patterns coexist -- atoms for serialized state, direct Yjs for live - collaborative types - - Opening a modal during active collaboration doesn't re-initialize form state - - No increase in total re-render count (measure with React DevTools profiler) - - All existing E2E tests pass without modification +- AssignReviewersModal no longer needs useEffectEvent guard (the underlying + atom doesn't change reference when an unrelated study syncs) +- useChecklistAnswers uses the study atom for reading finalized state (study + status, reviewer assignments), but retains direct Y.Doc access for the live + editing path (Y.Text instances for collaborative checklist editing). Both + read patterns coexist -- atoms for serialized state, direct Yjs for live + collaborative types +- Opening a modal during active collaboration doesn't re-initialize form state +- No increase in total re-render count (measure with React DevTools profiler) +- All existing E2E tests pass without modification diff --git a/packages/web/e2e/amstar2-workflow.spec.ts b/packages/web/e2e/amstar2-workflow.spec.ts index ddf3a477a..d7832d3df 100644 --- a/packages/web/e2e/amstar2-workflow.spec.ts +++ b/packages/web/e2e/amstar2-workflow.spec.ts @@ -34,11 +34,15 @@ test('Dual-Reviewer AMSTAR2 Workflow', async ({ context, page }) => { // User A fills AMSTAR2 checklist // ================================================================ await page.getByRole('tab', { name: /To Do/i }).click(); - await expect(page.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ + timeout: 10_000, + }); await page.getByRole('button', { name: /Select Checklist/i }).click(); await page.getByRole('button', { name: /Add Checklist/i }).click(); - await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ + timeout: 10_000, + }); await page.getByRole('button', { name: 'Open', exact: true }).click(); await expect(page).toHaveURL(/\/checklists\//, { timeout: 10_000 }); @@ -56,11 +60,15 @@ test('Dual-Reviewer AMSTAR2 Workflow', async ({ context, page }) => { await expect(page.getByText('AMSTAR2 E2E Test').first()).toBeVisible({ timeout: 15_000 }); await page.getByRole('tab', { name: /To Do/i }).click(); - await expect(page.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ + timeout: 10_000, + }); await page.getByRole('button', { name: /Select Checklist/i }).click(); await page.getByRole('button', { name: /Add Checklist/i }).click(); - await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ + timeout: 10_000, + }); await page.getByRole('button', { name: 'Open', exact: true }).last().click(); await expect(page).toHaveURL(/\/checklists\//, { timeout: 10_000 }); @@ -73,7 +81,9 @@ test('Dual-Reviewer AMSTAR2 Workflow', async ({ context, page }) => { .catch(() => false) ) { await page.goBack(); - await expect(page.getByRole('button', { name: 'Open', exact: true }).first()).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: 'Open', exact: true }).first()).toBeVisible({ + timeout: 10_000, + }); await page.getByRole('button', { name: 'Open', exact: true }).first().click(); await expect(page).toHaveURL(/\/checklists\//, { timeout: 10_000 }); } @@ -93,7 +103,9 @@ test('Dual-Reviewer AMSTAR2 Workflow', async ({ context, page }) => { await page.getByRole('button', { name: /Reconcile/i }).click(); await expect(page).toHaveURL(/\/reconcile\//, { timeout: 10_000 }); - await expect(page.getByRole('heading', { name: 'Reconciliation' })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('heading', { name: 'Reconciliation' })).toBeVisible({ + timeout: 10_000, + }); await expect(page.getByText('Question 1 of 16')).toBeVisible(); // Select Alice's answer for all 16 questions diff --git a/packages/web/e2e/concurrent-crdt.spec.ts b/packages/web/e2e/concurrent-crdt.spec.ts index 7fa6804b2..57c93454b 100644 --- a/packages/web/e2e/concurrent-crdt.spec.ts +++ b/packages/web/e2e/concurrent-crdt.spec.ts @@ -113,7 +113,9 @@ async function openEditableChecklist(page: Page): Promise { .catch(() => false) ) { await page.goBack(); - await expect(page.getByRole('button', { name: 'Open', exact: true }).first()).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: 'Open', exact: true }).first()).toBeVisible({ + timeout: 10_000, + }); await page.getByRole('button', { name: 'Open', exact: true }).first().click(); await expect(page).toHaveURL(/\/checklists\//, { timeout: 10_000 }); } @@ -252,10 +254,14 @@ test.describe('Concurrent CRDT: AMSTAR2', () => { // User A adds checklist await setupPage.getByRole('tab', { name: /To Do/i }).click(); - await expect(setupPage.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ timeout: 10_000 }); + await expect(setupPage.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ + timeout: 10_000, + }); await setupPage.getByRole('button', { name: /Select Checklist/i }).click(); await setupPage.getByRole('button', { name: /Add Checklist/i }).click(); - await expect(setupPage.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(setupPage.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ + timeout: 10_000, + }); await setupPage.getByRole('button', { name: 'Open', exact: true }).click(); await expect(setupPage).toHaveURL(/\/checklists\//, { timeout: 10_000 }); @@ -270,10 +276,14 @@ test.describe('Concurrent CRDT: AMSTAR2', () => { await expect(setupPage.getByText('AMSTAR2 CRDT Test').first()).toBeVisible({ timeout: 15_000 }); await setupPage.getByRole('tab', { name: /To Do/i }).click(); - await expect(setupPage.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ timeout: 10_000 }); + await expect(setupPage.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ + timeout: 10_000, + }); await setupPage.getByRole('button', { name: /Select Checklist/i }).click(); await setupPage.getByRole('button', { name: /Add Checklist/i }).click(); - await expect(setupPage.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(setupPage.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ + timeout: 10_000, + }); const checklistUrlB = await openEditableChecklist(setupPage); await setupCtx.close(); @@ -323,18 +333,24 @@ test.describe('Concurrent CRDT: ROB2', () => { // User A adds ROB2 checklist await setupPage.getByRole('tab', { name: /To Do/i }).click(); - await expect(setupPage.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ timeout: 10_000 }); + await expect(setupPage.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ + timeout: 10_000, + }); await setupPage.getByRole('button', { name: /Select Checklist/i }).click(); await setupPage.getByText(/AMSTAR 2/i).click(); await setupPage.getByRole('option', { name: /RoB 2/i }).click(); await setupPage.getByText(/Select outcome/i).click(); await setupPage.getByRole('option', { name: /Primary outcome/i }).click(); await setupPage.getByRole('button', { name: /Add Checklist/i }).click(); - await expect(setupPage.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(setupPage.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ + timeout: 10_000, + }); await setupPage.getByRole('button', { name: 'Open', exact: true }).click(); await expect(setupPage).toHaveURL(/\/checklists\//, { timeout: 10_000 }); - await expect(setupPage.getByText('Individually-randomized parallel-group trial')).toBeVisible({ timeout: 10_000 }); + await expect(setupPage.getByText('Individually-randomized parallel-group trial')).toBeVisible({ + timeout: 10_000, + }); // Fill preliminary so domain questions are visible await fillROB2Preliminary(setupPage, 'Drug A', 'Placebo'); @@ -350,19 +366,25 @@ test.describe('Concurrent CRDT: ROB2', () => { await expect(setupPage.getByText('ROB2 CRDT Test').first()).toBeVisible({ timeout: 15_000 }); await setupPage.getByRole('tab', { name: /To Do/i }).click(); - await expect(setupPage.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ timeout: 10_000 }); + await expect(setupPage.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ + timeout: 10_000, + }); await setupPage.getByRole('button', { name: /Select Checklist/i }).click(); await setupPage.getByText(/AMSTAR 2/i).click(); await setupPage.getByRole('option', { name: /RoB 2/i }).click(); await setupPage.getByText(/Select outcome/i).click(); await setupPage.getByRole('option', { name: /Primary outcome/i }).click(); await setupPage.getByRole('button', { name: /Add Checklist/i }).click(); - await expect(setupPage.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(setupPage.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ + timeout: 10_000, + }); const checklistUrlB = await openEditableChecklist(setupPage); // Fill preliminary for User B too - await expect(setupPage.getByText('Individually-randomized parallel-group trial')).toBeVisible({ timeout: 10_000 }); + await expect(setupPage.getByText('Individually-randomized parallel-group trial')).toBeVisible({ + timeout: 10_000, + }); await fillROB2Preliminary(setupPage, 'Drug B', 'Standard care'); await setupCtx.close(); @@ -375,12 +397,16 @@ test.describe('Concurrent CRDT: ROB2', () => { loadedSelector: 'D1', clickA: async (page, count) => { await page.getByRole('button', { name: 'D1', exact: true }).click(); - await expect(page.getByRole('button', { name: 'Y', exact: true }).first()).toBeVisible({ timeout: 5_000 }); + await expect(page.getByRole('button', { name: 'Y', exact: true }).first()).toBeVisible({ + timeout: 5_000, + }); return clickROB2Buttons(page, 'Y', count); }, clickB: async (page, count) => { await page.getByRole('button', { name: 'D1', exact: true }).click(); - await expect(page.getByRole('button', { name: 'N', exact: true }).first()).toBeVisible({ timeout: 5_000 }); + await expect(page.getByRole('button', { name: 'N', exact: true }).first()).toBeVisible({ + timeout: 5_000, + }); return clickROB2Buttons(page, 'N', count); }, countA: page => countSelectedROB2Buttons(page, 'Y'), diff --git a/packages/web/e2e/persistence-recovery.spec.ts b/packages/web/e2e/persistence-recovery.spec.ts index 2dc1db6f2..f9f4d4411 100644 --- a/packages/web/e2e/persistence-recovery.spec.ts +++ b/packages/web/e2e/persistence-recovery.spec.ts @@ -79,11 +79,15 @@ test('Project state survives page refresh', async ({ context, page }) => { // partial in-progress state survives the refresh. // ================================================================ await page.getByRole('tab', { name: /To Do/i }).click(); - await expect(page.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ + timeout: 10_000, + }); await page.getByRole('button', { name: /Select Checklist/i }).click(); await page.getByRole('button', { name: /Add Checklist/i }).click(); - await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ + timeout: 10_000, + }); await page.getByRole('button', { name: 'Open', exact: true }).click(); await expect(page).toHaveURL(/\/checklists\//, { timeout: 10_000 }); await expect(page.getByRole('radio', { name: 'Yes' }).first()).toBeVisible({ timeout: 10_000 }); @@ -136,7 +140,9 @@ test('Project state survives page refresh', async ({ context, page }) => { // before the first reload. // ================================================================ await page.getByRole('tab', { name: /To Do/i }).click(); - await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ + timeout: 10_000, + }); // Reopen the same checklist via the "Open" button await page.getByRole('button', { name: 'Open', exact: true }).click(); @@ -178,12 +184,7 @@ test('Project data survives navigate-away and navigate-back (cached phase)', asy context, page, }) => { - const projectId = await setupProjectWithStudy( - context, - page, - scenario, - 'Cache Revisit E2E', - ); + const projectId = await setupProjectWithStudy(context, page, scenario, 'Cache Revisit E2E'); // Verify study is present after initial setup await page.getByRole('tab', { name: /All Studies/i }).click(); @@ -209,10 +210,7 @@ test('Project data survives navigate-away and navigate-back (cached phase)', asy await expect(page.getByText(/1 study in this project/i)).toBeVisible({ timeout: 15_000 }); }); -test('Concurrent server-side change merges correctly on revisit', async ({ - context, - page, -}) => { +test('Concurrent server-side change merges correctly on revisit', async ({ context, page }) => { const projectId = await setupProjectWithStudy( context, page, @@ -247,16 +245,8 @@ test('Concurrent server-side change merges correctly on revisit', async ({ await expect(page.getByText(/2 studies in this project/i)).toBeVisible({ timeout: 30_000 }); }); -test('Project actions work after cold reload (no warm query cache)', async ({ - context, - page, -}) => { - await setupProjectWithStudy( - context, - page, - scenario, - 'Cold Reload Actions E2E', - ); +test('Project actions work after cold reload (no warm query cache)', async ({ context, page }) => { + await setupProjectWithStudy(context, page, scenario, 'Cold Reload Actions E2E'); await page.getByRole('tab', { name: /All Studies/i }).click(); await expect(page.getByText(/1 study in this project/i)).toBeVisible({ timeout: 10_000 }); @@ -288,16 +278,8 @@ test('Project actions work after cold reload (no warm query cache)', async ({ expect(connectionErrors).toHaveLength(0); }); -test('Rapid navigation does not corrupt state or crash', async ({ - context, - page, -}) => { - const projectId = await setupProjectWithStudy( - context, - page, - scenario, - 'Rapid Nav E2E', - ); +test('Rapid navigation does not corrupt state or crash', async ({ context, page }) => { + const projectId = await setupProjectWithStudy(context, page, scenario, 'Rapid Nav E2E'); await page.getByRole('tab', { name: /All Studies/i }).click(); await expect(page.getByText(/1 study in this project/i)).toBeVisible({ timeout: 10_000 }); diff --git a/packages/web/e2e/realtime-collaboration.spec.ts b/packages/web/e2e/realtime-collaboration.spec.ts index dfde7e156..2c6a46429 100644 --- a/packages/web/e2e/realtime-collaboration.spec.ts +++ b/packages/web/e2e/realtime-collaboration.spec.ts @@ -109,10 +109,14 @@ test('Presence avatars, cursor sync, and text editing sync during reconciliation // User A: add AMSTAR2 checklist, answer Yes to everything, mark complete await page.getByRole('tab', { name: /To Do/i }).click(); - await expect(page.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ + timeout: 10_000, + }); await page.getByRole('button', { name: /Select Checklist/i }).click(); await page.getByRole('button', { name: /Add Checklist/i }).click(); - await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ + timeout: 10_000, + }); await page.getByRole('button', { name: 'Open', exact: true }).click(); await expect(page).toHaveURL(/\/checklists\//, { timeout: 10_000 }); await expect(page.locator('input[type="radio"]').first()).toBeVisible({ timeout: 10_000 }); @@ -128,10 +132,14 @@ test('Presence avatars, cursor sync, and text editing sync during reconciliation await expect(page.getByText('Realtime Reconcile Test').first()).toBeVisible({ timeout: 15_000 }); await page.getByRole('tab', { name: /To Do/i }).click(); - await expect(page.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ + timeout: 10_000, + }); await page.getByRole('button', { name: /Select Checklist/i }).click(); await page.getByRole('button', { name: /Add Checklist/i }).click(); - await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ + timeout: 10_000, + }); await page.getByRole('button', { name: 'Open', exact: true }).last().click(); await expect(page).toHaveURL(/\/checklists\//, { timeout: 10_000 }); @@ -143,7 +151,9 @@ test('Presence avatars, cursor sync, and text editing sync during reconciliation .catch(() => false) ) { await page.goBack(); - await expect(page.getByRole('button', { name: 'Open', exact: true }).first()).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: 'Open', exact: true }).first()).toBeVisible({ + timeout: 10_000, + }); await page.getByRole('button', { name: 'Open', exact: true }).first().click(); await expect(page).toHaveURL(/\/checklists\//, { timeout: 10_000 }); } diff --git a/packages/web/e2e/rob2-workflow.spec.ts b/packages/web/e2e/rob2-workflow.spec.ts index 1b30cb952..16030e2c2 100644 --- a/packages/web/e2e/rob2-workflow.spec.ts +++ b/packages/web/e2e/rob2-workflow.spec.ts @@ -44,7 +44,9 @@ test('Dual-Reviewer ROB2 Workflow', async ({ context, page }) => { // User A fills ROB2 checklist // ================================================================ await page.getByRole('tab', { name: /To Do/i }).click(); - await expect(page.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: /Select Checklist/i })).toBeVisible({ + timeout: 10_000, + }); await page.getByRole('button', { name: /Select Checklist/i }).click(); await page.getByText(/AMSTAR 2/i).click(); @@ -52,11 +54,15 @@ test('Dual-Reviewer ROB2 Workflow', async ({ context, page }) => { await page.getByText(/Select outcome/i).click(); await page.getByRole('option', { name: /Pain reduction/i }).click(); await page.getByRole('button', { name: /Add Checklist/i }).click(); - await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ + timeout: 10_000, + }); await page.getByRole('button', { name: 'Open', exact: true }).click(); await expect(page).toHaveURL(/\/checklists\//, { timeout: 10_000 }); - await expect(page.getByText('Individually-randomized parallel-group trial')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText('Individually-randomized parallel-group trial')).toBeVisible({ + timeout: 10_000, + }); await fillROB2Preliminary(page, 'Drug X', 'Placebo'); await answerAllROB2Domains(page, 'Y'); @@ -78,7 +84,9 @@ test('Dual-Reviewer ROB2 Workflow', async ({ context, page }) => { await page.getByText(/Select outcome/i).click(); await page.getByRole('option', { name: /Pain reduction/i }).click(); await page.getByRole('button', { name: /Add Checklist/i }).click(); - await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: 'Open', exact: true })).toBeVisible({ + timeout: 10_000, + }); await page.getByRole('button', { name: 'Open', exact: true }).last().click(); await expect(page).toHaveURL(/\/checklists\//, { timeout: 10_000 }); @@ -90,12 +98,16 @@ test('Dual-Reviewer ROB2 Workflow', async ({ context, page }) => { .catch(() => false) ) { await page.goBack(); - await expect(page.getByRole('button', { name: 'Open', exact: true }).first()).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('button', { name: 'Open', exact: true }).first()).toBeVisible({ + timeout: 10_000, + }); await page.getByRole('button', { name: 'Open', exact: true }).first().click(); await expect(page).toHaveURL(/\/checklists\//, { timeout: 10_000 }); } - await expect(page.getByText('Individually-randomized parallel-group trial')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText('Individually-randomized parallel-group trial')).toBeVisible({ + timeout: 10_000, + }); await fillROB2Preliminary(page, 'Drug Y', 'Standard care'); await answerAllROB2Domains(page, 'N'); await markChecklistComplete(page); diff --git a/packages/web/e2e/shared-steps.ts b/packages/web/e2e/shared-steps.ts index c0fa0cd54..f9a7a1b0e 100644 --- a/packages/web/e2e/shared-steps.ts +++ b/packages/web/e2e/shared-steps.ts @@ -43,7 +43,9 @@ export async function fillROB2Preliminary( export async function answerAllROB2Domains(page: Page, answer: string) { for (const domain of ['D1', 'D2', 'D3', 'D4', 'D5']) { await page.getByRole('button', { name: domain, exact: true }).click(); - await expect(page.getByRole('button', { name: answer, exact: true }).first()).toBeVisible({ timeout: 5_000 }); + await expect(page.getByRole('button', { name: answer, exact: true }).first()).toBeVisible({ + timeout: 5_000, + }); const buttons = page.getByRole('button', { name: answer, exact: true }); const count = await buttons.count(); diff --git a/packages/web/migrations/meta/0001_snapshot.json b/packages/web/migrations/meta/0001_snapshot.json index 30b240cea..0a4739f44 100644 --- a/packages/web/migrations/meta/0001_snapshot.json +++ b/packages/web/migrations/meta/0001_snapshot.json @@ -104,9 +104,7 @@ "indexes": { "account_userId_idx": { "name": "account_userId_idx", - "columns": [ - "userId" - ], + "columns": ["userId"], "isUnique": false } }, @@ -115,12 +113,8 @@ "name": "account_userId_user_id_fk", "tableFrom": "account", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -198,12 +192,8 @@ "name": "invitation_inviterId_user_id_fk", "tableFrom": "invitation", "tableTo": "user", - "columnsFrom": [ - "inviterId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["inviterId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -211,12 +201,8 @@ "name": "invitation_organizationId_organization_id_fk", "tableFrom": "invitation", "tableTo": "organization", - "columnsFrom": [ - "organizationId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organizationId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -310,9 +296,7 @@ "indexes": { "mediaFiles_projectId_idx": { "name": "mediaFiles_projectId_idx", - "columns": [ - "projectId" - ], + "columns": ["projectId"], "isUnique": false } }, @@ -321,12 +305,8 @@ "name": "mediaFiles_uploadedBy_user_id_fk", "tableFrom": "mediaFiles", "tableTo": "user", - "columnsFrom": [ - "uploadedBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["uploadedBy"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -334,12 +314,8 @@ "name": "mediaFiles_orgId_organization_id_fk", "tableFrom": "mediaFiles", "tableTo": "organization", - "columnsFrom": [ - "orgId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["orgId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -347,12 +323,8 @@ "name": "mediaFiles_projectId_projects_id_fk", "tableFrom": "mediaFiles", "tableTo": "projects", - "columnsFrom": [ - "projectId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["projectId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -405,16 +377,12 @@ "indexes": { "member_userId_idx": { "name": "member_userId_idx", - "columns": [ - "userId" - ], + "columns": ["userId"], "isUnique": false }, "member_organizationId_idx": { "name": "member_organizationId_idx", - "columns": [ - "organizationId" - ], + "columns": ["organizationId"], "isUnique": false } }, @@ -423,12 +391,8 @@ "name": "member_userId_user_id_fk", "tableFrom": "member", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -436,12 +400,8 @@ "name": "member_organizationId_organization_id_fk", "tableFrom": "member", "tableTo": "organization", - "columnsFrom": [ - "organizationId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organizationId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -521,16 +481,12 @@ "indexes": { "org_access_grants_stripeCheckoutSessionId_unique": { "name": "org_access_grants_stripeCheckoutSessionId_unique", - "columns": [ - "stripeCheckoutSessionId" - ], + "columns": ["stripeCheckoutSessionId"], "isUnique": true }, "org_access_grants_orgId_idx": { "name": "org_access_grants_orgId_idx", - "columns": [ - "orgId" - ], + "columns": ["orgId"], "isUnique": false } }, @@ -539,12 +495,8 @@ "name": "org_access_grants_orgId_organization_id_fk", "tableFrom": "org_access_grants", "tableTo": "organization", - "columnsFrom": [ - "orgId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["orgId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -603,9 +555,7 @@ "indexes": { "organization_slug_unique": { "name": "organization_slug_unique", - "columns": [ - "slug" - ], + "columns": ["slug"], "isUnique": true } }, @@ -709,16 +659,12 @@ "indexes": { "project_invitations_token_unique": { "name": "project_invitations_token_unique", - "columns": [ - "token" - ], + "columns": ["token"], "isUnique": true }, "project_invitations_projectId_idx": { "name": "project_invitations_projectId_idx", - "columns": [ - "projectId" - ], + "columns": ["projectId"], "isUnique": false } }, @@ -727,12 +673,8 @@ "name": "project_invitations_orgId_organization_id_fk", "tableFrom": "project_invitations", "tableTo": "organization", - "columnsFrom": [ - "orgId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["orgId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -740,12 +682,8 @@ "name": "project_invitations_projectId_projects_id_fk", "tableFrom": "project_invitations", "tableTo": "projects", - "columnsFrom": [ - "projectId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["projectId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -753,12 +691,8 @@ "name": "project_invitations_invitedBy_user_id_fk", "tableFrom": "project_invitations", "tableTo": "user", - "columnsFrom": [ - "invitedBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["invitedBy"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -811,16 +745,12 @@ "indexes": { "project_members_projectId_idx": { "name": "project_members_projectId_idx", - "columns": [ - "projectId" - ], + "columns": ["projectId"], "isUnique": false }, "project_members_userId_idx": { "name": "project_members_userId_idx", - "columns": [ - "userId" - ], + "columns": ["userId"], "isUnique": false } }, @@ -829,12 +759,8 @@ "name": "project_members_projectId_projects_id_fk", "tableFrom": "project_members", "tableTo": "projects", - "columnsFrom": [ - "projectId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["projectId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -842,12 +768,8 @@ "name": "project_members_userId_user_id_fk", "tableFrom": "project_members", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -914,9 +836,7 @@ "indexes": { "projects_orgId_idx": { "name": "projects_orgId_idx", - "columns": [ - "orgId" - ], + "columns": ["orgId"], "isUnique": false } }, @@ -925,12 +845,8 @@ "name": "projects_orgId_organization_id_fk", "tableFrom": "projects", "tableTo": "organization", - "columnsFrom": [ - "orgId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["orgId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -938,12 +854,8 @@ "name": "projects_createdBy_user_id_fk", "tableFrom": "projects", "tableTo": "user", - "columnsFrom": [ - "createdBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["createdBy"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1031,16 +943,12 @@ "indexes": { "session_token_unique": { "name": "session_token_unique", - "columns": [ - "token" - ], + "columns": ["token"], "isUnique": true }, "session_userId_idx": { "name": "session_userId_idx", - "columns": [ - "userId" - ], + "columns": ["userId"], "isUnique": false } }, @@ -1049,12 +957,8 @@ "name": "session_userId_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1062,12 +966,8 @@ "name": "session_impersonatedBy_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": [ - "impersonatedBy" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["impersonatedBy"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -1075,12 +975,8 @@ "name": "session_activeOrganizationId_organization_id_fk", "tableFrom": "session", "tableTo": "organization", - "columnsFrom": [ - "activeOrganizationId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["activeOrganizationId"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1230,16 +1126,12 @@ "indexes": { "stripe_event_ledger_payloadHash_unique": { "name": "stripe_event_ledger_payloadHash_unique", - "columns": [ - "payloadHash" - ], + "columns": ["payloadHash"], "isUnique": true }, "stripe_event_ledger_stripeEventId_unique": { "name": "stripe_event_ledger_stripeEventId_unique", - "columns": [ - "stripeEventId" - ], + "columns": ["stripeEventId"], "isUnique": true } }, @@ -1378,9 +1270,7 @@ "indexes": { "subscription_referenceId_idx": { "name": "subscription_referenceId_idx", - "columns": [ - "referenceId" - ], + "columns": ["referenceId"], "isUnique": false } }, @@ -1443,12 +1333,8 @@ "name": "twoFactor_userId_user_id_fk", "tableFrom": "twoFactor", "tableTo": "user", - "columnsFrom": [ - "userId" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["userId"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1665,16 +1551,12 @@ "indexes": { "user_email_unique": { "name": "user_email_unique", - "columns": [ - "email" - ], + "columns": ["email"], "isUnique": true }, "user_username_unique": { "name": "user_username_unique", - "columns": [ - "username" - ], + "columns": ["username"], "isUnique": true } }, @@ -1748,4 +1630,4 @@ "internal": { "indexes": {} } -} \ No newline at end of file +} diff --git a/packages/web/migrations/meta/_journal.json b/packages/web/migrations/meta/_journal.json index b90d8e607..d8f9b465f 100644 --- a/packages/web/migrations/meta/_journal.json +++ b/packages/web/migrations/meta/_journal.json @@ -17,4 +17,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/web/src/components/admin/AnalyticsSection.tsx b/packages/web/src/components/admin/AnalyticsSection.tsx index 084294a05..ee3bbda41 100644 --- a/packages/web/src/components/admin/AnalyticsSection.tsx +++ b/packages/web/src/components/admin/AnalyticsSection.tsx @@ -149,7 +149,7 @@ export function AnalyticsSection() { setWebhookDays(parseInt(e.target.value, 10))} - className='border-input h-8 rounded-lg border bg-transparent px-2.5 text-sm transition-colors outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3' + className='border-input focus-visible:border-ring focus-visible:ring-ring/50 h-8 rounded-lg border bg-transparent px-2.5 text-sm transition-colors outline-none focus-visible:ring-3' > diff --git a/packages/web/src/components/admin/ui/AdminDataTable.tsx b/packages/web/src/components/admin/ui/AdminDataTable.tsx index 32c9afbe7..cde8748fe 100644 --- a/packages/web/src/components/admin/ui/AdminDataTable.tsx +++ b/packages/web/src/components/admin/ui/AdminDataTable.tsx @@ -37,7 +37,6 @@ export function AdminDataTable({ }: AdminDataTableProps) { const [sorting, setSorting] = useState([]); - const table = useReactTable({ data: data || [], columns: columns || [], diff --git a/packages/web/src/components/checklist/ChecklistYjsWrapper.tsx b/packages/web/src/components/checklist/ChecklistYjsWrapper.tsx index 0e31882ab..7224e8eb2 100644 --- a/packages/web/src/components/checklist/ChecklistYjsWrapper.tsx +++ b/packages/web/src/components/checklist/ChecklistYjsWrapper.tsx @@ -96,7 +96,7 @@ export function ChecklistYjsWrapper({ projectId, studyId, checklistId }: Checkli // Auto-select primary PDF useEffect(() => { if (defaultPdf && !selectedPdfId) { - setSelectedPdfId(defaultPdf.id); + setSelectedPdfId(defaultPdf.id); } }, [defaultPdf, selectedPdfId]); @@ -105,7 +105,7 @@ export function ChecklistYjsWrapper({ projectId, studyId, checklistId }: Checkli const fileName = currentPdf?.fileName; if (!fileName || !orgId || attemptedPdfFile === fileName || pdfLoading) return; - setAttemptedPdfFile(fileName); + setAttemptedPdfFile(fileName); setPdfLoading(true); setPdfData(null); @@ -143,7 +143,6 @@ export function ChecklistYjsWrapper({ projectId, studyId, checklistId }: Checkli setAttemptedPdfFile(null); }, []); - const handlePdfChange = useCallback( async (data: ArrayBuffer, fileName: string) => { if (!orgId) { @@ -184,7 +183,6 @@ export function ChecklistYjsWrapper({ projectId, studyId, checklistId }: Checkli }, [orgId, projectId, studyId, studyPdfs, user?.id, addPdfToStudy], ); - const isChecklistValid = useMemo(() => { if (!checklistForUI) return false; diff --git a/packages/web/src/components/checklist/SplitScreenLayout.tsx b/packages/web/src/components/checklist/SplitScreenLayout.tsx index be2f86a61..1dd3f42f0 100644 --- a/packages/web/src/components/checklist/SplitScreenLayout.tsx +++ b/packages/web/src/components/checklist/SplitScreenLayout.tsx @@ -36,7 +36,7 @@ export function SplitScreenLayout({ // Sync showSecondPanel with prop changes useEffect(() => { - setShowSecondPanel(showSecondPanelProp ?? false); + setShowSecondPanel(showSecondPanelProp ?? false); }, [showSecondPanelProp]); // Extract exactly two panels from children diff --git a/packages/web/src/components/dev/DevImportProject.tsx b/packages/web/src/components/dev/DevImportProject.tsx index 6fcb136dc..650cf9fd1 100644 --- a/packages/web/src/components/dev/DevImportProject.tsx +++ b/packages/web/src/components/dev/DevImportProject.tsx @@ -97,7 +97,7 @@ export function DevImportProject() { useEffect(() => { if (orgs.length > 0 && !selectedOrgId) { - setSelectedOrgId(orgs[0].id); + setSelectedOrgId(orgs[0].id); } }, [orgs, selectedOrgId]); @@ -109,7 +109,7 @@ export function DevImportProject() { useEffect(() => { if (selectedTemplate) { const tmpl = TEMPLATES.find(t => t.name === selectedTemplate); - if (tmpl) setProjectName(tmpl.description); + if (tmpl) setProjectName(tmpl.description); } }, [selectedTemplate]); @@ -144,8 +144,7 @@ export function DevImportProject() { data: { orgId: resolvedOrgId, name: projectName.trim() }, })) as { id: string }; - const userMapping = - Object.keys(roleAssignments).length > 0 ? roleAssignments : undefined; + const userMapping = Object.keys(roleAssignments).length > 0 ? roleAssignments : undefined; const templateResult = (await applyTemplate({ data: { @@ -175,7 +174,10 @@ export function DevImportProject() { const studiesWithIds = studies.filter(s => s.doi); if (studiesWithIds.length > 0) { - setResult({ success: true, message: `Fetching references (0/${studiesWithIds.length})...` }); + setResult({ + success: true, + message: `Fetching references (0/${studiesWithIds.length})...`, + }); let fetched = 0; let pdfCount = 0; @@ -297,8 +299,7 @@ export function DevImportProject() { setResult(null); try { - const name = - (parsed.meta as Record)?.name || 'Imported Project'; + const name = (parsed.meta as Record)?.name || 'Imported Project'; const description = ((parsed.meta as Record)?.description as string) || undefined; @@ -341,9 +342,9 @@ export function DevImportProject() { const tabClass = (active: boolean) => `flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded transition-colors ${ - active - ? 'bg-purple-600 text-white' - : 'text-muted-foreground hover:text-foreground hover:bg-muted' + active ? + 'bg-purple-600 text-white' + : 'text-muted-foreground hover:text-foreground hover:bg-muted' }`; return ( @@ -548,7 +549,7 @@ function UserSearchField({ useEffect(() => { if (debouncedQuery.length < 2) { - setResults([]); + setResults([]); return; } let cancelled = false; @@ -669,9 +670,7 @@ function UserSearchField({ onClick={() => handleSelect(user)} > - - {user.name || 'Unknown'} - + {user.name || 'Unknown'} {user.email} {currentUser && user.id === currentUser.id && ( (me) diff --git a/packages/web/src/components/pdf/embedpdf/react/src/components/page-controls.tsx b/packages/web/src/components/pdf/embedpdf/react/src/components/page-controls.tsx index fc8b88a0b..5dca6db90 100644 --- a/packages/web/src/components/pdf/embedpdf/react/src/components/page-controls.tsx +++ b/packages/web/src/components/pdf/embedpdf/react/src/components/page-controls.tsx @@ -19,7 +19,7 @@ export function PageControls({ documentId }: PageControlsProps) { const [inputValue, setInputValue] = useState(currentPage.toString()); useEffect(() => { - setInputValue(currentPage.toString()); + setInputValue(currentPage.toString()); }, [currentPage]); const startHideTimer = useCallback(() => { diff --git a/packages/web/src/components/pdf/embedpdf/react/src/components/search-sidebar.tsx b/packages/web/src/components/pdf/embedpdf/react/src/components/search-sidebar.tsx index 577856c29..6d8cb94bb 100644 --- a/packages/web/src/components/pdf/embedpdf/react/src/components/search-sidebar.tsx +++ b/packages/web/src/components/pdf/embedpdf/react/src/components/search-sidebar.tsx @@ -59,7 +59,7 @@ export function SearchSidebar({ documentId, onClose }: SearchSidebarProps) { // Sync inputValue with persisted state.query when state loads useEffect(() => { - setInputValue(state.query || ''); + setInputValue(state.query || ''); }, [state.query, documentId]); useEffect(() => { diff --git a/packages/web/src/components/project/CreateProjectModal.tsx b/packages/web/src/components/project/CreateProjectModal.tsx index 2d4c0d079..c0ac7294a 100644 --- a/packages/web/src/components/project/CreateProjectModal.tsx +++ b/packages/web/src/components/project/CreateProjectModal.tsx @@ -51,7 +51,7 @@ export function CreateProjectModal({ open, onOpenChange }: CreateProjectModalPro // Auto-select first org when orgs load and user has multiple useEffect(() => { if (orgs.length > 1 && !selectedOrgId) { - setSelectedOrgId(orgs[0].id); + setSelectedOrgId(orgs[0].id); } }, [orgs, selectedOrgId]); @@ -64,7 +64,7 @@ export function CreateProjectModal({ open, onOpenChange }: CreateProjectModalPro // Reset form when dialog closes useEffect(() => { if (!open) { - setProjectName(''); + setProjectName(''); setProjectDescription(''); setSelectedOrgId(null); } diff --git a/packages/web/src/components/project/ProjectHeader.tsx b/packages/web/src/components/project/ProjectHeader.tsx index 631686e5d..89c2ffa51 100644 --- a/packages/web/src/components/project/ProjectHeader.tsx +++ b/packages/web/src/components/project/ProjectHeader.tsx @@ -39,11 +39,11 @@ export function ProjectHeader({ // Sync local state when external data loads useEffect(() => { - if (name) setLocalName(name); + if (name) setLocalName(name); }, [name]); useEffect(() => { - setLocalDescription(description || ''); + setLocalDescription(description || ''); }, [description]); const handleNameCommit = useCallback( diff --git a/packages/web/src/components/project/ProjectView.tsx b/packages/web/src/components/project/ProjectView.tsx index 6de0a6c32..a242e1d9f 100644 --- a/packages/web/src/components/project/ProjectView.tsx +++ b/packages/web/src/components/project/ProjectView.tsx @@ -95,7 +95,7 @@ function ProjectViewInner({ projectId }: ProjectViewProps) { ) return; const pdfs = pendingPdfs; - setPendingPdfs(null); + setPendingPdfs(null); for (const pdf of pdfs) { const studyName = pdf.fileName ? pdf.fileName.replace(/\.pdf$/i, '') : 'Untitled Study'; @@ -154,7 +154,7 @@ function ProjectViewInner({ projectId }: ProjectViewProps) { ) return; const refs = pendingRefs; - setPendingRefs(null); + setPendingRefs(null); for (const ref of refs) { project.study.create(ref.title, ref.metadata?.abstract || '', ref.metadata || {}); } @@ -170,7 +170,7 @@ function ProjectViewInner({ projectId }: ProjectViewProps) { ) return; const driveFiles = pendingDriveFiles; - setPendingDriveFiles(null); + setPendingDriveFiles(null); for (const file of driveFiles) { const title = file.title || file.name.replace(/\.pdf$/i, ''); const metadata = { diff --git a/packages/web/src/components/project/SlidingPanel.tsx b/packages/web/src/components/project/SlidingPanel.tsx index 31160b50e..daf2c0d60 100644 --- a/packages/web/src/components/project/SlidingPanel.tsx +++ b/packages/web/src/components/project/SlidingPanel.tsx @@ -37,7 +37,7 @@ export function SlidingPanel({ // Mount/animate lifecycle useEffect(() => { if (open) { - setMounted(true); + setMounted(true); requestAnimationFrame(() => { requestAnimationFrame(() => { setVisible(true); diff --git a/packages/web/src/components/project/add-studies/AddStudiesForm.tsx b/packages/web/src/components/project/add-studies/AddStudiesForm.tsx index b3ea9d2e5..887d718f8 100644 --- a/packages/web/src/components/project/add-studies/AddStudiesForm.tsx +++ b/packages/web/src/components/project/add-studies/AddStudiesForm.tsx @@ -10,14 +10,7 @@ */ import { useState, useEffect, useCallback, useRef } from 'react'; -import { - PlusIcon, - XIcon, - UploadIcon, - FileTextIcon, - LinkIcon, - FolderIcon, -} from 'lucide-react'; +import { PlusIcon, XIcon, UploadIcon, FileTextIcon, LinkIcon, FolderIcon } from 'lucide-react'; import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'; import { Tabs, TabsList, TabsTrigger, TabsIndicator, TabsContent } from '@/components/ui/tabs'; import { showToast } from '@/components/ui/toast'; @@ -78,7 +71,6 @@ export function AddStudiesForm({ const isExpanded = alwaysExpanded || expanded || studies.hasAnyStudies(); - const hasExistingStudiesRef = useRef(hasExistingStudies); hasExistingStudiesRef.current = hasExistingStudies; const isExpandedRef = useRef(isExpanded); @@ -87,7 +79,6 @@ export function AddStudiesForm({ isDraggingOverRef.current = isDraggingOver; const handlePdfSelectRef = useRef(studies.handlePdfSelect); handlePdfSelectRef.current = studies.handlePdfSelect; - // Restore state from OAuth redirect. // Expand unconditionally since restoreState enqueues React state updates diff --git a/packages/web/src/components/project/all-studies-tab/AssignReviewersModal.tsx b/packages/web/src/components/project/all-studies-tab/AssignReviewersModal.tsx index 40de817ec..c97e6a91a 100644 --- a/packages/web/src/components/project/all-studies-tab/AssignReviewersModal.tsx +++ b/packages/web/src/components/project/all-studies-tab/AssignReviewersModal.tsx @@ -60,14 +60,12 @@ export function AssignReviewersModal({ }); useEffect(() => { - if (open) { initializeForm(); } else { setReviewer1('_unassigned'); setReviewer2('_unassigned'); } - }, [open]); const handleSave = useCallback(async () => { diff --git a/packages/web/src/components/project/all-studies-tab/EditPdfMetadataModal.tsx b/packages/web/src/components/project/all-studies-tab/EditPdfMetadataModal.tsx index a6d023543..8b6de4c80 100644 --- a/packages/web/src/components/project/all-studies-tab/EditPdfMetadataModal.tsx +++ b/packages/web/src/components/project/all-studies-tab/EditPdfMetadataModal.tsx @@ -34,7 +34,6 @@ export function EditPdfMetadataModal({ const [saving, setSaving] = useState(false); useEffect(() => { - if (pdf && open) { setTitle(pdf.title || ''); setFirstAuthor(pdf.firstAuthor || ''); @@ -42,7 +41,6 @@ export function EditPdfMetadataModal({ setJournal(pdf.journal || ''); setDoi(pdf.doi || ''); } - }, [pdf, open]); const handleSave = useCallback(async () => { diff --git a/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.tsx b/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.tsx index 720e0f986..8c3fd8349 100644 --- a/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.tsx +++ b/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.tsx @@ -134,7 +134,7 @@ export function GoogleDrivePickerLauncher({ // Check connection status on mount useEffect(() => { if (!active) return; - checkConnectionStatus(); + checkConnectionStatus(); }, [active, checkConnectionStatus]); const handleConnectGoogle = useCallback(async () => { diff --git a/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.tsx b/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.tsx index b370956eb..5bb49ee1c 100644 --- a/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.tsx +++ b/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.tsx @@ -32,12 +32,11 @@ export function GoogleDrivePickerModal({ const [importing, setImporting] = useState(false); // Use refs for values that may change between modal open and picker callback - + const studyIdRef = useRef(studyId); studyIdRef.current = studyId; const onImportSuccessRef = useRef(onImportSuccess); onImportSuccessRef.current = onImportSuccess; - const handlePicked = useCallback( async (picked: Array<{ id: string; name: string }>, pickerStudyId?: string) => { diff --git a/packages/web/src/components/project/overview-tab/AddMemberModal.tsx b/packages/web/src/components/project/overview-tab/AddMemberModal.tsx index ba03a4a72..e563e7b44 100644 --- a/packages/web/src/components/project/overview-tab/AddMemberModal.tsx +++ b/packages/web/src/components/project/overview-tab/AddMemberModal.tsx @@ -69,7 +69,7 @@ export function AddMemberModal({ // Search users on debounced query change useEffect(() => { if (debouncedQuery.length < 2) { - setSearchResults([]); + setSearchResults([]); return; } let cancelled = false; diff --git a/packages/web/src/components/project/overview-tab/ChartSection.tsx b/packages/web/src/components/project/overview-tab/ChartSection.tsx index bf62e73b7..ed733f34d 100644 --- a/packages/web/src/components/project/overview-tab/ChartSection.tsx +++ b/packages/web/src/components/project/overview-tab/ChartSection.tsx @@ -141,7 +141,6 @@ export function ChartSection({ studies }: ChartSectionProps) { // Sync custom labels when raw data changes useEffect(() => { - setCustomLabels(prev => { const currentIds = prev.map(l => l.id).join(','); const newIds = rawChecklistData.map(d => d.id).join(','); diff --git a/packages/web/src/components/project/overview-tab/ReviewerAssignment.tsx b/packages/web/src/components/project/overview-tab/ReviewerAssignment.tsx index ccff85a90..caf112283 100644 --- a/packages/web/src/components/project/overview-tab/ReviewerAssignment.tsx +++ b/packages/web/src/components/project/overview-tab/ReviewerAssignment.tsx @@ -272,7 +272,15 @@ export function ReviewerAssignment({ } return assignments; - }, [unassignedStudies, showCustomize, isCustomValid, pool1Members, pool2Members, getMemberName, members]); + }, [ + unassignedStudies, + showCustomize, + isCustomValid, + pool1Members, + pool2Members, + getMemberName, + members, + ]); const handleGenerate = useCallback(() => { const assignments = generateAssignments(); diff --git a/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.tsx b/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.tsx index 9a17f5177..b5a06d8df 100644 --- a/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.tsx +++ b/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.tsx @@ -118,7 +118,7 @@ export function ReconciliationWrapper({ // Auto-select primary PDF when study loads useEffect(() => { if (defaultPdf && !selectedPdfId) { - setSelectedPdfId(defaultPdf.id); + setSelectedPdfId(defaultPdf.id); } }, [defaultPdf, selectedPdfId]); @@ -127,7 +127,7 @@ export function ReconciliationWrapper({ const fileName = currentPdf?.fileName; if (!fileName || !orgId || attemptedPdfFile === fileName || pdfLoading) return; - setAttemptedPdfFile(fileName); + setAttemptedPdfFile(fileName); setPdfLoading(true); setPdfData(null); @@ -255,7 +255,6 @@ export function ReconciliationWrapper({ return; } - setHasCheckedForReconciled(true); setReconciledChecklistLoading(true); @@ -319,7 +318,6 @@ export function ReconciliationWrapper({ setReconciledChecklistId(newChecklistId); setReconciledChecklistLoading(false); - }, [ currentStudy, connectionState.phase, @@ -355,7 +353,7 @@ export function ReconciliationWrapper({ checklist2Id, reconciledChecklistId: firstCreated.id, }); - setReconciledChecklistId(firstCreated.id); + setReconciledChecklistId(firstCreated.id); } } }, [ diff --git a/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/MultiPartQuestionPage.tsx b/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/MultiPartQuestionPage.tsx index 7c91deec0..8e5bb3f75 100644 --- a/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/MultiPartQuestionPage.tsx +++ b/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/MultiPartQuestionPage.tsx @@ -70,7 +70,6 @@ export function MultiPartQuestionPage({ // Reset auto-fill tracking when question changes useEffect(() => { - setHasAutoFilled(false); }, [questionKey]); @@ -83,7 +82,6 @@ export function MultiPartQuestionPage({ if (finalAnswers && typeof finalAnswers === 'object') { const hasParts = dataKeys.some((dk: string) => finalAnswers[dk]); if (hasParts) { - setLocalFinal(JSON.parse(JSON.stringify(finalAnswers))); if (multiPartEqual(finalAnswers, reviewer1Answers, dataKeys)) { setSelectedSource('reviewer1'); @@ -126,7 +124,7 @@ export function MultiPartQuestionPage({ ) { const newFinal = JSON.parse(JSON.stringify(reviewer1Answers)); onFinalChange(newFinal); - + setHasAutoFilled(true); } }, [isAgreement, finalAnswers, reviewer1Answers, dataKeys, hasAutoFilled, onFinalChange]); diff --git a/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/ReconciliationQuestionPage.tsx b/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/ReconciliationQuestionPage.tsx index a98975b2f..097ffd5ab 100644 --- a/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/ReconciliationQuestionPage.tsx +++ b/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/ReconciliationQuestionPage.tsx @@ -91,7 +91,6 @@ function SingleQuestionPage({ // Reset auto-fill tracking when question changes useEffect(() => { - setHasAutoFilled(false); }, [questionKey]); @@ -100,7 +99,6 @@ function SingleQuestionPage({ // Initialize local final from props or default to reviewer1 useEffect(() => { if (finalAnswers) { - setLocalFinal(JSON.parse(JSON.stringify(finalAnswers))); if (answersEqual(finalAnswers, reviewer1Answers)) { setSelectedSource('reviewer1'); @@ -132,7 +130,7 @@ function SingleQuestionPage({ ) { const newFinal = JSON.parse(JSON.stringify(reviewer1Answers)); onFinalChange(newFinal); - + setHasAutoFilled(true); } }, [isAgreement, finalAnswers, reviewer1Answers, hasAutoFilled, onFinalChange]); diff --git a/packages/web/src/components/project/reconcile-tab/engine/useReconciliationEngine.ts b/packages/web/src/components/project/reconcile-tab/engine/useReconciliationEngine.ts index c84727fd3..87e8790f8 100644 --- a/packages/web/src/components/project/reconcile-tab/engine/useReconciliationEngine.ts +++ b/packages/web/src/components/project/reconcile-tab/engine/useReconciliationEngine.ts @@ -103,11 +103,10 @@ export function useReconciliationEngine({ const stored = localStorage.getItem(storageKey); if (stored) { const parsed = JSON.parse(stored); - + if (typeof parsed.currentPage === 'number') setCurrentPage(parsed.currentPage); if (parsed.viewMode === 'questions' || parsed.viewMode === 'summary') setViewModeRaw(parsed.viewMode); - } } catch { // Silently ignore corrupted storage @@ -198,7 +197,7 @@ export function useReconciliationEngine({ if (totalPages === 0) return; const clamped = Math.max(0, Math.min(currentPage, totalPages - 1)); if (clamped !== currentPage) { - setCurrentPage(clamped); + setCurrentPage(clamped); } }, [totalPages, currentPage]); @@ -213,7 +212,7 @@ export function useReconciliationEngine({ if (navItems.length > 0) { const item = navItems[currentPage]; if (item?.sectionKey) { - setExpandedDomain(item.sectionKey); + setExpandedDomain(item.sectionKey); hasAutoExpandedRef.current = true; } } diff --git a/packages/web/src/components/settings/BillingSettings.tsx b/packages/web/src/components/settings/BillingSettings.tsx index a950eab1d..4e8a7fb5d 100644 --- a/packages/web/src/components/settings/BillingSettings.tsx +++ b/packages/web/src/components/settings/BillingSettings.tsx @@ -71,7 +71,7 @@ export function BillingSettings() { useEffect(() => { const params = new URLSearchParams(window.location.search); if (params.get('success') === 'true') { - setCheckoutOutcome('success'); + setCheckoutOutcome('success'); // Beat the webhook race: pull canonical subscription state from Stripe // before reading it from the DB. Failure is non-fatal — the webhook will // reconcile eventually. diff --git a/packages/web/src/components/settings/LinkedAccountsSection.tsx b/packages/web/src/components/settings/LinkedAccountsSection.tsx index c3532a421..ac2650045 100644 --- a/packages/web/src/components/settings/LinkedAccountsSection.tsx +++ b/packages/web/src/components/settings/LinkedAccountsSection.tsx @@ -68,7 +68,7 @@ export function LinkedAccountsSection() { sessionStorage.removeItem('linkingProvider'); if (oauthError.code === 'ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER') { - setMergeConflictProvider(provider); + setMergeConflictProvider(provider); setTimeout(() => setShowMergeDialog(true), 100); return; } diff --git a/packages/web/src/components/settings/MergeAccountsDialog.tsx b/packages/web/src/components/settings/MergeAccountsDialog.tsx index e9a45d626..2cfa6b339 100644 --- a/packages/web/src/components/settings/MergeAccountsDialog.tsx +++ b/packages/web/src/components/settings/MergeAccountsDialog.tsx @@ -68,7 +68,6 @@ export function MergeAccountsDialog({ // Reset on open/close useEffect(() => { - if (open) { setStep(STEPS.PROMPT); setTargetEmail(''); @@ -79,7 +78,6 @@ export function MergeAccountsDialog({ setError(null); setLoading(false); } - }, [open]); const isOrcidConflict = useMemo(() => conflictProvider === 'orcid', [conflictProvider]); diff --git a/packages/web/src/components/settings/PlansSettings.tsx b/packages/web/src/components/settings/PlansSettings.tsx index e89eadc03..321fc50d0 100644 --- a/packages/web/src/components/settings/PlansSettings.tsx +++ b/packages/web/src/components/settings/PlansSettings.tsx @@ -35,7 +35,7 @@ export function PlansSettings() { }, [navigate, refetch]); useEffect(() => { - if (hasPendingPlan()) processPendingPlan(); + if (hasPendingPlan()) processPendingPlan(); }, []); // eslint-disable-line react-hooks/exhaustive-deps if (pageState === 'error') { diff --git a/packages/web/src/components/settings/ProfileInfoSection.tsx b/packages/web/src/components/settings/ProfileInfoSection.tsx index 7d50e98e1..cf83cd1fa 100644 --- a/packages/web/src/components/settings/ProfileInfoSection.tsx +++ b/packages/web/src/components/settings/ProfileInfoSection.tsx @@ -36,7 +36,6 @@ export function ProfileInfoSection() { return name.split(' ')[0] || ''; }, [user?.givenName, user?.name]); - const lastName = useMemo(() => { if (user?.familyName) return user.familyName as string; const name = (user?.name as string) || ''; diff --git a/packages/web/src/components/settings/SecuritySettings.tsx b/packages/web/src/components/settings/SecuritySettings.tsx index ea50fe37c..a7d00cd4e 100644 --- a/packages/web/src/components/settings/SecuritySettings.tsx +++ b/packages/web/src/components/settings/SecuritySettings.tsx @@ -63,7 +63,6 @@ export function SecuritySettings() { [currentPassword, newPassword, confirmPassword, unmetRequirements, changePassword], ); - const handleSendPasswordSetup = useCallback(async () => { setAddPasswordLoading(true); setPasswordError(''); diff --git a/packages/web/src/config/sentry.ts b/packages/web/src/config/sentry.ts index 3f83ddaf6..1b09ff17a 100644 --- a/packages/web/src/config/sentry.ts +++ b/packages/web/src/config/sentry.ts @@ -36,9 +36,7 @@ export function initSentry(): void { // Called from router.tsx where the instance is available. export function initSentryRouterTracing(router: Router): void { if (!SENTRY_DSN || !Sentry.getClient()) return; - Sentry.addIntegration( - Sentry.tanstackRouterBrowserTracingIntegration(router as never), - ); + Sentry.addIntegration(Sentry.tanstackRouterBrowserTracingIntegration(router as never)); } export function setSentryUser(user: { id: string; email?: string; name?: string } | null): void { diff --git a/packages/web/src/hooks/useReconciliationPresence.ts b/packages/web/src/hooks/useReconciliationPresence.ts index b502d8ea8..0870aca8b 100644 --- a/packages/web/src/hooks/useReconciliationPresence.ts +++ b/packages/web/src/hooks/useReconciliationPresence.ts @@ -132,14 +132,13 @@ export function useReconciliationPresence({ const [refreshTick, setRefreshTick] = useState(0); // Refs to avoid stale closures in event handlers - + const currentPageRef = useRef(getCurrentPage); currentPageRef.current = getCurrentPage; const checklistTypeRef = useRef(checklistType); checklistTypeRef.current = checklistType; const currentUserRef = useRef(currentUser); currentUserRef.current = currentUser; - // Periodic refresh for stale cursor detection useEffect(() => { @@ -193,7 +192,7 @@ export function useReconciliationPresence({ x, y, scrollY, - + timestamp: Date.now(), }, }); diff --git a/packages/web/src/hooks/useYText.ts b/packages/web/src/hooks/useYText.ts index 05da83820..aff043ade 100644 --- a/packages/web/src/hooks/useYText.ts +++ b/packages/web/src/hooks/useYText.ts @@ -10,7 +10,6 @@ export function useYText(yText: Y.Text | null): string { useEffect(() => { if (!yText) { - setValue(''); return; } diff --git a/packages/web/src/project/__tests__/studyActions.test.ts b/packages/web/src/project/__tests__/studyActions.test.ts index 822364f27..82edb8de6 100644 --- a/packages/web/src/project/__tests__/studyActions.test.ts +++ b/packages/web/src/project/__tests__/studyActions.test.ts @@ -28,7 +28,12 @@ vi.mock('@/api/google-drive', () => ({ importFromGoogleDrive: vi.fn().mockResolvedValue({ success: true, id: 'media-1', - file: { key: 'projects/proj-1/studies/study-1/Witt2019.pdf', fileName: 'Witt2019.pdf', size: 1024, source: 'google-drive' }, + file: { + key: 'projects/proj-1/studies/study-1/Witt2019.pdf', + fileName: 'Witt2019.pdf', + size: 1024, + source: 'google-drive', + }, }), })); @@ -42,7 +47,11 @@ vi.mock('@/api/pdf-api', () => ({ vi.mock('@/lib/pdfUtils.js', () => ({ extractPdfTitle: vi.fn().mockResolvedValue(null), extractPdfDoi: vi.fn().mockResolvedValue(null), - normalizeTitle: (t: string) => t?.toLowerCase().replace(/[^\w\s]/g, '').trim() ?? '', + normalizeTitle: (t: string) => + t + ?.toLowerCase() + .replace(/[^\w\s]/g, '') + .trim() ?? '', })); vi.mock('@/lib/referenceLookup.js', () => ({ @@ -164,7 +173,9 @@ describe('studyActions.addBatch', () => { ]); expect(uploadPdf).toHaveBeenCalledWith( - 'org-1', 'proj-1', 'study-1', + 'org-1', + 'proj-1', + 'study-1', expect.any(ArrayBuffer), 'Local.pdf', ); @@ -175,7 +186,9 @@ describe('studyActions.addBatch', () => { mockCreateStudy .mockReturnValueOnce('s1') .mockReturnValueOnce('s2') - .mockImplementationOnce(() => { throw new Error('boom'); }) + .mockImplementationOnce(() => { + throw new Error('boom'); + }) .mockReturnValueOnce('s4'); const result = await studyActions.addBatch([ diff --git a/packages/web/src/project/actions/pdfs.ts b/packages/web/src/project/actions/pdfs.ts index 29fa3ca70..e18fc51f1 100644 --- a/packages/web/src/project/actions/pdfs.ts +++ b/packages/web/src/project/actions/pdfs.ts @@ -115,8 +115,7 @@ export const pdfActions = { throw new Error('No active project connection'); } - const study = - getProjectAtoms(projectId).getOrCreateStudyAtom(studyId).get() ?? null; + const study = getProjectAtoms(projectId).getOrCreateStudyAtom(studyId).get() ?? null; const existingPdf = study?.pdfs.find(p => p.fileName === file.name); if (existingPdf) { throw new Error(`File "${file.name}" already exists. Rename or remove the existing copy.`); @@ -249,8 +248,7 @@ export const pdfActions = { if (!studyId || !file) return; if (!projectId || !orgId || !ops) throw new Error('No active project connection'); - const study = - getProjectAtoms(projectId).getOrCreateStudyAtom(studyId).get() ?? null; + const study = getProjectAtoms(projectId).getOrCreateStudyAtom(studyId).get() ?? null; const hasPdfs = (study?.pdfs.length ?? 0) > 0; const effectiveTag = !hasPdfs ? 'primary' : tag; diff --git a/packages/web/src/project/actions/studies.ts b/packages/web/src/project/actions/studies.ts index d3d5edf20..1ef2a764c 100644 --- a/packages/web/src/project/actions/studies.ts +++ b/packages/web/src/project/actions/studies.ts @@ -344,9 +344,7 @@ export const studyActions = { }; const studyName = getStudyNameFromFilename( - (study.pdfFileName as string) || - (study.googleDriveFileName as string) || - null, + (study.pdfFileName as string) || (study.googleDriveFileName as string) || null, ); const studyId = ops.study.createStudy( studyName, diff --git a/packages/web/src/routes/__root.tsx b/packages/web/src/routes/__root.tsx index 2325db24e..85992473d 100644 --- a/packages/web/src/routes/__root.tsx +++ b/packages/web/src/routes/__root.tsx @@ -85,7 +85,7 @@ export const Route = createRootRoute({ : "connect-src 'self' wss://corates.org https://api.crossref.org https://eutils.ncbi.nlm.nih.gov https://api.unpaywall.org https://plausible.jacobmaynard.dev https://*.ingest.us.sentry.io", "worker-src 'self' blob:", "font-src 'self'", - "frame-src https://docs.google.com", + 'frame-src https://docs.google.com', "frame-ancestors 'none'", "form-action 'self'", "base-uri 'self'", diff --git a/packages/web/src/routes/_app/_protected/admin/billing.ledger.tsx b/packages/web/src/routes/_app/_protected/admin/billing.ledger.tsx index 74de35046..aa2d64f0b 100644 --- a/packages/web/src/routes/_app/_protected/admin/billing.ledger.tsx +++ b/packages/web/src/routes/_app/_protected/admin/billing.ledger.tsx @@ -323,7 +323,7 @@ function AdminBillingLedgerPage() { setLimit(parseInt(e.target.value, 10))} - className='border-input mt-1 block h-8 w-full rounded-lg border bg-transparent px-2.5 text-sm transition-colors outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3' + className='border-input focus-visible:border-ring focus-visible:ring-ring/50 mt-1 block h-8 w-full rounded-lg border bg-transparent px-2.5 text-sm transition-colors outline-none focus-visible:ring-3' > {LIMIT_OPTIONS.map(opt => ( {orgs.map(org => ( diff --git a/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx b/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx index 3030cfba4..65b475c2a 100644 --- a/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx +++ b/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx @@ -446,11 +446,15 @@ function UserDetailContent() {
Created
-
{formatDateTime(userData.user.createdAt)}
+
+ {formatDateTime(userData.user.createdAt)} +
Updated
-
{formatDateTime(userData.user.updatedAt)}
+
+ {formatDateTime(userData.user.updatedAt)} +
Stripe Customer
diff --git a/packages/web/src/routes/_auth/check-email.tsx b/packages/web/src/routes/_auth/check-email.tsx index b19c90f0f..11162690f 100644 --- a/packages/web/src/routes/_auth/check-email.tsx +++ b/packages/web/src/routes/_auth/check-email.tsx @@ -68,7 +68,7 @@ function CheckEmailPage() { // Set up polling and visibility change listener useEffect(() => { intervalRef.current = setInterval(() => checkVerificationStatus(true), POLL_INTERVAL_MS); - checkVerificationStatus(true); + checkVerificationStatus(true); const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { diff --git a/packages/web/src/routes/_auth/complete-profile.tsx b/packages/web/src/routes/_auth/complete-profile.tsx index c7b8533b1..f1333b637 100644 --- a/packages/web/src/routes/_auth/complete-profile.tsx +++ b/packages/web/src/routes/_auth/complete-profile.tsx @@ -105,7 +105,7 @@ function CompleteProfilePage() { if (!user && !hasEditedName && !hasAutofilledName) { const pendingName = localStorage.getItem('pendingName'); if (!firstName.trim() && !lastName.trim() && pendingName) { - setFirstName(pendingName); + setFirstName(pendingName); setHasAutofilledName(true); } } @@ -149,7 +149,7 @@ function CompleteProfilePage() { useEffect(() => { const pendingPersona = localStorage.getItem('pendingPersona'); if (pendingPersona) { - setPersona(pendingPersona); + setPersona(pendingPersona); localStorage.removeItem('pendingPersona'); } }, []); diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 7f16e5d36..eaf71956b 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -13,9 +13,8 @@ let startFetch: ((req: Request, opts?: never) => Response | Promise) | async function getStartFetch() { if (!startFetch) { - const { createStartHandler, defaultStreamHandler } = await import( - '@tanstack/react-start/server' - ); + const { createStartHandler, defaultStreamHandler } = + await import('@tanstack/react-start/server'); startFetch = createStartHandler(defaultStreamHandler); } return startFetch!; diff --git a/packages/web/src/server/functions/admin-orgs.server.ts b/packages/web/src/server/functions/admin-orgs.server.ts index c4bb0e151..803ca17c4 100644 --- a/packages/web/src/server/functions/admin-orgs.server.ts +++ b/packages/web/src/server/functions/admin-orgs.server.ts @@ -82,7 +82,10 @@ async function dispatchSubscriptionNotify( failed: result.failed, }); } catch (err) { - captureError(err, { tags: { component: 'admin-orgs', action: 'subscription-notify' }, extra: { orgId, subscriptionAction: action } }); + captureError(err, { + tags: { component: 'admin-orgs', action: 'subscription-notify' }, + extra: { orgId, subscriptionAction: action }, + }); } } diff --git a/packages/web/src/server/functions/admin-projects.server.ts b/packages/web/src/server/functions/admin-projects.server.ts index 87000be32..3ff12c5a9 100644 --- a/packages/web/src/server/functions/admin-projects.server.ts +++ b/packages/web/src/server/functions/admin-projects.server.ts @@ -295,10 +295,7 @@ export async function deleteAdminProject(session: Session, db: Database, project export async function wakeAllProjectDOs(session: Session, db: Database) { assertAdmin(session); - const allProjects = await db - .select({ id: projects.id }) - .from(projects) - .all(); + const allProjects = await db.select({ id: projects.id }).from(projects).all(); const batchSize = 10; let succeeded = 0; @@ -308,7 +305,7 @@ export async function wakeAllProjectDOs(session: Session, db: Database) { for (let i = 0; i < allProjects.length; i += batchSize) { const batch = allProjects.slice(i, i + batchSize); const results = await Promise.allSettled( - batch.map(async (p) => { + batch.map(async p => { const stub = getProjectDocStub(env, p.id); await stub.getProjectInfo(); return p.id; diff --git a/packages/web/src/server/functions/billing.server.ts b/packages/web/src/server/functions/billing.server.ts index 8aadfad98..8e03e1d74 100644 --- a/packages/web/src/server/functions/billing.server.ts +++ b/packages/web/src/server/functions/billing.server.ts @@ -134,7 +134,9 @@ export async function validateCoupon(code: string) { } if (!isStripeConfigured(env)) { - captureError(new Error('validate_coupon_failed: Stripe not configured'), { tags: { component: 'billing', action: 'validate-coupon' } }); + captureError(new Error('validate_coupon_failed: Stripe not configured'), { + tags: { component: 'billing', action: 'validate-coupon' }, + }); return { valid: false as const, error: 'Payment system not available' }; } diff --git a/packages/web/src/server/functions/users.server.ts b/packages/web/src/server/functions/users.server.ts index ec9c52bd6..f5cc6bf9e 100644 --- a/packages/web/src/server/functions/users.server.ts +++ b/packages/web/src/server/functions/users.server.ts @@ -224,7 +224,10 @@ export async function syncProfile(db: Database, session: Session) { }); return { projectId, success: true }; } catch (err) { - captureError(err, { tags: { component: 'users', action: 'sync-profile' }, extra: { projectId } }); + captureError(err, { + tags: { component: 'users', action: 'sync-profile' }, + extra: { projectId }, + }); return { projectId, success: false }; } }), diff --git a/packages/web/src/stores/projectAtoms.ts b/packages/web/src/stores/projectAtoms.ts index 1e33d8d08..c38d8754b 100644 --- a/packages/web/src/stores/projectAtoms.ts +++ b/packages/web/src/stores/projectAtoms.ts @@ -106,11 +106,14 @@ export function useProjectMembers(projectId: string): MemberEntry[] { export function useAllStudies(projectId: string): StudyInfo[] { const atoms = getProjectAtoms(projectId); - return useValue('allStudies:' + projectId, () => { - return atoms.studyOrder.get().flatMap(id => { - const study = atoms.getOrCreateStudyAtom(id).get(); - return study ? [study] : []; - }); - }, [atoms]); + return useValue( + 'allStudies:' + projectId, + () => { + return atoms.studyOrder.get().flatMap(id => { + const study = atoms.getOrCreateStudyAtom(id).get(); + return study ? [study] : []; + }); + }, + [atoms], + ); } - diff --git a/packages/workers/src/commands/invitations/acceptInvitation.ts b/packages/workers/src/commands/invitations/acceptInvitation.ts index 0231e43d9..940595729 100644 --- a/packages/workers/src/commands/invitations/acceptInvitation.ts +++ b/packages/workers/src/commands/invitations/acceptInvitation.ts @@ -107,7 +107,10 @@ export async function acceptInvitation( const normalizedInvitationEmail = (invitation.email || '').trim().toLowerCase(); if (normalizedUserEmail !== normalizedInvitationEmail) { - warn('Invitation email mismatch: user=%s, invitation=%s', [currentUser.email || '', invitation.email || '']); + warn('Invitation email mismatch: user=%s, invitation=%s', [ + currentUser.email || '', + invitation.email || '', + ]); throw createDomainError(AUTH_ERRORS.FORBIDDEN, { reason: 'email_mismatch', userEmail: currentUser.email, @@ -257,7 +260,10 @@ export async function acceptInvitation( image: currentUser.image, }); } catch (err) { - captureError(err, { tags: { component: 'invitation', action: 'accept-do-sync' }, extra: { projectId: invitation.projectId } }); + captureError(err, { + tags: { component: 'invitation', action: 'accept-do-sync' }, + extra: { projectId: invitation.projectId }, + }); } return { diff --git a/packages/workers/src/commands/invitations/createInvitation.ts b/packages/workers/src/commands/invitations/createInvitation.ts index 1337f9d1a..a6aa59da5 100644 --- a/packages/workers/src/commands/invitations/createInvitation.ts +++ b/packages/workers/src/commands/invitations/createInvitation.ts @@ -126,7 +126,10 @@ export async function createInvitation( }); emailQueued = result.emailQueued; } catch (err) { - captureError(err, { tags: { component: 'invitation', action: 'magic-link-generation' }, extra: { projectId } }); + captureError(err, { + tags: { component: 'invitation', action: 'magic-link-generation' }, + extra: { projectId }, + }); } return { invitationId, emailQueued }; diff --git a/packages/workers/src/commands/members/addMember.ts b/packages/workers/src/commands/members/addMember.ts index 23c991744..fcbad367a 100644 --- a/packages/workers/src/commands/members/addMember.ts +++ b/packages/workers/src/commands/members/addMember.ts @@ -144,7 +144,10 @@ export async function addMember( role, }); } catch (err) { - captureError(err, { tags: { component: 'member', action: 'add-notify' }, extra: { projectId } }); + captureError(err, { + tags: { component: 'member', action: 'add-notify' }, + extra: { projectId }, + }); } // Sync member to DO with automatic retry diff --git a/packages/workers/src/commands/members/removeMember.ts b/packages/workers/src/commands/members/removeMember.ts index abd10aac5..d0db49426 100644 --- a/packages/workers/src/commands/members/removeMember.ts +++ b/packages/workers/src/commands/members/removeMember.ts @@ -73,7 +73,10 @@ export async function removeMember( removedBy: actor.name || actor.email || 'Unknown', }); } catch (err) { - captureError(err, { tags: { component: 'member', action: 'remove-notify' }, extra: { projectId, userId } }); + captureError(err, { + tags: { component: 'member', action: 'remove-notify' }, + extra: { projectId, userId }, + }); } } diff --git a/packages/workers/src/commands/members/updateMemberRole.ts b/packages/workers/src/commands/members/updateMemberRole.ts index 11a1d5ad6..adedd1c70 100644 --- a/packages/workers/src/commands/members/updateMemberRole.ts +++ b/packages/workers/src/commands/members/updateMemberRole.ts @@ -65,7 +65,10 @@ export async function updateMemberRole( role, }); } catch (err) { - captureError(err, { tags: { component: 'member', action: 'role-update-notify' }, extra: { projectId, userId } }); + captureError(err, { + tags: { component: 'member', action: 'role-update-notify' }, + extra: { projectId, userId }, + }); } return { userId, role }; diff --git a/packages/workers/src/commands/projects/createProject.ts b/packages/workers/src/commands/projects/createProject.ts index fac44eda8..5d18cbbce 100644 --- a/packages/workers/src/commands/projects/createProject.ts +++ b/packages/workers/src/commands/projects/createProject.ts @@ -134,7 +134,10 @@ export async function createProject( ], ); } catch (err) { - captureError(err, { tags: { component: 'project', action: 'create-do-sync' }, extra: { projectId } }); + captureError(err, { + tags: { component: 'project', action: 'create-do-sync' }, + extra: { projectId }, + }); } return { diff --git a/packages/workers/src/commands/projects/deleteProject.ts b/packages/workers/src/commands/projects/deleteProject.ts index 6bdf87851..fb4a48b4a 100644 --- a/packages/workers/src/commands/projects/deleteProject.ts +++ b/packages/workers/src/commands/projects/deleteProject.ts @@ -51,14 +51,20 @@ export async function deleteProject( try { await disconnectAllFromProject(env, projectId); } catch (err) { - captureError(err, { tags: { component: 'project', action: 'delete-disconnect' }, extra: { projectId } }); + captureError(err, { + tags: { component: 'project', action: 'delete-disconnect' }, + extra: { projectId }, + }); } // Clean up all PDFs from R2 storage try { await cleanupProjectStorage(env, projectId); } catch (err) { - captureError(err, { tags: { component: 'project', action: 'delete-r2-cleanup' }, extra: { projectId } }); + captureError(err, { + tags: { component: 'project', action: 'delete-r2-cleanup' }, + extra: { projectId }, + }); } try { diff --git a/packages/workers/src/commands/projects/updateProject.ts b/packages/workers/src/commands/projects/updateProject.ts index 3cb8100ea..116ee5662 100644 --- a/packages/workers/src/commands/projects/updateProject.ts +++ b/packages/workers/src/commands/projects/updateProject.ts @@ -63,7 +63,10 @@ export async function updateProject( try { await syncProjectToDO(env, projectId, metaUpdate, null); } catch (err) { - captureError(err, { tags: { component: 'project', action: 'update-do-sync' }, extra: { projectId } }); + captureError(err, { + tags: { component: 'project', action: 'update-do-sync' }, + extra: { projectId }, + }); } return { projectId, updated: true }; diff --git a/packages/workers/src/durable-objects/ProjectDoc.ts b/packages/workers/src/durable-objects/ProjectDoc.ts index 1f1deaff3..054a8baa1 100644 --- a/packages/workers/src/durable-objects/ProjectDoc.ts +++ b/packages/workers/src/durable-objects/ProjectDoc.ts @@ -459,8 +459,12 @@ class ProjectDocBase extends DurableObject { async getStorageStats(): Promise { await this.initializeDoc(); - const { snapshot: snapshotRows, update: updateRows, snapshotBytes, updateBytes } = - this.persistence.getRowBreakdown(); + const { + snapshot: snapshotRows, + update: updateRows, + snapshotBytes, + updateBytes, + } = this.persistence.getRowBreakdown(); const { oldestRowAt, newestRowAt } = this.persistence.getTimestamps(); const encodedSnapshotBytes = Y.encodeStateAsUpdate(this.doc!).byteLength; diff --git a/packages/workers/src/durable-objects/ProjectDocPersistence.ts b/packages/workers/src/durable-objects/ProjectDocPersistence.ts index 3d7311b00..6b6572880 100644 --- a/packages/workers/src/durable-objects/ProjectDocPersistence.ts +++ b/packages/workers/src/durable-objects/ProjectDocPersistence.ts @@ -177,9 +177,7 @@ export class ProjectDocPersistence { if (this.rowCount < 2) return; const hasUpdates = this.ctx.storage.sql - .exec<{ n: number }>( - `SELECT COUNT(*) AS n FROM yjs_updates WHERE kind = 'update' LIMIT 1`, - ) + .exec<{ n: number }>(`SELECT COUNT(*) AS n FROM yjs_updates WHERE kind = 'update' LIMIT 1`) .one(); if (hasUpdates.n === 0) return; @@ -210,7 +208,12 @@ export class ProjectDocPersistence { return this.rowCount; } - getRowBreakdown(): { snapshot: number; update: number; snapshotBytes: number; updateBytes: number } { + getRowBreakdown(): { + snapshot: number; + update: number; + snapshotBytes: number; + updateBytes: number; + } { const breakdown = this.ctx.storage.sql .exec<{ kind: string; n: number; bytes: number }>( `SELECT kind, COUNT(*) AS n, COALESCE(SUM(LENGTH(payload)), 0) AS bytes diff --git a/packages/workers/src/durable-objects/dev-handlers.ts b/packages/workers/src/durable-objects/dev-handlers.ts index 84fc8ef5a..181735b5c 100644 --- a/packages/workers/src/durable-objects/dev-handlers.ts +++ b/packages/workers/src/durable-objects/dev-handlers.ts @@ -95,7 +95,7 @@ interface ImportData { authors?: unknown; journal?: unknown; doi?: unknown; - abstract?: unknown; + abstract?: unknown; pdfUrl?: unknown; pdfSource?: unknown; pdfAccessible?: unknown; @@ -1023,8 +1023,7 @@ export async function handleDevApplyTemplate(ctx: DevContext, request: Request): const importResponse = await handleDevImport(ctx, fakeRequest); const importResult = (await importResponse.json()) as Record; - return new Response( - JSON.stringify({ ...importResult, studies: studyIdentifiers }), - { headers: { 'Content-Type': 'application/json' } }, - ); + return new Response(JSON.stringify({ ...importResult, studies: studyIdentifiers }), { + headers: { 'Content-Type': 'application/json' }, + }); } diff --git a/packages/workers/src/lib/logger.ts b/packages/workers/src/lib/logger.ts index a40d0891e..47801a050 100644 --- a/packages/workers/src/lib/logger.ts +++ b/packages/workers/src/lib/logger.ts @@ -13,12 +13,22 @@ export function captureError(error: unknown, context?: ErrorContext): void { } export function warn(message: string, params?: LogParams): void { - console.warn(message, ...(Array.isArray(params) ? params : params ? [params] : [])); + console.warn( + message, + ...(Array.isArray(params) ? params + : params ? [params] + : []), + ); Sentry.logger.warn(message, toAttributes(params)); } export function info(message: string, params?: LogParams): void { - console.info(message, ...(Array.isArray(params) ? params : params ? [params] : [])); + console.info( + message, + ...(Array.isArray(params) ? params + : params ? [params] + : []), + ); Sentry.logger.info(message, toAttributes(params)); } diff --git a/packages/workers/src/lib/mock-templates.ts b/packages/workers/src/lib/mock-templates.ts index 2e5883d64..d60abc01a 100644 --- a/packages/workers/src/lib/mock-templates.ts +++ b/packages/workers/src/lib/mock-templates.ts @@ -552,7 +552,8 @@ const MOCK_TEMPLATES: Record = { description: '', createdAt: now, updatedAt: now, - originalTitle: 'The PRISMA 2020 statement: an updated guideline for reporting systematic reviews', + originalTitle: + 'The PRISMA 2020 statement: an updated guideline for reporting systematic reviews', firstAuthor: 'Page', publicationYear: 2021, authors: 'Page MJ, McKenzie JE, Bossuyt PM, Boutron I, Hoffmann TC, Mulrow CD, et al.', @@ -625,7 +626,8 @@ const MOCK_TEMPLATES: Record = { description: '', createdAt: now, updatedAt: now, - originalTitle: 'The PRISMA 2020 statement: an updated guideline for reporting systematic reviews', + originalTitle: + 'The PRISMA 2020 statement: an updated guideline for reporting systematic reviews', firstAuthor: 'Page', publicationYear: 2021, authors: 'Page MJ, McKenzie JE, Bossuyt PM, Boutron I, Hoffmann TC, Mulrow CD, et al.', @@ -759,7 +761,8 @@ const MOCK_TEMPLATES: Record = { description: '', createdAt: now, updatedAt: now, - originalTitle: 'The PRISMA 2020 statement: an updated guideline for reporting systematic reviews', + originalTitle: + 'The PRISMA 2020 statement: an updated guideline for reporting systematic reviews', firstAuthor: 'Page', publicationYear: 2021, authors: 'Page MJ, McKenzie JE, Bossuyt PM, Boutron I, Hoffmann TC, Mulrow CD, et al.', @@ -850,7 +853,8 @@ const MOCK_TEMPLATES: Record = { description: '', createdAt: now, updatedAt: now, - originalTitle: 'The PRISMA 2020 statement: an updated guideline for reporting systematic reviews', + originalTitle: + 'The PRISMA 2020 statement: an updated guideline for reporting systematic reviews', firstAuthor: 'Page', publicationYear: 2021, authors: 'Page MJ, McKenzie JE, Bossuyt PM, Boutron I, Hoffmann TC, Mulrow CD, et al.', @@ -915,7 +919,8 @@ const MOCK_TEMPLATES: Record = { description: '', createdAt: now, updatedAt: now, - originalTitle: 'The Cochrane Collaboration\'s tool for assessing risk of bias in randomised trials', + originalTitle: + "The Cochrane Collaboration's tool for assessing risk of bias in randomised trials", firstAuthor: 'Higgins', publicationYear: 2011, authors: 'Higgins JPT, Altman DG, Gotzsche PC, Juni P, Moher D, Oxman AD, et al.', diff --git a/packages/workers/src/lib/retry.ts b/packages/workers/src/lib/retry.ts index 19949c625..0f637da6f 100644 --- a/packages/workers/src/lib/retry.ts +++ b/packages/workers/src/lib/retry.ts @@ -66,7 +66,9 @@ export async function withRetry(options: RetryOptions): Promise Date: Fri, 1 May 2026 03:12:47 -0500 Subject: [PATCH 07/10] add some ci checks on pr --- .github/workflows/ci.yml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 811fe9b7c..7c8b59840 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,8 @@ name: CI - Deploy & Test on: push: branches: [main] + pull_request: + branches: [main] # Cancel in-progress runs on the same branch concurrency: @@ -52,7 +54,42 @@ concurrency: # --------------------------------------------------------------- jobs: + checks: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build workspace dependencies + run: pnpm --filter @corates/shared --filter @corates/db build + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + - name: Unit tests + run: pnpm test + deploy-and-test: + if: github.event_name == 'push' + needs: checks runs-on: ubuntu-latest permissions: contents: read From 1d4b4b0f5d719af25bee4997cd5d1e8141efe1c3 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Fri, 1 May 2026 03:16:52 -0500 Subject: [PATCH 08/10] add some react lint rules back and fix google drive picker --- eslint.config.js | 1 + .../google-drive/GoogleDrivePickerModal.tsx | 55 ++++++++----------- 2 files changed, 25 insertions(+), 31 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 1a6778840..4c33720ad 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -321,6 +321,7 @@ export default [ rules: { 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn', + 'react-hooks/set-state-in-render': 'error', }, }, { diff --git a/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.tsx b/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.tsx index 5bb49ee1c..1339a6bf0 100644 --- a/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.tsx +++ b/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.tsx @@ -2,7 +2,7 @@ * GoogleDrivePickerModal - Modal for selecting PDFs from Google Drive (single-study import) */ -import { useState, useCallback, useRef } from 'react'; +import { useState } from 'react'; import { showToast } from '@/components/ui/toast'; import { Dialog, @@ -31,38 +31,31 @@ export function GoogleDrivePickerModal({ }: GoogleDrivePickerModalProps) { const [importing, setImporting] = useState(false); - // Use refs for values that may change between modal open and picker callback + async function handlePicked( + picked: Array<{ id: string; name: string }>, + pickerStudyId?: string, + ) { + const file = picked?.[0]; + if (!file) return; - const studyIdRef = useRef(studyId); - studyIdRef.current = studyId; - const onImportSuccessRef = useRef(onImportSuccess); - onImportSuccessRef.current = onImportSuccess; + const targetStudyId = pickerStudyId || studyId; + if (!targetStudyId) return; - const handlePicked = useCallback( - async (picked: Array<{ id: string; name: string }>, pickerStudyId?: string) => { - const file = picked?.[0]; - if (!file) return; - - const targetStudyId = pickerStudyId || studyIdRef.current; - if (!targetStudyId) return; - - try { - setImporting(true); - const result = await importFromGoogleDrive(file.id, projectId, targetStudyId); - showToast.success( - 'PDF Imported', - `Successfully imported "${file.name}" from Google Drive.`, - ); - onImportSuccessRef.current?.(result.file, targetStudyId); - } catch (err: unknown) { - const { handleError } = await import('@/lib/error-utils'); - await handleError(err, { toastTitle: 'Import Failed' }); - } finally { - setImporting(false); - } - }, - [projectId], - ); + try { + setImporting(true); + const result = await importFromGoogleDrive(file.id, projectId, targetStudyId); + showToast.success( + 'PDF Imported', + `Successfully imported "${file.name}" from Google Drive.`, + ); + onImportSuccess?.(result.file, targetStudyId); + } catch (err: unknown) { + const { handleError } = await import('@/lib/error-utils'); + await handleError(err, { toastTitle: 'Import Failed' }); + } finally { + setImporting(false); + } + } return ( !openState && onClose()}> From 5b83877d23d5a32cc4cb78aadb00c7421dd93738 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Fri, 1 May 2026 03:17:14 -0500 Subject: [PATCH 09/10] remove yjs migration doc --- packages/docs/audits/yjs-reactive-hooks.md | 211 --------------------- 1 file changed, 211 deletions(-) delete mode 100644 packages/docs/audits/yjs-reactive-hooks.md diff --git a/packages/docs/audits/yjs-reactive-hooks.md b/packages/docs/audits/yjs-reactive-hooks.md deleted file mode 100644 index a672e1f88..000000000 --- a/packages/docs/audits/yjs-reactive-hooks.md +++ /dev/null @@ -1,211 +0,0 @@ -Reactive Yjs Hooks Prototype -- @tldraw/state - -Status: Proposal -Last updated: 2026-04-30 - -Problem - -The sync manager (sync.ts) already does per-study dirty tracking via studyCache. -But the final step -- sortedStudies = [...studyCache.values()] -- creates a new -array every sync, which replaces the entire studies array in Zustand via immer. -Every component that reads any study re-renders, even if their specific study -didn't change. Forms re-initialize, modals lose state, selectors fire for -unrelated updates. - -This is a structural problem, not a bug-by-bug fix. The Zustand array is the -wrong data structure for per-entity collaborative state. - -Approach - -Use @tldraw/state (npm package, ~3,800 lines, only depends on @tldraw/utils) as -the reactive primitive layer between Yjs and React. Keep Yjs for sync/CRDT. Keep -Zustand for non-collaborative UI state (tab selection, modal open/close, etc). - -@tldraw/state provides: - -- atom(name, value, options?) -- mutable cell with equality-gated set() -- computed(name, fn) -- derived value that only recomputes when dependencies change -- AtomMap -- reactive Map> with per-key subscriptions -- useValue(atom) -- React hook via useSyncExternalStore, re-renders only when - the atom's value actually changes (by equality check) -- transact(fn) -- batch multiple atom writes into one notification pass - -The key property: atom.set(newValue) is a no-op if newValue equals the current -value (shallow equality by default, configurable). This is the missing gate that -studyCache already computes but Zustand discards. - -Architecture - -Y.Doc (Yjs) -| -| observe / observeDeep -v -Sync Manager (sync.ts) -| -| atom.set(serializedStudy) <-- equality gate here -v -AtomMap <-- one atom per study -| -| useValue(atom) <-- React subscribes to individual atoms -v -React components <-- only re-render when THEIR study changes - -Zustand stays for: - Connection lifecycle state - UI state (active tab, modal open/close, selection) - Non-collaborative derived state (project stats, preferences) - -Zustand does NOT keep: - Project metadata (name, settings) -- same referential instability problem - Members list -- same problem, just less frequent - Studies -- the primary motivation for this work - -All collaborative data from Y.Doc goes through atoms. This avoids having -two read patterns for the same class of data. - -What to prototype - -Phase 1: StudyAtomMap + useStudy hook - -Install @tldraw/state and @tldraw/state-react. - -Create a StudyAtomMap in the sync manager: - - const studyAtoms = new AtomMap() - -In handleReviewsEvents, instead of rebuilding the full array and calling -setProjectData, write individual atoms: - - for (const [studyId, study] of studyCache) { - if (dirtyStudyIds.has(studyId)) { - studyAtoms.set(studyId, study) - } - } - -Expose a React hook: - - function useStudy(studyId: string): StudyInfo | undefined { - const atom = studyAtoms.getAtom(studyId) - return useValue(atom) - } - -Expose a study order atom for list rendering: - - const studyOrder = atom('studyOrder', []) - - function useStudyIds(): string[] { - return useValue(studyOrder) - } - -List components use useStudyIds() to get the array of IDs, then each row -uses useStudy(id). Adding/removing studies changes the order atom. Editing -a study's fields only touches that study's atom -- other rows don't re-render. - -Phase 1b: Meta and members atoms - -Apply the same pattern to project metadata and members. These have the same -referential instability problem as studies -- just triggered less often. Since -the atom infrastructure exists from Phase 1, this is trivial: - - const projectMeta = atom('projectMeta', defaultMeta) - const membersAtom = atom('members', []) - -This ensures all collaborative Y.Doc data flows through one read pattern -(atoms + useValue), with Zustand reserved for genuinely non-collaborative -state. - -Phase 2: Snapshot isolation hook - -Even with per-study atoms solving most re-render problems, same-entity -conflicts still need isolation. If two users are both editing study-5's -reviewers simultaneously, the atom for study-5 will update from the remote -peer. A modal editing that same study needs to capture-and-hold during the -editing session. - -Atoms reduce the blast radius (unrelated studies no longer trigger it), but -same-entity remote updates still can. Generalize the useEffectEvent pattern -from the reviewer modal fix: - - function useSnapshotValue(liveValue: T, isEditing: boolean): T { - const snapshotRef = useRef(liveValue); - if (!isEditing) snapshotRef.current = liveValue; - return isEditing ? snapshotRef.current : liveValue; - } - -Every modal/form that edits collaborative data uses useSnapshotValue with -the atom-backed live value. The atom provides referential stability across -unrelated syncs; the snapshot provides isolation from same-entity syncs -during edits. - -Phase 3: Computed selectors - -Replace derived Zustand selectors with computed(): - - const studiesForTab = computed('studiesForTab', () => { - return studyOrder.get() - .map(id => studyAtoms.get(id)) - .filter(s => matchesTab(s, activeTab)) - }) - -These only recompute when their input atoms change. If a study that isn't -in the current tab gets updated, the tab's computed doesn't fire. - -Phase 4: Migrate one component end-to-end - -Pick ChecklistYjsWrapper or AllStudiesTab. Replace the Zustand -selectStudies selector with useStudy / useStudyIds. Verify: - - - Opening a modal and editing doesn't get interrupted by unrelated syncs - - Assigning reviewers in the modal survives background Y.Doc updates - - The component only re-renders when its specific study changes - - No regressions in E2E tests - -Phase 5: Evaluate and expand - -If Phase 3 validates the approach: - - - Migrate remaining study consumers - - Consider AtomMaps for checklist answers (useChecklistAnswers currently - observes the entire reviews Y.Map -- same broad-observer problem) - - Remove studies array from Zustand projectStore - - Delete the reactive-yjs-hooks-plan.md predecessor doc - -What NOT to prototype - -- Don't replace Yjs sync -- keep y-websocket / Durable Object sync as-is -- Don't build a custom sync protocol -- Yjs CRDTs work fine for this use case -- Don't remove Zustand -- it stays for connection lifecycle, UI state, and - non-collaborative derived state. All collaborative Y.Doc data moves to atoms. -- Don't vendor / fork @tldraw/state yet -- use the npm package first, only - vendor if the dependency becomes a problem -- Don't add tldraw's HistoryBuffer or rollback transactions -- not needed - for this use case - -Dependencies - -@tldraw/state (4.5.10) -- core atoms, computed, transactions -@tldraw/state-react (4.5.10) -- useValue, useComputed, useAtom hooks -@tldraw/utils (4.5.10) -- transitive dep, 5 utility functions - -All are MIT licensed. Combined footprint is ~4,400 lines. No other transitive -dependencies. - -Risk assessment - -Low risk: - Additive change -- new hooks alongside existing Zustand, migrate gradually - @tldraw/state is well-tested, used in production by tldraw - Existing E2E tests validate behavior, not implementation - -Medium risk: - Two state systems during migration (atoms + Zustand) -- need clear ownership -boundaries per data type - -Watch for: - Interaction between tldraw's transact() and Yjs transactions - Whether @tldraw/state-react's useValue plays well with React Compiler -- -useValue uses useSyncExternalStore (which the compiler handles fine) but -tldraw may wrap it with patterns the compiler doesn't optimize well. -Worth a 10-minute check: install the packages, write a test component, -run the compiler, see what it emits. - -Success criteria - -- AssignReviewersModal no longer needs useEffectEvent guard (the underlying - atom doesn't change reference when an unrelated study syncs) -- useChecklistAnswers uses the study atom for reading finalized state (study - status, reviewer assignments), but retains direct Y.Doc access for the live - editing path (Y.Text instances for collaborative checklist editing). Both - read patterns coexist -- atoms for serialized state, direct Yjs for live - collaborative types -- Opening a modal during active collaboration doesn't re-initialize form state -- No increase in total re-render count (measure with React DevTools profiler) -- All existing E2E tests pass without modification From 20fe102170c068ca38d4a2857174d61c1f2e1143 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 1 May 2026 08:17:55 +0000 Subject: [PATCH 10/10] Apply Prettier formatting --- .../project/google-drive/GoogleDrivePickerModal.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.tsx b/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.tsx index 1339a6bf0..34d31d682 100644 --- a/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.tsx +++ b/packages/web/src/components/project/google-drive/GoogleDrivePickerModal.tsx @@ -31,10 +31,7 @@ export function GoogleDrivePickerModal({ }: GoogleDrivePickerModalProps) { const [importing, setImporting] = useState(false); - async function handlePicked( - picked: Array<{ id: string; name: string }>, - pickerStudyId?: string, - ) { + async function handlePicked(picked: Array<{ id: string; name: string }>, pickerStudyId?: string) { const file = picked?.[0]; if (!file) return; @@ -44,10 +41,7 @@ export function GoogleDrivePickerModal({ try { setImporting(true); const result = await importFromGoogleDrive(file.id, projectId, targetStudyId); - showToast.success( - 'PDF Imported', - `Successfully imported "${file.name}" from Google Drive.`, - ); + showToast.success('PDF Imported', `Successfully imported "${file.name}" from Google Drive.`); onImportSuccess?.(result.file, targetStudyId); } catch (err: unknown) { const { handleError } = await import('@/lib/error-utils');