Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 7 additions & 34 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
12 changes: 11 additions & 1 deletion src/hooks/invites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getInvitesByIdQueryKey,
getInvitesQueryKey,
getMembersQueryKey,
getProjectByIdQueryKey,
getProjectsQueryKey,
} from '../lib/react-query.js'
import { useClientApi } from './client.js'
Expand Down Expand Up @@ -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,
})
Comment on lines +101 to +109

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To clarify, is the issue more specific to having gcTime: Inifinity? From my understanding of the docs here, the existing invalidation of all project queries just below should already account for queries using staleTime: Infinity due to inexact query matching:

set staleTime to Infinity to never trigger a refetch until the Query is invalidated manually.

'static' and Infinity both prevent staleness-based refetches, but 'static' is stricter: queryClient.invalidateQueries() can invalidate a query with staleTime: Infinity, but has no effect on staleTime: 'static'. refetchOnMount, refetchOnWindowFocus, and refetchOnReconnect set to "always" are also blocked by 'static'. Use 'static' for data that cannot change while the app is running: feature flags fetched at boot, user permissions loaded at login, static reference tables. Use Infinity when you still want manual invalidation to work.

Or is this tangential to what you're describing?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah this detail about the difference between removeQueries and invalidateQueries maybe helps a bit:

Unlike invalidateQueries or refetchQueries, removeQueries removes matching queries from the cache instead of refetching them.

So I guess the existing invalidation is triggering a refetch of the project instance, but maybe the refetch is returning a value that is referentially equivalent to the stale instance, and thus the observers are still using the stale one? Can't fully remember how IPC handles getProject calls for existing project instances.

Could be way off here...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the issue is that regardless of the gcTime and staleTime, react-query will serve the "stale" reference first, while it refetches the new reference, and that's what causes our errors. We could stop caching anything in react-query, but that would increase IPC traffic because getProject() does a round-trip check that the project is not closed for every call. However I think this is all getting rather messy to I'm considering dropping the changes to comapeo-ipc which made it "instance aware" at the expense of not being able to simply run the core e2e tests in core-react-native (the e2e tests as written require full lifecycle open -> close control)

queryClient.invalidateQueries({
queryKey: getProjectsQueryKey(),
})
Expand Down
1 change: 1 addition & 0 deletions test/helpers/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function setupCoreIpc() {
port2,
server,
client,
manager,
fastifyController,
cleanup: async () => {
server.close()
Expand Down
202 changes: 202 additions & 0 deletions test/hooks/invite-rejoin.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Comment thread
RangerMauve marked this conversation as resolved.
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<ReturnType<typeof setupCoreIpc>['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.
Comment on lines +65 to +68

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar question about precision of the root cause of the issue. My understanding is that it's more about the gcTime setting than the staleTime setting.

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,
)
},
)
Loading