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 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-07-31 20:45:51 +03:00
parent 21d67da9a9
commit 9f381485dc
2 changed files with 144 additions and 0 deletions
@@ -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<unknown>>()
const mockCreateAccount = jest.fn<(...args: unknown[]) => Promise<unknown>>()
const mockDisposeBundle = jest.fn()
jest.mock('../session-core', () => ({
...jest.requireActual<object>('../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(
<Provider>
<Probe />
</Provider>,
)
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)
})
})
+11
View File
@@ -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