From 9f381485dc0df40ddedebce29723e44d52ea205a Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 31 Jul 2026 20:45:51 +0300 Subject: [PATCH] dispose superseded bundles on aborted login and signup `createAccount` and `login` abandoned the factory's returned bundle when a newer task had already aborted them. The factories return an ARMED bundle, so that session kept auto-refreshing and rotating refresh tokens server-side for an account the app was no longer tracking; for signup the void-fired post-signup writes kept its agent live too. Dispose in both abort branches, matching `resumeSession`. Also document why disposal of a replaced bundle is deferred to the post-commit effect rather than done inline. Co-Authored-By: Claude Fable 5 --- .../session/__tests__/provider-abort-test.tsx | 133 ++++++++++++++++++ src/state/session/index.tsx | 11 ++ 2 files changed, 144 insertions(+) create mode 100644 src/state/session/__tests__/provider-abort-test.tsx diff --git a/src/state/session/__tests__/provider-abort-test.tsx b/src/state/session/__tests__/provider-abort-test.tsx new file mode 100644 index 0000000000..0bf5c08f21 --- /dev/null +++ b/src/state/session/__tests__/provider-abort-test.tsx @@ -0,0 +1,133 @@ +import {describe, expect, it, jest} from '@jest/globals' +import {act, render} from '@testing-library/react-native' + +/* + * The provider pulls the whole app shell in through `#/state/util` and the + * account factories. These mocks cut the tree back to the session lifecycle + * itself, which is all these tests drive. + */ +jest.mock('#/state/persisted', () => { + const { + defaults, + }: typeof import('#/state/persisted/schema') = require('#/state/persisted/schema') + return { + defaults, + get: (key: keyof typeof defaults) => defaults[key], + write: () => Promise.resolve(), + readLatest: (key: keyof typeof defaults) => defaults[key], + onUpdate: () => () => {}, + } +}) +jest.mock('#/state/util', () => ({useCloseAllActiveElements: () => () => {}})) +jest.mock('#/components/dialogs/Context', () => ({ + useGlobalDialogsControlContext: () => ({signinDialogControl: {open() {}}}), +})) +jest.mock('#/analytics', () => ({ + AnalyticsContext: ({children}: {children: React.ReactNode}) => children, + useAnalyticsBase: () => ({metric() {}, logger: {debug() {}, error() {}}}), + utils: {accountToSessionMetadata: () => ({}), useMeta: () => undefined}, +})) +jest.mock('#/state/shell/onboarding', () => ({ + useOnboardingDispatch: () => () => {}, +})) +jest.mock('#/ageAssurance/data', () => ({ + clearAgeAssuranceServerDataForAll: () => {}, + clearAgeAssuranceServerDataForDid: () => {}, +})) +jest.mock('#/lib/persisted-query-storage', () => ({ + clearPersistedQueryStorage: () => Promise.resolve(), +})) +jest.mock('#/lib/notifications/notifications', () => ({ + unregisterPushToken: () => Promise.resolve(), +})) +jest.mock('jwt-decode', () => ({jwtDecode: () => ({})})) + +/* + * The factories are stubbed so a test controls exactly when each one resolves, + * which is what lets a second call abort the first while it is in flight. + * `disposeBundle` is spied on rather than replaced wholesale: the rest of + * session-core stays real so the provider's own module graph is unchanged. + */ +const mockLogin = jest.fn<(...args: unknown[]) => Promise>() +const mockCreateAccount = jest.fn<(...args: unknown[]) => Promise>() +const mockDisposeBundle = jest.fn() +jest.mock('../session-core', () => ({ + ...jest.requireActual('../session-core'), + createSessionBundleAndLogin: (...args: unknown[]) => mockLogin(...args), + disposeBundle: (bundle: unknown) => mockDisposeBundle(bundle), +})) +jest.mock('../create-account', () => ({ + createSessionBundleAndCreateAccount: (...args: unknown[]) => + mockCreateAccount(...args), +})) + +import {Provider, useSessionApi} from '#/state/session' +import {type SessionApiContext} from '#/state/session/types' + +/** Render the provider and hand back its api context. */ +function renderProvider(): SessionApiContext { + let api!: SessionApiContext + function Probe() { + api = useSessionApi() + return null + } + render( + + + , + ) + return api +} + +/* + * Every factory returns an ARMED bundle, so a call whose result is thrown away + * because a newer call superseded it must dispose that bundle. Leaving it armed + * leaves a live session auto-refreshing and rotating refresh tokens server-side + * for an account the app is no longer tracking. + */ +describe('superseded session tasks dispose their bundle', () => { + it('disposes the bundle of an aborted login', async () => { + const bundle = {} as never + let resolveLogin!: (value: unknown) => void + mockLogin.mockReturnValueOnce( + new Promise(resolve => { + resolveLogin = resolve + }), + ) + const api = renderProvider() + + const superseded = api.login({} as never, 'LoginForm') + /* the second call aborts the first task's signal, and never settles */ + mockLogin.mockReturnValueOnce(new Promise(() => {})) + void api.login({} as never, 'LoginForm') + + await act(async () => { + resolveLogin({bundle, account: {did: 'did:plc:example'}}) + await superseded + }) + + expect(mockDisposeBundle).toHaveBeenCalledWith(bundle) + }) + + it('disposes the bundle of an aborted createAccount', async () => { + const bundle = {} as never + let resolveCreate!: (value: unknown) => void + mockCreateAccount.mockReturnValueOnce( + new Promise(resolve => { + resolveCreate = resolve + }), + ) + const api = renderProvider() + + const superseded = api.createAccount({} as never, {} as never) + mockCreateAccount.mockReturnValueOnce(new Promise(() => {})) + void api.createAccount({} as never, {} as never) + + await act(async () => { + resolveCreate({bundle, account: {did: 'did:plc:example'}}) + await superseded + }) + + expect(mockDisposeBundle).toHaveBeenCalledWith(bundle) + }) +}) diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 2881b5e792..2baaffbeae 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -239,6 +239,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) if (signal.aborted) { + // The factory returns an armed bundle, so a superseded signup must dispose it. + disposeBundle(bundle) return } store.dispatch({ @@ -264,6 +266,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) if (signal.aborted) { + // The factory returns an armed bundle, so a superseded login must dispose it. + disposeBundle(bundle) return } store.dispatch({ @@ -560,6 +564,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) { if (__DEV__ && IS_WEB) window.agent = bundle.agent const currentBundleRef = useRef(bundle) + /* + * Disposal is deferred to this post-commit effect deliberately: components may + * still render against the outgoing bundle during the commit that swaps it, so + * tearing its agent down inline would pull the agent out from under them. The + * reducer's bundle-identity guard drops any events the not-yet-disposed session + * emits in that window. + */ useEffect(() => { if (currentBundleRef.current !== bundle) { const prevBundle = currentBundleRef.current