diff --git a/package-lock.json b/package-lock.json index b539189..996238b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ }, "devDependencies": { "@comapeo/core": "7.4.0", - "@comapeo/ipc": "9.0.0", + "@comapeo/ipc": "9.0.1", "@eslint/js": "10.0.1", "@ianvs/prettier-plugin-sort-imports": "4.7.1", "@mapeo/crypto": "1.1.0", @@ -41,6 +41,7 @@ "ky": "2.0.2", "lint-staged": "17.1.1", "npm-run-all2": "9.0.2", + "p-event": "6.0.1", "prettier": "3.9.6", "random-access-memory": "6.2.1", "react": "19.2.8", @@ -548,9 +549,9 @@ } }, "node_modules/@comapeo/ipc": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@comapeo/ipc/-/ipc-9.0.0.tgz", - "integrity": "sha512-zAoZnj4eZqDaYnYzhaFmCVh2eYnYJtSl8OY0Mzxfb5q4/4IP373lVx4Fa43t+tfBD7/WquUaOBWHarUUGq1eeA==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@comapeo/ipc/-/ipc-9.0.1.tgz", + "integrity": "sha512-9cTbxkvQyz6Pnk7R2WYXMOKLW6jg8HSpTDWDuv8cx0b4JI+MKET85g0lZqT2T2VN/vrqPFqfTg4HqrTVjfdNzQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2488,9 +2489,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2508,9 +2506,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2528,9 +2523,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2548,9 +2540,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2568,9 +2557,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2588,9 +2574,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7002,9 +6985,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7026,9 +7006,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7050,9 +7027,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7074,9 +7048,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7794,6 +7765,8 @@ }, "node_modules/p-event": { "version": "6.0.1", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-6.0.1.tgz", + "integrity": "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index ef637a5..33b8ab8 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ }, "devDependencies": { "@comapeo/core": "7.4.0", - "@comapeo/ipc": "9.0.0", + "@comapeo/ipc": "9.0.1", "@eslint/js": "10.0.1", "@ianvs/prettier-plugin-sort-imports": "4.7.1", "@mapeo/crypto": "1.1.0", @@ -102,6 +102,7 @@ "ky": "2.0.2", "lint-staged": "17.1.1", "npm-run-all2": "9.0.2", + "p-event": "6.0.1", "prettier": "3.9.6", "random-access-memory": "6.2.1", "react": "19.2.8", diff --git a/src/hooks/invites.ts b/src/hooks/invites.ts index 925aeaa..945dea4 100644 --- a/src/hooks/invites.ts +++ b/src/hooks/invites.ts @@ -13,6 +13,7 @@ import { getInvitesByIdQueryKey, getInvitesQueryKey, getMembersQueryKey, + getProjectByIdQueryKey, getProjectsQueryKey, } from '../lib/react-query.js' import { useClientApi } from './client.js' @@ -93,10 +94,19 @@ export function useAcceptInvite() { mutationFn: async ({ inviteId }: { inviteId: string }) => { return clientApi.invite.accept({ inviteId }) }, - onSuccess: () => { + onSuccess: (projectId) => { queryClient.invalidateQueries({ queryKey: getInvitesQueryKey(), }) + // Accepting an invite (re-)adds the project on the backend, which + // closes any project instance that was open before the invite (e.g. + // after leaving the project) and opens a fresh one. The project + // client is cached with staleTime/gcTime Infinity, so drop it here + // or every observer keeps using the closed instance. + queryClient.removeQueries({ + queryKey: getProjectByIdQueryKey({ projectId }), + exact: true, + }) queryClient.invalidateQueries({ queryKey: getProjectsQueryKey(), }) diff --git a/test/helpers/ipc.ts b/test/helpers/ipc.ts index 204d756..761b69c 100644 --- a/test/helpers/ipc.ts +++ b/test/helpers/ipc.ts @@ -51,6 +51,7 @@ export function setupCoreIpc() { port2, server, client, + manager, fastifyController, cleanup: async () => { server.close() diff --git a/test/hooks/invite-rejoin.test.ts b/test/hooks/invite-rejoin.test.ts new file mode 100644 index 0000000..3cb1584 --- /dev/null +++ b/test/hooks/invite-rejoin.test.ts @@ -0,0 +1,202 @@ +// @vitest-environment node +import '../helpers/jsdom-setup.js' + +import { QueryClient } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { pEvent } from 'p-event' +import { assert, test } from 'vitest' + +import { + useAcceptInvite, + useLeaveProject, + useProjectSettings, + useSingleProject, +} from '../../src/index.js' +import { setupCoreIpc } from '../helpers/ipc.js' +import { createWrapper } from '../helpers/react.js' + +const MEMBER_ROLE_ID = '012fd2d431c0bf60' +const BLOCKED_ROLE_ID = '9e6d29263cba36c9' + +type Managers = Array['manager']> + +function connectPeers(managers: Managers) { + let requestedDisconnect = false + for (const manager of managers) { + manager.startLocalPeerDiscoveryServer().then(({ name, port }) => { + if (requestedDisconnect) return + for (const otherManager of managers) { + if (otherManager === manager) continue + otherManager.connectLocalPeer({ address: '127.0.0.1', name, port }) + } + }) + } + return async () => { + requestedDisconnect = true + await Promise.all( + managers.map((manager) => + manager.stopLocalPeerDiscoveryServer({ force: true }), + ), + ) + } +} + +async function waitForPeers(managers: Managers) { + const deviceIds = new Set(managers.map((m) => m.deviceId)) + const isDone = async () => { + for (const manager of managers) { + const unconnected = new Set(deviceIds) + unconnected.delete(manager.deviceId) + for (const peer of await manager.listLocalPeers()) { + if (peer.status === 'connected') unconnected.delete(peer.deviceId) + } + if (unconnected.size > 0) return false + } + return true + } + while (!(await isDone())) { + await new Promise((res) => setTimeout(res, 50)) + } +} + +// Regression test for digidem/comapeo-mobile#2042 and #2041: a member is +// removed from a project, leaves it, and is re-invited. Accepting the new +// invite closes the old project instance on the manager +// (`MapeoManager.addProject`) and opens a fresh one. The project client +// wrapper is cached with `staleTime: Infinity`, so without invalidation the +// hooks keep using the closed instance and every project call rejects with +// ProjectClosed until app restart. +test( + 're-joining a project after leaving yields a working project instance', + { timeout: 60_000 }, + async (t) => { + const invitor = setupCoreIpc() + const invitee = setupCoreIpc() + + t.onTestFinished(async () => { + await Promise.all([invitor.cleanup(), invitee.cleanup()]) + }) + + await invitor.manager.setDeviceInfo({ + name: 'invitor', + deviceType: 'desktop', + }) + await invitee.manager.setDeviceInfo({ + name: 'invitee', + deviceType: 'mobile', + }) + + const disconnect = connectPeers([invitor.manager, invitee.manager]) + t.onTestFinished(disconnect) + await waitForPeers([invitor.manager, invitee.manager]) + + const projectId = await invitor.manager.createProject({ name: 'mapeo' }) + const invitorProject = await invitor.manager.getProject(projectId) + + const queryClient = new QueryClient() + const wrapper = createWrapper({ clientApi: invitee.client, queryClient }) + + async function inviteAndAccept() { + const invitePromise = pEvent(invitee.manager.invite, 'invite-received') + const inviteSettled = invitorProject.$member.invite( + invitee.manager.deviceId, + { roleId: MEMBER_ROLE_ID }, + ) + const { inviteId } = await invitePromise + const acceptHook = renderHook(() => useAcceptInvite(), { wrapper }) + act(() => { + acceptHook.result.current.mutate({ inviteId }) + }) + await waitFor( + () => { + assert.strictEqual( + acceptHook.result.current.status, + 'success', + `accept failed: ${acceptHook.result.current.error?.stack}`, + ) + }, + { timeout: 10_000 }, + ) + await inviteSettled + acceptHook.unmount() + } + + await inviteAndAccept() + + // Simulates app screens using the project after joining + const projectHook = renderHook( + ({ projectId }) => useSingleProject({ projectId }), + { wrapper, initialProps: { projectId } }, + ) + await waitFor(() => { + assert.isNotNull(projectHook.result.current) + assert.ok(projectHook.result.current.data) + }) + const originalWrapper = projectHook.result.current.data + + // Invitor removes the member + await invitorProject.$member.assignRole( + invitee.manager.deviceId, + BLOCKED_ROLE_ID, + ) + + // Wait for the role change to sync to the invitee (the app listens for + // this via `own-role-change` and shows the "removed from project" sheet) + await waitFor( + async () => { + const role = await originalWrapper.$getOwnRole() + assert.strictEqual(role.roleId, BLOCKED_ROLE_ID) + }, + { timeout: 10_000 }, + ) + + // The app unmounts the removed project's screens before leaving + projectHook.unmount() + + const leaveHook = renderHook(() => useLeaveProject(), { wrapper }) + act(() => { + leaveHook.result.current.mutate({ projectId }) + }) + await waitFor(() => { + assert.strictEqual(leaveHook.result.current.status, 'success') + }) + leaveHook.unmount() + + // Invitor re-invites, invitee accepts. Accepting re-adds the project: + // the manager closes the stale project instance and opens a fresh one. + await inviteAndAccept() + + // Simulates the app navigating (back) into the project after re-joining: + // the project provider and its dependent screens mount together, so a + // stale cached project client would be handed to the dependent queries + // synchronously (digidem/comapeo-mobile#2041's fatal ProjectClosed). + const rejoinedProjectHook = renderHook( + ({ projectId }) => useSingleProject({ projectId }), + { wrapper, initialProps: { projectId } }, + ) + const settingsHook = renderHook( + ({ projectId }) => useProjectSettings({ projectId }), + { wrapper, initialProps: { projectId } }, + ) + await waitFor(() => { + assert.isNotNull(rejoinedProjectHook.result.current) + assert.ok(rejoinedProjectHook.result.current.data) + }) + await waitFor( + () => { + assert.isNotNull(settingsHook.result.current) + assert.isNull(settingsHook.result.current.error) + assert.ok(settingsHook.result.current.data) + }, + { timeout: 10_000 }, + ) + assert.strictEqual(settingsHook.result.current.data.name, 'mapeo') + + // The re-joined project must be a fresh instance — calls on the wrapper + // cached before the re-join reject because that instance is closed. + assert.notStrictEqual( + rejoinedProjectHook.result.current.data, + originalWrapper, + ) + }, +)