Skip to content
This repository was archived by the owner on Jul 1, 2026. It is now read-only.

Commit eb7b625

Browse files
Fix mobile OAuth resume hang and redirect loop after file pick.
StrictMode-safe resume claiming, safe return paths away from the callback URL, auto-reopen Effective Paths after OAuth, and stop hiding the desktop picker before it is visible. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ca6be9b commit eb7b625

10 files changed

Lines changed: 210 additions & 70 deletions

src/components/EffectivePathsSyncDialog.tsx

Lines changed: 31 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,12 @@ import {
8181
resumeMobileEffectivePathsGrant,
8282
} from '../effectivePaths/effectivePathsMobileGrantFlow'
8383
import {
84+
beginEpMobileResumeRun,
85+
claimEpMobileResume,
8486
consumeEpMobilePickerError,
85-
peekEpMobileResume,
87+
finishEpMobileResumeRun,
8688
} from '../effectivePaths/effectivePathsMobileGrantSession'
87-
import { mobilePickerRedirectPreferred, redirectToMobilePickerAuth } from '../effectivePaths/googleDrivePickerMobile'
89+
import { mobilePickerRedirectPreferred } from '../effectivePaths/googleDrivePickerMobile'
8890
import {
8991
getCachedGoogleSheetsAccessToken,
9092
googleSheetsOAuthConfigured,
@@ -248,7 +250,6 @@ export function EffectivePathsSyncDialog({
248250
const [workbookAccess, setWorkbookAccess] = useState<LinkedWorkbookAccess[] | null>(null)
249251
const [loadingSheets, setLoadingSheets] = useState(false)
250252
const [pickerUiHidden, setPickerUiHidden] = useState(false)
251-
const mobileResumeStartedRef = useRef(false)
252253
const [loadProgress, setLoadProgress] = useState<EffectivePathsLoadProgress | null>(null)
253254
const [syncProgress, setSyncProgress] = useState<{
254255
direction: 'import' | 'export'
@@ -632,44 +633,27 @@ export function EffectivePathsSyncDialog({
632633
const deniedIds = collectDeniedLinkedSpreadsheetIds(result.workbookAccess)
633634
if (deniedIds.length > 0) {
634635
const linkedNames = linkedWorkbookNamesFromGateway(result)
635-
if (mobilePickerRedirectPreferred()) {
636-
await redirectToMobilePickerAuth({
637-
phase: 'linked_workbooks',
638-
masterSpreadsheetId: parsedMaster.spreadsheetId,
639-
masterSheetGid: parsedMaster.sheetGid,
640-
spreadsheetIds: collectSpreadsheetIdsFromGateway(result, parsedMaster.spreadsheetId),
641-
multiselect: true,
642-
requireMasterSpreadsheetId: parsedMaster.spreadsheetId,
643-
titles: {
644-
...pickerTitles,
645-
linkedWorkbooks:
636+
if (!mobilePickerRedirectPreferred()) {
637+
const retryGrant = await grantEffectivePathsSpreadsheetsAccess(
638+
token,
639+
collectSpreadsheetIdsFromGateway(result, parsedMaster.spreadsheetId),
640+
{
641+
title:
646642
linkedNames.length > 0
647643
? `${t('ep_picker_linked_workbooks_title')} (${linkedNames.join(', ')})`
648644
: t('ep_picker_linked_workbooks_title'),
645+
multiselect: true,
646+
requireMasterSpreadsheetId: parsedMaster.spreadsheetId,
647+
onPickerUiActive: setPickerUiHidden,
649648
},
650-
})
651-
return
652-
}
653-
654-
const retryGrant = await grantEffectivePathsSpreadsheetsAccess(
655-
token,
656-
collectSpreadsheetIdsFromGateway(result, parsedMaster.spreadsheetId),
657-
{
658-
title:
659-
linkedNames.length > 0
660-
? `${t('ep_picker_linked_workbooks_title')} (${linkedNames.join(', ')})`
661-
: t('ep_picker_linked_workbooks_title'),
662-
multiselect: true,
663-
requireMasterSpreadsheetId: parsedMaster.spreadsheetId,
664-
onPickerUiActive: setPickerUiHidden,
665-
},
666-
)
667-
if (!retryGrant.ok) {
668-
reportMobilePickerError(retryGrant.reason)
669-
} else {
670-
result = await listWorkbooks()
671-
if (result.ok) {
672-
cacheLinkedSpreadsheetIdsFromGateway(parsedMaster.spreadsheetId, result)
649+
)
650+
if (!retryGrant.ok) {
651+
reportMobilePickerError(retryGrant.reason)
652+
} else {
653+
result = await listWorkbooks()
654+
if (result.ok) {
655+
cacheLinkedSpreadsheetIdsFromGateway(parsedMaster.spreadsheetId, result)
656+
}
673657
}
674658
}
675659
}
@@ -730,23 +714,25 @@ export function EffectivePathsSyncDialog({
730714
)
731715
}
732716
},
733-
[formatExportError, parsedMaster, pickerTitles, reportMobilePickerError, showNotice, t],
717+
[formatExportError, parsedMaster, reportMobilePickerError, showNotice, t],
734718
)
735719

736720
useEffect(() => {
737-
if (!open || mobileResumeStartedRef.current) return
721+
if (!open) return
738722

739723
const mobileError = consumeEpMobilePickerError()
740724
if (mobileError) {
741725
queueMicrotask(() => reportMobilePickerError(mobileError))
742726
return
743727
}
744728

745-
if (!peekEpMobileResume()) return
746-
mobileResumeStartedRef.current = true
729+
const resume = claimEpMobileResume()
730+
if (!resume) return
731+
if (!beginEpMobileResumeRun()) return
747732

748733
void (async () => {
749734
setLoadingSheets(true)
735+
setPickerUiHidden(false)
750736
resetEffectivePathsWorkbookLoadState({
751737
setWorkbooks,
752738
setIdsTabTitle,
@@ -773,11 +759,11 @@ export function EffectivePathsSyncDialog({
773759
setLoadProgress(null)
774760
setNotice(null)
775761
try {
776-
const accessResult = await resumeMobileEffectivePathsGrant()
762+
const accessResult = await resumeMobileEffectivePathsGrant(resume)
777763
if (!accessResult.ok) {
778764
if (accessResult.reason === 'gateway_failed') {
779765
showNotice(formatExportError(accessResult.error, accessResult.message), 'error')
780-
} else if (accessResult.reason !== 'mobile_resume_missing') {
766+
} else {
781767
reportMobilePickerError(accessResult.reason)
782768
}
783769
return
@@ -796,16 +782,12 @@ export function EffectivePathsSyncDialog({
796782
} finally {
797783
setLoadingSheets(false)
798784
setLoadProgress(null)
785+
setPickerUiHidden(false)
786+
finishEpMobileResumeRun(true)
799787
}
800788
})()
801789
}, [formatExportError, loadLinkedWorkbooks, open, reportMobilePickerError, showNotice, t])
802790

803-
useEffect(() => {
804-
if (!open) {
805-
mobileResumeStartedRef.current = false
806-
}
807-
}, [open])
808-
809791
const handleLoadSheets = useCallback(async () => {
810792
if (!parsedMaster) {
811793
showNotice(

src/components/lab/LabImportExportPanel.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useRef, useState, type ChangeEvent } from 'react'
1+
import { useEffect, useRef, useState, type ChangeEvent } from 'react'
22
import type { BotsEpSyncState } from '../../effectivePaths/botsEpStateFromPersisted'
33
import type { ModulesEpSyncState } from '../../effectivePaths/modulesEpStateFromPersisted'
44
import type { GuardiansEpSyncState } from '../../effectivePaths/guardiansEpStateFromPersisted'
@@ -109,6 +109,17 @@ export function LabImportExportPanel({
109109
peekEpMobileResume() != null ||
110110
peekEpMobilePickerError() != null,
111111
)
112+
113+
useEffect(() => {
114+
if (
115+
!hasEpResumeQuery() &&
116+
peekEpMobileResume() == null &&
117+
peekEpMobilePickerError() == null
118+
) {
119+
return
120+
}
121+
queueMicrotask(() => setEffectivePathsSyncOpen(true))
122+
}, [])
112123
const effectivePathsAvailable = googleSheetsOAuthConfigured()
113124
const importLabCsvFileInputRef = useRef<HTMLInputElement>(null)
114125
const importPlayerInfoFileInputRef = useRef<HTMLInputElement>(null)

src/effectivePaths/effectivePathsMobileGrantFlow.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { EffectivePathsIdsGateway } from './assembleEffectivePathsListResult'
2-
import { consumeEpMobileResume } from './effectivePathsMobileGrantSession'
2+
import type { EpMobileResumePayload } from './effectivePathsMobileGrantSession'
33
import { readCachedLinkedSpreadsheetIds } from './effectivePathsLinkedSpreadsheetCache'
44
import { fetchEffectivePathsIdsGateway } from './exportEffectivePathsApi'
55
import { redirectToMobilePickerAuth } from './googleDrivePickerMobile'
@@ -81,12 +81,9 @@ export type ResumeMobileEffectivePathsGrantResult =
8181
/**
8282
* Continue grant after Google redirects back. May start another redirect for linked workbooks.
8383
*/
84-
export async function resumeMobileEffectivePathsGrant(): Promise<ResumeMobileEffectivePathsGrantResult> {
85-
const resume = consumeEpMobileResume()
86-
if (!resume) {
87-
return { ok: false, reason: 'mobile_resume_missing' }
88-
}
89-
84+
export async function resumeMobileEffectivePathsGrant(
85+
resume: EpMobileResumePayload,
86+
): Promise<ResumeMobileEffectivePathsGrantResult> {
9087
const {
9188
accessToken,
9289
pickedSpreadsheetIds,
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5+
import {
6+
beginEpMobileResumeRun,
7+
claimEpMobileResume,
8+
finishEpMobileResumeRun,
9+
peekEpMobileResume,
10+
resetEpMobileResumeRunStateForTests,
11+
stashEpMobileResume,
12+
} from './effectivePathsMobileGrantSession'
13+
14+
const sampleResume = {
15+
accessToken: 'token-abc',
16+
pickedSpreadsheetIds: ['sheet-1'],
17+
phase: 'master' as const,
18+
masterSpreadsheetId: 'sheet-1',
19+
masterSheetGid: null,
20+
titles: {
21+
idsMaster: 'IDS',
22+
allWorkbooks: 'All',
23+
linkedWorkbooks: 'Linked',
24+
},
25+
}
26+
27+
describe('ep mobile resume run guard', () => {
28+
beforeEach(() => {
29+
sessionStorage.clear()
30+
resetEpMobileResumeRunStateForTests()
31+
})
32+
33+
afterEach(() => {
34+
sessionStorage.clear()
35+
resetEpMobileResumeRunStateForTests()
36+
})
37+
38+
it('claims without consuming until finish', () => {
39+
stashEpMobileResume(sampleResume)
40+
41+
const claimed = claimEpMobileResume()
42+
expect(claimed).toEqual(sampleResume)
43+
expect(peekEpMobileResume()).toEqual(sampleResume)
44+
45+
finishEpMobileResumeRun(true)
46+
expect(peekEpMobileResume()).toBeNull()
47+
})
48+
49+
it('prevents a second in-flight run (StrictMode remount)', () => {
50+
stashEpMobileResume(sampleResume)
51+
52+
expect(claimEpMobileResume()).toEqual(sampleResume)
53+
expect(beginEpMobileResumeRun()).toBe(true)
54+
expect(beginEpMobileResumeRun()).toBe(false)
55+
expect(claimEpMobileResume()).toBeNull()
56+
})
57+
})

src/effectivePaths/effectivePathsMobileGrantSession.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { EnsureEffectivePathsSpreadsheetAccessTitles } from './grantEffectivePathsSpreadsheetAccess'
2+
import { publicAppOrigin } from '../devOrigin'
23

34
const PKCE_SESSION_KEY = 'towersmith_ep_mobile_pkce'
45
const FLOW_SESSION_KEY = 'towersmith_ep_mobile_grant_flow'
@@ -117,13 +118,45 @@ export function peekEpMobileResume(): EpMobileResumePayload | null {
117118
return readJson<EpMobileResumePayload>(RESUME_SESSION_KEY)
118119
}
119120

121+
let activeEpMobileResume: EpMobileResumePayload | null = null
122+
let epMobileResumeInFlight = false
123+
124+
/** Peek the pending resume payload (StrictMode-safe; does not consume sessionStorage). */
125+
export function claimEpMobileResume(): EpMobileResumePayload | null {
126+
if (epMobileResumeInFlight) return null
127+
if (activeEpMobileResume) return activeEpMobileResume
128+
const resume = peekEpMobileResume()
129+
if (!resume) return null
130+
activeEpMobileResume = resume
131+
return resume
132+
}
133+
134+
/** Mark the resume runner as started so a StrictMode remount does not start a second run. */
135+
export function beginEpMobileResumeRun(): boolean {
136+
if (epMobileResumeInFlight) return false
137+
epMobileResumeInFlight = true
138+
return true
139+
}
140+
141+
/** End the resume runner and optionally remove the payload from sessionStorage. */
142+
export function finishEpMobileResumeRun(commit = true): void {
143+
if (commit) {
144+
consumeEpMobileResume()
145+
}
146+
activeEpMobileResume = null
147+
epMobileResumeInFlight = false
148+
}
149+
150+
/** @internal Test helper */
151+
export function resetEpMobileResumeRunStateForTests(): void {
152+
activeEpMobileResume = null
153+
epMobileResumeInFlight = false
154+
}
155+
120156
export const EP_RESUME_QUERY_PARAM = 'ep_resume'
121157

122158
export function urlWithEpResumeFlag(target: string): string {
123-
const url = new URL(
124-
target,
125-
typeof window !== 'undefined' ? window.location.origin : 'https://www.towersmith.com',
126-
)
159+
const url = new URL(target, publicAppOrigin())
127160
url.searchParams.set(EP_RESUME_QUERY_PARAM, '1')
128161
return url.toString()
129162
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { safeAppReturnPath } from './epMobileOAuthReturn'
3+
4+
describe('safeAppReturnPath', () => {
5+
it('returns site root when preferred path is the OAuth callback', () => {
6+
expect(
7+
safeAppReturnPath('https://www.towersmith.com/oauth/google-drive-picker?code=abc'),
8+
).toBe('https://www.towersmith.com/')
9+
})
10+
11+
it('strips OAuth query params from a normal return URL', () => {
12+
expect(safeAppReturnPath('https://www.towersmith.com/?code=abc&state=xyz')).toBe(
13+
'https://www.towersmith.com/',
14+
)
15+
})
16+
17+
it('keeps a normal lab return URL', () => {
18+
expect(safeAppReturnPath('https://www.towersmith.com/?panel=lab')).toBe(
19+
'https://www.towersmith.com/?panel=lab',
20+
)
21+
})
22+
})
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { publicAppOrigin } from '../devOrigin'
2+
import { isGooglePickerOAuthCallbackPath } from './googleDrivePickerEnvironment'
3+
4+
const OAUTH_QUERY_PARAMS = ['code', 'state', 'error', 'scope', 'authuser', 'prompt', 'picked_file_ids']
5+
6+
/** Never send the user back to the OAuth callback URL (that causes a redirect loop). */
7+
export function safeAppReturnPath(preferred?: string | null): string {
8+
if (preferred) {
9+
try {
10+
const url = new URL(preferred)
11+
if (isGooglePickerOAuthCallbackPath(url.pathname)) {
12+
return `${url.origin}/`
13+
}
14+
for (const key of OAUTH_QUERY_PARAMS) {
15+
url.searchParams.delete(key)
16+
}
17+
return url.toString()
18+
} catch {
19+
/* ignore malformed URLs */
20+
}
21+
}
22+
return `${publicAppOrigin()}/`
23+
}
24+
25+
/** When VITE_PUBLIC_APP_ORIGIN is set, start OAuth from that host so sessionStorage matches the callback. */
26+
export function redirectToCanonicalOriginIfNeeded(): boolean {
27+
if (typeof window === 'undefined') return false
28+
const canonical = publicAppOrigin()
29+
if (window.location.origin === canonical) return false
30+
window.location.assign(
31+
`${canonical}${window.location.pathname}${window.location.search}${window.location.hash}`,
32+
)
33+
return true
34+
}

src/effectivePaths/googleDrivePicker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,8 +259,8 @@ export async function pickGoogleSpreadsheets(
259259
}
260260

261261
try {
262-
options.onPickerUiActive?.(true)
263262
builder.build().setVisible(true)
263+
options.onPickerUiActive?.(true)
264264
} catch {
265265
finish({ ok: false, reason: 'picker_failed' })
266266
}

0 commit comments

Comments
 (0)