-
Notifications
You must be signed in to change notification settings - Fork 1
fix: drop cached project instance on rejoin #199
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c9dc997
227737c
8fac2ec
5112fb1
faf3897
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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' | ||
|
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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| ) | ||
| }, | ||
| ) | ||
There was a problem hiding this comment.
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 usingstaleTime: Infinitydue to inexact query matching:Or is this tangential to what you're describing?
There was a problem hiding this comment.
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:
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...
There was a problem hiding this comment.
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)