Skip to content

Commit 9ccde3a

Browse files
committed
fix(session): harden upgrade path + address review feedback
- Reconcile plan surfaces after upgrade even when the fresh disableCookieCache read fails: invalidate ['organizations']/['subscription'] regardless of the bypass-read outcome (they read server truth, not the cookie cache). The valid cookie-cached session is still served, so a transient failure no longer signs the user out or leaves the just-upgraded plan looking stale. Org-activate fallback stays gated on having a session. - Use a bare return in the cancelled branch of refreshAfterUpgrade (the caller discards the value) for clearer intent; caller coerces with ?? null. - Make the upgrade tests deterministic: the mount mock honors the abort signal like the real fetch-backed client, and assertions read the query cache (the state cancelQueries/setQueryData/invalidation actually govern) instead of the async-rendered context value.
1 parent 650db2d commit 9ccde3a

2 files changed

Lines changed: 59 additions & 12 deletions

File tree

apps/sim/app/_shell/providers/session-provider.test.tsx

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ describe('SessionProvider', () => {
181181

182182
mockGetSession.mockImplementation((arg?: unknown) => {
183183
if (isUpgradeCall(arg)) return upgrade.promise
184+
// Honor the abort signal like the real fetch-backed client: cancelQueries
185+
// aborts the in-flight mount read, so it rejects rather than resolving late.
186+
const signal = (arg as { fetchOptions?: { signal?: AbortSignal } })?.fetchOptions?.signal
187+
signal?.addEventListener('abort', () =>
188+
mount.reject(new DOMException('Aborted', 'AbortError'))
189+
)
184190
return mount.promise
185191
})
186192
// activeOrganizationId is present, so setActive / listCreatorOrganizations are not reached.
@@ -189,25 +195,65 @@ describe('SessionProvider', () => {
189195
await flush()
190196

191197
// Resolve the fresh upgrade read FIRST. The cancelQueries guard runs before
192-
// setQueryData, cancelling the in-flight stale mount query.
198+
// setQueryData, cancelling (aborting) the in-flight stale mount query.
193199
await act(async () => {
194200
upgrade.resolve({ data: FRESH_SESSION })
195201
await Promise.resolve()
196202
})
203+
await flushUntil(() => h.queryClient.getQueryData(sessionKeys.detail()) != null)
204+
205+
// Assert on the cache — the contended state cancelQueries + setQueryData
206+
// govern. The fresh value wins; the aborted stale mount read never clobbers it.
207+
expect(h.queryClient.getQueryData(sessionKeys.detail())).toEqual(FRESH_SESSION)
208+
expect(h.queryClient.getQueryData(sessionKeys.detail())).not.toEqual(STALE_SESSION)
209+
210+
h.unmount()
211+
})
212+
213+
it('upgrade path: a failed fresh read keeps the user signed in and still reconciles plan surfaces', async () => {
214+
setSearch('?upgraded=true')
215+
216+
const mount = defer<{ data: AppSession }>()
217+
const upgrade = defer<{ data: AppSession }>()
218+
mockGetSession.mockImplementation((arg?: unknown) =>
219+
isUpgradeCall(arg) ? upgrade.promise : mount.promise
220+
)
221+
222+
const invalidateSpy = vi.spyOn(QueryClient.prototype, 'invalidateQueries')
223+
const invalidatedKeys = () =>
224+
invalidateSpy.mock.calls.map(([arg]) => (arg as { queryKey?: unknown[] })?.queryKey)
225+
226+
const h = renderProvider()
197227
await flush()
198228

199-
// Now the stale mount query resolves LATE. Because it was cancelled, its
200-
// result must NOT clobber the fresh value written to the cache.
229+
// The fresh disableCookieCache read fails.
230+
await act(async () => {
231+
upgrade.reject(new Error('refresh failed'))
232+
await Promise.resolve()
233+
})
234+
await flush()
235+
236+
// The normal cookie-cached mount query lands AFTER the failure.
201237
await act(async () => {
202238
mount.resolve({ data: STALE_SESSION })
203239
await Promise.resolve()
204240
})
205-
await flushUntil(() => h.ctx()?.data != null)
241+
await flushUntil(
242+
() =>
243+
h.queryClient.getQueryData(sessionKeys.detail()) != null &&
244+
invalidatedKeys().some((k) => Array.isArray(k) && k[0] === 'subscription')
245+
)
206246

207-
expect(h.queryClient.getQueryData(sessionKeys.detail())).toEqual(FRESH_SESSION)
208-
expect(h.ctx()?.data).toEqual(FRESH_SESSION)
209-
expect(h.ctx()?.data).not.toEqual(STALE_SESSION)
247+
// The valid cookie-cached session is still cached — a failed upgrade refresh
248+
// must not sign the user out, and it must not surface as a session error.
249+
expect(h.queryClient.getQueryData(sessionKeys.detail())).toEqual(STALE_SESSION)
250+
expect(h.queryClient.getQueryState(sessionKeys.detail())?.error ?? null).toBeNull()
251+
252+
// Plan surfaces read server truth, so they still reconcile after the failure.
253+
expect(invalidatedKeys()).toContainEqual(['organizations'])
254+
expect(invalidatedKeys()).toContainEqual(['subscription'])
210255

256+
invalidateSpy.mockRestore()
211257
h.unmount()
212258
})
213259

apps/sim/app/_shell/providers/session-provider.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,31 +65,32 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
6565
const res = await client.getSession({ query: { disableCookieCache: true } })
6666
const fresh = extractSessionDataFromAuthClientResult(res) as AppSession
6767

68-
if (isCancelled) return fresh
68+
if (isCancelled) return
6969

7070
await queryClient.cancelQueries({ queryKey: sessionKeys.detail() })
7171
queryClient.setQueryData(sessionKeys.detail(), fresh)
7272
return fresh
7373
}
7474

7575
const initializeSession = async () => {
76-
let session: AppSession
76+
let session: AppSession = null
7777
try {
78-
session = await refreshAfterUpgrade()
78+
session = (await refreshAfterUpgrade()) ?? null
7979
} catch (e) {
8080
logger.warn('Failed to refresh session after subscription upgrade', { error: e })
81-
return
8281
}
8382

8483
if (isCancelled) {
8584
return
8685
}
8786

87+
// Refresh the plan surfaces even if the cookie-bypass read above failed: they
88+
// query server truth (not the session cookie cache), so they still reconcile.
8889
queryClient.invalidateQueries({ queryKey: ['organizations'] })
8990
queryClient.invalidateQueries({ queryKey: ['subscription'] })
9091

9192
const activeOrganizationId = session?.session?.activeOrganizationId ?? null
92-
if (activeOrganizationId) {
93+
if (!session || activeOrganizationId) {
9394
return
9495
}
9596

0 commit comments

Comments
 (0)