From 044a657ccbfb95d8a00dfb784231402f5c398e4a Mon Sep 17 00:00:00 2001 From: Brandon Corfman Date: Mon, 1 Jun 2026 22:38:04 -0400 Subject: [PATCH] Fixed cloud panel display update issues --- src/editor/CloudAccountPanel.tsx | 127 ++++++++++++++---- .../cloud-account-publish-gating.test.tsx | 88 +++++++++++- 2 files changed, 185 insertions(+), 30 deletions(-) diff --git a/src/editor/CloudAccountPanel.tsx b/src/editor/CloudAccountPanel.tsx index b411e513..ba8daec1 100644 --- a/src/editor/CloudAccountPanel.tsx +++ b/src/editor/CloudAccountPanel.tsx @@ -5,6 +5,38 @@ import { PROJECT_LAST_SAVED_AT_STORAGE_KEY, PROJECT_STORAGE_KEY, WORKSPACE_BACKU import { WorkspaceConflictModal } from './WorkspaceConflictModal'; import { summarizeYamlWorkspace } from './workspaceSummary'; +type CloudAccountUser = { id: string; email: string } | null; +type CloudPublishInfo = { ok: true; login: string; pagesBaseUrl: string; repo: string } | { ok: false; error: string }; + +let cachedCloudAccountUser: CloudAccountUser | undefined; +let cachedCloudAccountUserPromise: Promise | null = null; +const cachedPublishInfoByUserId = new Map(); + +function resolveCachedCloudAccountUser(): Promise { + if (cachedCloudAccountUser !== undefined) return Promise.resolve(cachedCloudAccountUser); + if (cachedCloudAccountUserPromise) return cachedCloudAccountUserPromise; + cachedCloudAccountUserPromise = me() + .then((res) => res.user) + .catch(() => null) + .then((user) => { + cachedCloudAccountUser = user; + cachedCloudAccountUserPromise = null; + return user; + }); + return cachedCloudAccountUserPromise; +} + +function setCachedCloudAccountUser(user: CloudAccountUser) { + cachedCloudAccountUser = user; + cachedCloudAccountUserPromise = null; +} + +export function __resetCloudAccountPanelAuthCacheForTests() { + cachedCloudAccountUser = undefined; + cachedCloudAccountUserPromise = null; + cachedPublishInfoByUserId.clear(); +} + export function buildGithubStartHref(params: { apiBaseUrl: string; baseUrl: string; @@ -60,13 +92,16 @@ export function CloudAccountPanel({ const LAST_PUBLISH_STORAGE_KEY = 'phaserforge.cloud.last_github_pages_publish_v1'; const CLOUD_GAME_MAP_STORAGE_KEY = 'phaserforge.cloud.project_game_id_map_v1'; const [csrfToken, setCsrfToken] = useState(null); - const [user, setUser] = useState<{ id: string; email: string } | null>(null); + const [user, setUser] = useState(cachedCloudAccountUser ?? null); + const [authResolved, setAuthResolved] = useState(cachedCloudAccountUser !== undefined); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [inviteToken, setInviteToken] = useState(''); const [showPassword, setShowPassword] = useState(false); const [busy, setBusy] = useState(false); - const [publishInfo, setPublishInfo] = useState<{ ok: true; login: string; pagesBaseUrl: string; repo: string } | { ok: false; error: string } | null>(null); + const [publishInfo, setPublishInfo] = useState( + cachedCloudAccountUser?.id ? cachedPublishInfoByUserId.get(cachedCloudAccountUser.id) ?? null : null, + ); const [publishCheck, setPublishCheck] = useState<{ url: string; exists: boolean; status: number | null } | null>(null); const [showPublishConfirm, setShowPublishConfirm] = useState(false); const [showGithubConfirm, setShowGithubConfirm] = useState(null); @@ -99,38 +134,43 @@ export function CloudAccountPanel({ let cancelled = false; const init = async () => { try { - const csrf = await fetchCsrfToken(); - if (!cancelled) setCsrfToken(csrf); - } catch { - // ignore - } - - try { - const res = await me(); - if (!cancelled) setUser(res.user); + const raw = window.localStorage.getItem(LAST_PUBLISH_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as { url?: unknown; publishedAtMs?: unknown }; + if (typeof parsed.url === 'string' && typeof parsed.publishedAtMs === 'number' && Number.isFinite(parsed.publishedAtMs) && !cancelled) { + setLastPublish({ url: parsed.url, publishedAtMs: parsed.publishedAtMs }); + } + } } catch { // ignore } try { - const raw = window.localStorage.getItem(LAST_PUBLISH_STORAGE_KEY); - if (!raw) return; - const parsed = JSON.parse(raw) as { url?: unknown; publishedAtMs?: unknown }; - if (typeof parsed.url !== 'string') return; - if (typeof parsed.publishedAtMs !== 'number' || !Number.isFinite(parsed.publishedAtMs)) return; - if (!cancelled) setLastPublish({ url: parsed.url, publishedAtMs: parsed.publishedAtMs }); + const raw = window.localStorage.getItem(CLOUD_GAME_MAP_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as Record; + const id = state.project?.id; + if (id) { + const mapped = parsed[id]; + if (typeof mapped === 'string' && mapped.length > 0 && !cancelled) setCloudGameId(mapped); + } + } } catch { // ignore } try { - const raw = window.localStorage.getItem(CLOUD_GAME_MAP_STORAGE_KEY); - if (!raw) return; - const parsed = JSON.parse(raw) as Record; - const id = state.project?.id; - if (!id) return; - const mapped = parsed[id]; - if (typeof mapped === 'string' && mapped.length > 0 && !cancelled) setCloudGameId(mapped); + const [csrfResult, userResult] = await Promise.allSettled([fetchCsrfToken(), resolveCachedCloudAccountUser()]); + if (!cancelled && csrfResult.status === 'fulfilled') setCsrfToken(csrfResult.value); + if (!cancelled) { + if (userResult.status === 'fulfilled') { + setUser(userResult.value); + } else { + setCachedCloudAccountUser(null); + setUser(null); + } + setAuthResolved(true); + } } catch { // ignore } @@ -250,12 +290,20 @@ export function CloudAccountPanel({ useEffect(() => { let cancelled = false; if (!user) return; + const cached = cachedPublishInfoByUserId.get(user.id); + if (cached) { + setPublishInfo(cached); + return; + } const loadPublishInfo = async () => { try { const info = await getGithubPagesPublishInfo(); + cachedPublishInfoByUserId.set(user.id, info); if (!cancelled) setPublishInfo(info); } catch { - if (!cancelled) setPublishInfo({ ok: false, error: 'publish_info_failed' }); + const info = { ok: false, error: 'publish_info_failed' } satisfies CloudPublishInfo; + cachedPublishInfoByUserId.set(user.id, info); + if (!cancelled) setPublishInfo(info); } }; void loadPublishInfo(); @@ -264,9 +312,10 @@ export function CloudAccountPanel({ }; }, [user]); - const ensurePublishInfo = async (): Promise<{ ok: true; login: string; pagesBaseUrl: string; repo: string } | { ok: false; error: string }> => { + const ensurePublishInfo = async (): Promise => { if (publishInfo) return publishInfo; const info = await getGithubPagesPublishInfo(); + if (user?.id) cachedPublishInfoByUserId.set(user.id, info); setPublishInfo(info); return info; }; @@ -297,7 +346,9 @@ export function CloudAccountPanel({ try { const csrf = await ensureCsrf(); const res = await signup(email, password, csrf, inviteToken.trim() || undefined); + setCachedCloudAccountUser(res.user); setUser(res.user); + setAuthResolved(true); onStatus(`Signed in as ${res.user.email}`); } catch (err) { const msg = err instanceof Error ? err.message : 'Signup failed'; @@ -314,7 +365,9 @@ export function CloudAccountPanel({ try { const csrf = await ensureCsrf(); const res = await login(email, password, csrf); + setCachedCloudAccountUser(res.user); setUser(res.user); + setAuthResolved(true); onStatus(`Signed in as ${res.user.email}`); } catch (err) { onError(err instanceof Error ? err.message : 'Login failed'); @@ -328,7 +381,10 @@ export function CloudAccountPanel({ try { const csrf = await ensureCsrf(); await logout(csrf); + setCachedCloudAccountUser(null); + if (user?.id) cachedPublishInfoByUserId.delete(user.id); setUser(null); + setAuthResolved(true); setPublishInfo(null); setPublishCheck(null); setShowPublishConfirm(false); @@ -350,7 +406,9 @@ export function CloudAccountPanel({ try { const csrf = await ensureCsrf(); await disconnectGithub(csrf); - setPublishInfo({ ok: false, error: 'github_not_linked' }); + const info = { ok: false, error: 'github_not_linked' } satisfies CloudPublishInfo; + if (user?.id) cachedPublishInfoByUserId.set(user.id, info); + setPublishInfo(info); setPublishCheck(null); setShowPublishConfirm(false); onStatus('Disconnected GitHub'); @@ -532,7 +590,20 @@ export function CloudAccountPanel({ onClose={() => setWorkspaceConflict(null)} /> ) : null} - {!user ? ( + {!authResolved ? ( + <> +
+
ACCOUNT
+
+ Checking account… +
+
+
+
PUBLISH (GITHUB PAGES)
+
Checking account status before loading publish options.
+
+ + ) : !user ? ( <>
ACCOUNT
diff --git a/tests/editor/cloud-account-publish-gating.test.tsx b/tests/editor/cloud-account-publish-gating.test.tsx index e1982384..3e9a3b17 100644 --- a/tests/editor/cloud-account-publish-gating.test.tsx +++ b/tests/editor/cloud-account-publish-gating.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import React from 'react'; -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { act } from 'react'; import { createRoot } from 'react-dom/client'; @@ -30,7 +30,7 @@ const api = vi.hoisted(() => { vi.mock('../../src/cloud/api', () => api); -import { CloudAccountPanel } from '../../src/editor/CloudAccountPanel'; +import { CloudAccountPanel, __resetCloudAccountPanelAuthCacheForTests } from '../../src/editor/CloudAccountPanel'; function baseState(): any { return { @@ -67,6 +67,11 @@ describe('CloudAccountPanel publish gating', () => { (globalThis as any).IS_REACT_ACT_ENVIRONMENT = undefined; }); + afterEach(() => { + __resetCloudAccountPanelAuthCacheForTests(); + vi.clearAllMocks(); + }); + it('shows a compact Publish section when not signed in', async () => { api.me.mockImplementationOnce(async () => { throw new Error('not_signed_in'); @@ -128,4 +133,83 @@ describe('CloudAccountPanel publish gating', () => { view.cleanup(); } }); + + it('shows a neutral loading state until auth resolves', async () => { + let resolveMe: ((value: { user: { id: string; email: string } }) => void) | null = null; + api.me.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveMe = resolve; + }), + ); + + const view = renderIntoDom( + {}} onLoadYaml={() => {}} onStatus={() => {}} onError={() => {}} />, + ); + try { + expect(document.querySelector('[data-testid="cloud-account-loading"]')?.textContent).toContain('Checking account'); + expect(document.querySelector('[data-testid="cloud-publish-signin-cta"]')).toBeFalsy(); + expect(document.querySelector('.cloud-signed-in')).toBeFalsy(); + + resolveMe?.({ user: { id: 'u1', email: 'a@b.c' } }); + await flushEffects(); + + expect(document.querySelector('[data-testid="cloud-account-loading"]')).toBeFalsy(); + expect(document.querySelector('.cloud-signed-in')?.textContent).toContain('a@b.c'); + } finally { + view.cleanup(); + } + }); + + it('reuses resolved auth on remount instead of showing the signed-out layout first', async () => { + api.me.mockResolvedValueOnce({ user: { id: 'u1', email: 'a@b.c' } }); + api.getGithubPagesPublishInfo.mockResolvedValue({ ok: false, error: 'github_not_linked' }); + + const firstView = renderIntoDom( + {}} onLoadYaml={() => {}} onStatus={() => {}} onError={() => {}} />, + ); + await flushEffects(); + firstView.cleanup(); + + const secondView = renderIntoDom( + {}} onLoadYaml={() => {}} onStatus={() => {}} onError={() => {}} />, + ); + try { + expect(document.querySelector('[data-testid="cloud-account-loading"]')).toBeFalsy(); + expect(document.querySelector('.cloud-signed-in')?.textContent).toContain('a@b.c'); + expect(api.me).toHaveBeenCalledTimes(1); + expect(document.querySelector('[data-testid="cloud-publish-signin-cta"]')).toBeFalsy(); + } finally { + secondView.cleanup(); + } + }); + + it('reuses resolved GitHub publish info on remount instead of showing checking state first', async () => { + api.me.mockResolvedValueOnce({ user: { id: 'u1', email: 'a@b.c' } }); + api.getGithubPagesPublishInfo.mockResolvedValueOnce({ + ok: true, + login: 'alice', + pagesBaseUrl: 'https://alice.github.io/', + repo: 'alice/alice.github.io', + }); + + const firstView = renderIntoDom( + {}} onLoadYaml={() => {}} onStatus={() => {}} onError={() => {}} />, + ); + await flushEffects(); + firstView.cleanup(); + + const secondView = renderIntoDom( + {}} onLoadYaml={() => {}} onStatus={() => {}} onError={() => {}} />, + ); + try { + expect(document.querySelector('[data-testid="cloud-github-connection"]')?.textContent).toContain('connected as alice'); + expect(document.querySelector('[data-testid="cloud-publish-connect-github-cta"]')).toBeFalsy(); + expect(document.querySelector('[aria-label="Publish route"]')).toBeTruthy(); + expect(document.body.textContent).not.toContain('Checking GitHub connection'); + expect(api.getGithubPagesPublishInfo).toHaveBeenCalledTimes(1); + } finally { + secondView.cleanup(); + } + }); });