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:
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -239,6 +239,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
|
// The factory returns an armed bundle, so a superseded signup must dispose it.
|
||||||
|
disposeBundle(bundle)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
@@ -264,6 +266,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
|
// The factory returns an armed bundle, so a superseded login must dispose it.
|
||||||
|
disposeBundle(bundle)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
@@ -560,6 +564,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
if (__DEV__ && IS_WEB) window.agent = bundle.agent
|
if (__DEV__ && IS_WEB) window.agent = bundle.agent
|
||||||
|
|
||||||
const currentBundleRef = useRef(bundle)
|
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(() => {
|
useEffect(() => {
|
||||||
if (currentBundleRef.current !== bundle) {
|
if (currentBundleRef.current !== bundle) {
|
||||||
const prevBundle = currentBundleRef.current
|
const prevBundle = currentBundleRef.current
|
||||||
|
|||||||
Reference in New Issue
Block a user