diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0239362d..d7b5a9f3 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 811fe9b7..7c8b5984 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 diff --git a/eslint.config.js b/eslint.config.js index ce5dde52..4c33720a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -319,7 +319,9 @@ export default [ }, }, rules: { - ...reactHooks.configs.recommended.rules, + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + 'react-hooks/set-state-in-render': 'error', }, }, { diff --git a/packages/web/e2e/amstar2-workflow.spec.ts b/packages/web/e2e/amstar2-workflow.spec.ts index ddf3a477..d7832d3d 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 7fa6804b..57c93454 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 2dc1db6f..f9f4d441 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 dfde7e15..2c6a4642 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 1b30cb95..16030e2c 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 c0fa0cd5..f9a7a1b0 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 30b240ce..0a4739f4 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 b90d8e60..d8f9b465 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/package.json b/packages/web/package.json index 971d3bb7..d3deb42d 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/AnalyticsSection.tsx b/packages/web/src/components/admin/AnalyticsSection.tsx index 084294a0..ee3bbda4 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 94e1a060..cde8748f 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([]); - // 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 fab98ab3..7224e8eb 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,6 @@ 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 +183,6 @@ 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 9e9ac72a..1dd3f42f 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/dashboard/LocalAppraisalsSection.tsx b/packages/web/src/components/dashboard/LocalAppraisalsSection.tsx index cf7b7942..523f2a5f 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/DevImportProject.tsx b/packages/web/src/components/dev/DevImportProject.tsx index a0fe434a..650cf9fd 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]); @@ -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([]); // eslint-disable-line react-hooks/set-state-in-effect -- clearing results when query is too short + 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/dev/DevPanel.tsx b/packages/web/src/components/dev/DevPanel.tsx index e7401423..61f2c433 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 aec71695..b788c1e5 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 87f03c1c..f231b0be 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/pdf/embedpdf/react/src/components/page-controls.tsx b/packages/web/src/components/pdf/embedpdf/react/src/components/page-controls.tsx index aaf3c243..5dca6db9 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 9adc6d03..6d8cb94b 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 f47b681b..c0ac7294 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 8b15ff9e..0e7916a2 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 a23806b8..89c2ffa5 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 f139d7f0..a242e1d9 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) @@ -99,7 +95,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 +154,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 +170,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 6469910b..daf2c0d6 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 ee8e92fe..887d718f 100644 --- a/packages/web/src/components/project/add-studies/AddStudiesForm.tsx +++ b/packages/web/src/components/project/add-studies/AddStudiesForm.tsx @@ -10,18 +10,11 @@ */ 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'; -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,15 +65,12 @@ 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(); - /* 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 +79,6 @@ 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 40be441d..8b986daf 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, - selectMembers, - selectConnectionPhase, -} from '@/stores/projectStore'; +import { useProjectStore, selectConnectionPhase } from '@/stores/projectStore'; import type { StudyInfo } from '@/stores/projectStore'; +import { useStudyIds, useAllStudies, useProjectMembers } from '@/stores/projectAtoms'; import { project } from '@/project'; import { useProjectContext } from '../ProjectContext'; import { @@ -38,10 +34,11 @@ export function AllStudiesTab() { const [showReviewersModal, setShowReviewersModal] = useState(false); const [editingStudy, setEditingStudy] = useState(null); - const studies = useProjectStore(s => selectStudies(s, projectId)); - const members = useProjectStore(s => selectMembers(s, projectId)); + const studyIds = useStudyIds(projectId); + const members = useProjectMembers(projectId); + const studies = useAllStudies(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 +79,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 +148,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 9c695df7..c97e6a91 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,12 @@ 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 eb84166e..8b6de4c8 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(() => { - /* 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 +41,6 @@ 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 f42c4ca3..8babb0d2 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/completed-tab/CompletedTab.tsx b/packages/web/src/components/project/completed-tab/CompletedTab.tsx index ff86c6d5..78aa5092 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/google-drive/GoogleDrivePickerLauncher.tsx b/packages/web/src/components/project/google-drive/GoogleDrivePickerLauncher.tsx index 41852487..8c3fd834 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 d75e6536..34d31d68 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,39 +31,25 @@ export function GoogleDrivePickerModal({ }: GoogleDrivePickerModalProps) { 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 */ + async function handlePicked(picked: Array<{ id: string; name: string }>, pickerStudyId?: string) { + const file = picked?.[0]; + if (!file) return; - const handlePicked = useCallback( - async (picked: Array<{ id: string; name: string }>, pickerStudyId?: string) => { - const file = picked?.[0]; - if (!file) return; + const targetStudyId = pickerStudyId || studyId; + if (!targetStudyId) 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()}> diff --git a/packages/web/src/components/project/outcomes/OutcomeManager.tsx b/packages/web/src/components/project/outcomes/OutcomeManager.tsx index 3528e2fb..b7c6d83a 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/overview-tab/AddMemberModal.tsx b/packages/web/src/components/project/overview-tab/AddMemberModal.tsx index a040fb87..e563e7b4 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 f657f715..ed733f34 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(() => { - // 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/overview-tab/OverviewTab.tsx b/packages/web/src/components/project/overview-tab/OverviewTab.tsx index 0dbcd27c..a49ecc5e 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/overview-tab/ReviewerAssignment.tsx b/packages/web/src/components/project/overview-tab/ReviewerAssignment.tsx index ccff85a9..caf11228 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/ReconcileTab.tsx b/packages/web/src/components/project/reconcile-tab/ReconcileTab.tsx index 3b3c3cb3..3a3355eb 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/reconcile-tab/ReconciliationWrapper.tsx b/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.tsx index aab97ebf..b5a06d8d 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,6 @@ export function ReconciliationWrapper({ return; } - /* eslint-disable react-hooks/set-state-in-effect -- one-time reconciled checklist initialization */ setHasCheckedForReconciled(true); setReconciledChecklistLoading(true); @@ -324,7 +318,6 @@ export function ReconciliationWrapper({ setReconciledChecklistId(newChecklistId); setReconciledChecklistLoading(false); - /* eslint-enable react-hooks/set-state-in-effect */ }, [ currentStudy, connectionState.phase, @@ -360,7 +353,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 59984d15..8e5bb3f7 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(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- reset on question change 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) { - // 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 +124,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 776b499c..097ffd5a 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(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- reset on question change setHasAutoFilled(false); }, [questionKey]); @@ -100,7 +99,6 @@ 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 +130,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 f1e880af..87e8790f 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); - /* 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 +197,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 +212,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/project/todo-tab/ChecklistForm.tsx b/packages/web/src/components/project/todo-tab/ChecklistForm.tsx index 1ba9b1c7..912e0d00 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/ToDoTab.tsx b/packages/web/src/components/project/todo-tab/ToDoTab.tsx index 935d8ee7..82aa4fc3 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/components/project/todo-tab/TodoStudyRow.tsx b/packages/web/src/components/project/todo-tab/TodoStudyRow.tsx index c99eb28f..dad4db2b 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/components/settings/BillingSettings.tsx b/packages/web/src/components/settings/BillingSettings.tsx index 919b4e8a..4e8a7fb5 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 db7d677e..ac265004 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 97ccac6d..2cfa6b33 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(() => { - /* eslint-disable react-hooks/set-state-in-effect -- resetting form on dialog open */ if (open) { setStep(STEPS.PROMPT); setTargetEmail(''); @@ -79,7 +78,6 @@ 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 871af12e..321fc50d 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 3ed83b75..cf83cd1f 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]); - // 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 13ba7c66..a7d00cd4 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], ); - // 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/config/sentry.ts b/packages/web/src/config/sentry.ts index 3f83ddaf..1b09ff17 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/useProjectData.ts b/packages/web/src/hooks/useProjectData.ts index e1b2665a..4a70a01f 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 c187fb80..6dd1059a 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/hooks/useReconciliationPresence.ts b/packages/web/src/hooks/useReconciliationPresence.ts index c1ddae4c..0870aca8 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 - /* 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 +192,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/useSnapshotValue.ts b/packages/web/src/hooks/useSnapshotValue.ts new file mode 100644 index 00000000..6568752a --- /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/hooks/useYText.ts b/packages/web/src/hooks/useYText.ts index f43e1bf8..aff043ad 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) { - // 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/__tests__/projectStore.test.ts b/packages/web/src/primitives/__tests__/projectStore.test.ts index 92915ab9..0a8dff55 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/checklists/useChecklistViewModel.ts b/packages/web/src/primitives/useProject/checklists/useChecklistViewModel.ts index 535fdac7..416e4045 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/studies.ts b/packages/web/src/primitives/useProject/studies.ts index 538b1921..ee551afa 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 fc1eff3a..e70abf57 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,8 @@ 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'; import { CHECKLIST_STATUS } from '@corates/shared/checklists'; @@ -30,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)[] = []; @@ -167,21 +170,26 @@ export function createSyncManager(projectId: string, getYDoc: () => Y.Doc | null dirtySlices.members = false; dirtySlices.meta = false; - if ( - updates.studies !== undefined || - updates.members !== undefined || - updates.meta !== undefined - ) { - useProjectStore.getState().setProjectData(projectId, updates); + 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); + } + if (updates.meta !== undefined) { + projectAtoms.meta.set(updates.meta); } } function scheduleSync(): void { if (pendingSync) return; pendingSync = true; - requestAnimationFrame(() => { + rafId = requestAnimationFrame(() => { + rafId = null; pendingSync = false; - doSync(); + if (!detached) doSync(); }); } @@ -227,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(); @@ -237,6 +251,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/project/__tests__/studyActions.test.ts b/packages/web/src/project/__tests__/studyActions.test.ts index 822364f2..82edb8de 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 fbe1a953..e18fc51f 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'; @@ -115,8 +115,7 @@ export const pdfActions = { throw new Error('No active project connection'); } - const study = - useProjectStore.getState().projects[projectId]?.studies.find(s => s.id === studyId) ?? 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 = - useProjectStore.getState().projects[projectId]?.studies.find(s => s.id === studyId) ?? 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 7414c1d3..1ef2a764 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) { @@ -345,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 2325db24..85992473 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 74de3504..aa2d64f0 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 3030cfba..65b475c2 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 eaef2f6c..11162690 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 bf4455ae..f1333b63 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/server.ts b/packages/web/src/server.ts index 19c187a4..eaf71956 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,25 @@ 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 +76,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 { diff --git a/packages/web/src/server/functions/admin-orgs.server.ts b/packages/web/src/server/functions/admin-orgs.server.ts index c4bb0e15..803ca17c 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 87000be3..3ff12c5a 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 8aadfad9..8e03e1d7 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 ec9c52bd..f5cc6bf9 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 new file mode 100644 index 00000000..c38d8754 --- /dev/null +++ b/packages/web/src/stores/projectAtoms.ts @@ -0,0 +1,119 @@ +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); +} + +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], + ); +} diff --git a/packages/web/src/stores/projectStore.ts b/packages/web/src/stores/projectStore.ts index 8b7f3de4..6415b810 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; -} diff --git a/packages/workers/src/commands/invitations/acceptInvitation.ts b/packages/workers/src/commands/invitations/acceptInvitation.ts index 0231e43d..94059572 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 1337f9d1..a6aa59da 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 23c99174..fcbad367 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 abd10aac..d0db4942 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 11a1d5ad..adedd1c7 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 fac44eda..5d18cbbc 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 6bdf8785..fb4a48b4 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 3cb8100e..116ee566 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 1f1deaff..054a8baa 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 3d7311b0..6b657288 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 84fc8ef5..181735b5 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 a40d0891..47801a05 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 2e5883d6..d60abc01 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 19949c62..0f637da6 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= 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