diff --git a/apps/web/src/app/[locale]/app/page.test.tsx b/apps/web/src/app/[locale]/app/page.test.tsx index d87aeda..314d1cb 100644 --- a/apps/web/src/app/[locale]/app/page.test.tsx +++ b/apps/web/src/app/[locale]/app/page.test.tsx @@ -20,6 +20,8 @@ const { loadWorkspaceMock, saveWorkspaceMock, compilePseudocodeMock, runMock, ed loading: false, // Whether the browser holds a Clerk session cookie (see mayHaveClerkSession). sessionHint: true, + // Clerk's script failed to load (see useAuthUnavailable). + unavailable: false, }, })); const localStore = new Map(); @@ -108,6 +110,7 @@ vi.mock("@/lib/auth-components", () => ({ isSignedIn: Boolean(authState.user), userId: authState.user?.id ?? null, }), + useAuthUnavailable: () => authState.unavailable, })); vi.mock("@/lib/storage", () => ({ @@ -314,6 +317,7 @@ describe("HomePage workspace flow", () => { }; authState.loading = false; authState.sessionHint = true; + authState.unavailable = false; Object.defineProperty(window as Window & { electron?: { isDesktop?: boolean } }, "electron", { configurable: true, value: undefined, @@ -542,6 +546,96 @@ describe("HomePage workspace flow", () => { expect(screen.getByRole("button", { name: "Checking sign-in status" })).toBeDisabled(); }); + it("stops waiting for Clerk and opens the in-memory workspace when Clerk fails to load", async () => { + authState.user = null; + authState.loading = true; + authState.unavailable = true; + mockLoadedWorkspace(createWorkspaceFixture()); + render(); + + await screen.findByRole("textbox", { name: "Mock editor" }); + expect(loadWorkspaceMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ mode: "memory" })); + expect(screen.getAllByText(/Couldn't load sign-in/).length).toBeGreaterThan(0); + expect(screen.getAllByRole("button", { name: "Sign-in unavailable" })[0]).toBeDisabled(); + // Save can't ask for a sign-in that can't load; the notice already explains why nothing is saved. + fireEvent.click(screen.getByRole("button", { name: "Save workspace" })); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.queryByRole("dialog", { name: "Sign in to save" })).not.toBeInTheDocument(); + expect(saveWorkspaceMock).not.toHaveBeenCalled(); + }); + + describe("Retry while Clerk is unavailable", () => { + let reload: ReturnType; + let confirm: ReturnType; + const unloadIsBlocked = () => !window.dispatchEvent(new Event("beforeunload", { cancelable: true })); + + beforeEach(() => { + authState.user = null; + authState.loading = true; + authState.unavailable = true; + mockLoadedWorkspace(createWorkspaceFixture()); + reload = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: Object.assign(new URL("https://pseudocode-compiler-preview.vercel.app/"), { reload }), + }); + confirm = vi.spyOn(window, "confirm"); + }); + + afterEach(() => { + confirm.mockRestore(); + }); + + async function openWorkspace() { + render(); + const editor = await screen.findByRole("textbox", { name: "Mock editor" }); + return { editor, retry: screen.getAllByRole("button", { name: "Retry" })[0] }; + } + + it("reloads straight away when nothing has been edited", async () => { + const { retry } = await openWorkspace(); + fireEvent.click(retry); + expect(confirm).not.toHaveBeenCalled(); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("keeps the edits and the leave warning when the student cancels", async () => { + confirm.mockReturnValue(false); + const { editor, retry } = await openWorkspace(); + fireEvent.change(editor, { target: { value: 'OUTPUT "Changed"' } }); + fireEvent.click(retry); + expect(confirm).toHaveBeenCalledTimes(1); + expect(reload).not.toHaveBeenCalled(); + expect(unloadIsBlocked()).toBe(true); + }); + + it("does not ask a second time once the student agrees to discard edits", async () => { + confirm.mockReturnValue(true); + const { editor, retry } = await openWorkspace(); + fireEvent.change(editor, { target: { value: 'OUTPUT "Changed"' } }); + expect(unloadIsBlocked()).toBe(true); + fireEvent.click(retry); + expect(confirm).toHaveBeenCalledTimes(1); + expect(reload).toHaveBeenCalledTimes(1); + expect(unloadIsBlocked()).toBe(false); + }); + }); + + it("does not show the sign-in notice to guests when Clerk is unavailable", async () => { + authState.user = null; + authState.loading = true; + authState.sessionHint = false; + authState.unavailable = true; + mockLoadedWorkspace(createWorkspaceFixture()); + render(); + + await screen.findByRole("textbox", { name: "Mock editor" }); + expect(screen.queryByText(/Couldn't load sign-in/)).not.toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "Sign-in unavailable" })[0]).toBeDisabled(); + }); + it("offers the flowchart from the toolbar without a beta setting, loading it on first open", async () => { authState.user = null; mockLoadedWorkspace(createWorkspaceFixture()); diff --git a/apps/web/src/app/[locale]/app/page.tsx b/apps/web/src/app/[locale]/app/page.tsx index 089a2c7..a5bdf00 100644 --- a/apps/web/src/app/[locale]/app/page.tsx +++ b/apps/web/src/app/[locale]/app/page.tsx @@ -74,6 +74,7 @@ import { isCloudAuthConfigured, mayHaveClerkSession, useAuth, + useAuthUnavailable, } from "@/lib/auth-components"; import { track } from "@/lib/analytics"; import { afterNextPaint } from "@/lib/afterNextPaint"; @@ -317,7 +318,12 @@ export default function HomePage() { // of waiting for Clerk's scripts and API calls. If Clerk then reports a session anyway, the // persistence mode changes and the workspace reloads from the cloud. const [awaitClerkSession] = useState(() => mayHaveClerkSession()); - const authLoading = !authLoaded && awaitClerkSession; + // If Clerk's script never loads (blocked or offline) it never reports isLoaded: stop waiting and open the + // in-memory workspace, with a notice, instead of leaving a signed-in student on an empty editor. + const authUnavailable = useAuthUnavailable() && !authLoaded; + // Guests lose nothing when Clerk is blocked, so only someone who has signed in here before gets the notice. + const showAuthUnavailableNotice = authUnavailable && awaitClerkSession; + const authLoading = !authLoaded && awaitClerkSession && !authUnavailable; const workspacePersistenceMode = getWorkspacePersistenceMode({ platform: appPlatform, signedIn: Boolean(isSignedIn), @@ -633,12 +639,15 @@ export default function HomePage() { saveThemeMode(themeMode); }, [resolvedTheme, themeMode]); + // Set once the user has already agreed to leave (Retry below), so the browser does not ask a second time. + const leaveConfirmedRef = useRef(false); useEffect(() => { if (typeof window === "undefined" || !shouldWarnBeforeUnload) { return; } const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (leaveConfirmedRef.current) return; event.preventDefault(); event.returnValue = t.save.leaveWarning; return event.returnValue; @@ -895,7 +904,9 @@ export default function HomePage() { } if (!canSaveWorkspace) { - setShowSignInPrompt(true); + // Signing in can't work while Clerk is unavailable; the account control says so (and the notice bar, for + // anyone who has signed in here before). + if (!authUnavailable) setShowSignInPrompt(true); return; } @@ -906,6 +917,7 @@ export default function HomePage() { }); }, [ authLoading, + authUnavailable, canSaveWorkspace, clearManualSaveStatusTimer, cloudSavingRequired, @@ -1166,6 +1178,22 @@ export default function HomePage() { return null; } + if (authUnavailable) { + return ( + + ); + } + // Still a placeholder until Clerk loads, even when the workspace did not wait for it. if (!authLoaded) { return ( @@ -1404,7 +1432,8 @@ export default function HomePage() { ) : null; const renderSignInPromptDialog = () => - showSignInPrompt ? ( + // A prompt opened before Clerk gave up would offer a Log In button that can no longer work. + showSignInPrompt && !authUnavailable ? ( setShowSignInPrompt(false)} className="ui-dialog max-w-sm">
@@ -1876,6 +1905,22 @@ export default function HomePage() { /** Explains account-sync situations in the notice bar, with the action that resolves them. */ const renderSyncNotice = () => { + if (showAuthUnavailableNotice) { + // iOS Safari never shows the leave warning, so ask here before a reload discards work in this tab. + const retry = () => { + if (shouldWarnBeforeUnload && !window.confirm(t.sync.authRetryDiscards)) return; + leaveConfirmedRef.current = true; + window.location.reload(); + }; + return ( +
+ {t.sync.authUnavailable} + +
+ ); + } if (!syncNotice) return null; const unreachable = syncNotice === "account-unreachable"; return ( @@ -2184,7 +2229,7 @@ export default function HomePage() { style={{ height: "100dvh", minHeight: "100svh" }} >
- {(saveError || appNotice || syncNotice) && ( + {(saveError || appNotice || syncNotice || showAuthUnavailableNotice) && (
{renderSyncNotice()} @@ -2665,7 +2710,7 @@ export default function HomePage() { {/* ════════════ Notice Bar ════════════ */} - {(saveError || appNotice || syncNotice) && ( + {(saveError || appNotice || syncNotice || showAuthUnavailableNotice) && (
{renderSyncNotice()} diff --git a/apps/web/src/i18n/messages/editor.en.ts b/apps/web/src/i18n/messages/editor.en.ts index f69d61e..e0dff33 100644 --- a/apps/web/src/i18n/messages/editor.en.ts +++ b/apps/web/src/i18n/messages/editor.en.ts @@ -76,7 +76,7 @@ export const editorEn = { }, }, auth: { - checkingStatus: "Checking sign-in status", checking: "Checking sign-in", logIn: "Log In", signedOut: "Signed out", + checkingStatus: "Checking sign-in status", checking: "Checking sign-in", unavailable: "Sign-in unavailable", logIn: "Log In", signedOut: "Signed out", signInToSave: "Sign in to save", signInDescription: "Browser saving uses your account. Sign in now to save this workspace and keep it available in the browser.", notNow: "Not now", appearance: "Appearance", general: "General", }, @@ -126,6 +126,8 @@ export const editorEn = { guestFolder: "Guest", accountUnreachable: "Couldn't reach your account, so your work is kept on this device for now. Choose Retry to sync it.", retry: "Retry", + authRetryDiscards: "Retrying reloads the page, and changes made here since it opened will be lost. Copy anything you want to keep first. Retry now?", + authUnavailable: "Couldn't load sign-in, so your saved files can't open and changes here won't be saved. Check your connection or content blocker, then choose Retry.", recoveredCopies: "Some changes saved only on this device couldn't be matched to your account's latest version. Your account's files are unchanged, and this device's versions are in the \"From this device\" folder so nothing is lost.", recoveredFolder: "From this device", guestImported: "Files you wrote before signing in were added to the \"Guest\" folder.", diff --git a/apps/web/src/i18n/messages/editor.zh.ts b/apps/web/src/i18n/messages/editor.zh.ts index 2bc6882..b7ced28 100644 --- a/apps/web/src/i18n/messages/editor.zh.ts +++ b/apps/web/src/i18n/messages/editor.zh.ts @@ -10,7 +10,7 @@ export const editorZh: typeof editorEn = { diagnostics: { hint: "提示:" }, autocomplete: { integerQuotient: "整数商", integerRemainder: "整数余数", stringLength: "字符串长度", lowerCase: "转换为小写", upperCase: "转换为大写", substring: "截取字符串", round: "实数四舍五入", random: "0 到 1 之间的随机数(含端点)", printAlias: "PRINT(别名)", outputAlias: "OUTPUT 的别名", quickStarter: "快速开始" }, flowchart: { name: "流程图", yes: "是", no: "否", createFirst: "请先创建文件", createFirstDescription: "流程图与伪代码文件关联。新建文件并命名后即可开始绘制流程图。", blocks: "组件", dragOntoFlow: "拖到流程中", generateCode: "生成代码", clearCanvas: "清空画布", confirmClearCanvas: "再次点击以清空", hidePalette: "隐藏组件栏", showPalette: "显示组件栏", inspector: "检查器", inspectorSelected: "编辑所选组件及其中显示的内容。", inspectorEmpty: "选择一个组件,以配置输入、输出或处理组件中的各行内容。", selected: "已选择", label: "标签", mode: "模式", input: "输入", output: "输出", variable: "变量", valueExpression: "值或表达式", blockTitle: "组件标题", blockLines: "组件内容", addLine: "添加一行", line: (number) => `第 ${number} 行`, removeLine: (number) => `删除第 ${number} 行`, condition: "条件", sideBranch: "侧分支", downBranch: "下方分支", callTarget: "调用目标", deleteSelected: "删除所选组件", buildLikeBlocks: "像搭积木一样构建。", helpIo: "拖入输入或输出组件,选中后设置它要读取或显示的文本。", helpProcess: "在处理组件中可以添加一行或多行伪代码。每次修改都会立即同步到代码中。", helpRun: "点击运行后,程序每执行到一个组件,流程图就会点亮它。可以前后单步查看,也可以自动播放。", process: "处理", processEmpty: "选择此组件并在其中添加内容。", until: "直到", routineHeader: "首行", run: { label: "程序运行", stepOf: (step, total) => `第 ${step} / ${total} 步`, first: "第一步", previous: "上一步", play: "播放", pause: "暂停", next: "下一步", last: "最后一步", speed: (speed) => `${speed} 倍速`, close: "关闭运行", output: "输出", stopped: "程序在这里因错误而停止。", truncated: (count) => `仅显示前 ${count.toLocaleString("zh")} 步。`, outOfDate: "此次运行后流程图已更改。请重新运行以在此查看。" }, palette: { start: { title: "开始", description: "程序入口" }, input: { title: "输入", description: "将值读入变量" }, process: { title: "处理", description: "在组件中添加可编辑内容" }, decision: { title: "判断", description: "按条件分支" }, output: { title: "输出", description: "显示值或消息" }, end: { title: "结束", description: "程序出口" }, subroutine: { title: "子程序", description: "调用过程或函数" } }, nodeTypes: { terminator: { label: "开始 / 停止", description: "流程的开始或结束" }, process: { label: "处理", description: "可包含一个或多个步骤的可视化组件" }, decision: { label: "判断", description: "是/否或真/假分支" }, inputOutput: { label: "输入 / 输出", description: "读取或输出数据" }, subroutine: { label: "子程序", description: "调用单独的过程或函数" } } }, - auth: { checkingStatus: "正在检查登录状态", checking: "正在检查登录状态", logIn: "登录", signedOut: "未登录", signInToSave: "登录后保存", signInDescription: "在浏览器中保存需要账号。立即登录即可保存此工作区,并在浏览器中继续使用。", notNow: "暂不", appearance: "外观", general: "通用" }, + auth: { checkingStatus: "正在检查登录状态", checking: "正在检查登录状态", unavailable: "登录服务不可用", logIn: "登录", signedOut: "未登录", signInToSave: "登录后保存", signInDescription: "在浏览器中保存需要账号。立即登录即可保存此工作区,并在浏览器中继续使用。", notNow: "暂不", appearance: "外观", general: "通用" }, save: { saveWorkspace: "保存工作区", saving: "保存中", saved: "已保存", failed: "保存失败", failedTitle: "保存失败。请查看消息栏了解详情。", cloudTitle: "将工作区保存到云端", localTitle: "将工作区保存在此设备上", signInTitle: "登录后保存到浏览器", failedKeptManual: "保存失败,更改仍保留在此设备上。", failedKeptAuto: "自动保存失败,更改仍保留在此设备上。", leaveWarning: "请在离开或刷新此页面前保存或导出工作。" }, empty: { welcome: "开始编写伪代码", description: "新建一个文件,写下你的算法,然后点击运行查看结果。", shortcuts: "键盘快捷键", openDocs: "阅读伪代码指南", practice: "做一道练习题" }, docs: { title: "文档" }, breadcrumbs: { documentPath: "文档路径" }, @@ -27,7 +27,7 @@ export const editorZh: typeof editorEn = { conflict: "此工作区也在其他标签页或设备上被修改。已保留你的版本,另一版本中不同的文件已复制到“云端副本”文件夹。", conflictFolder: "云端副本", guestFolder: "访客", - accountUnreachable: "无法连接你的账户,作品暂时保存在此设备上。点击“重试”即可同步。", retry: "重试", recoveredCopies: "部分只保存在此设备上的更改无法与账户中的最新版本对应。账户中的文件保持不变,此设备上的版本已放入“来自此设备”文件夹,不会丢失任何内容。", recoveredFolder: "来自此设备", + accountUnreachable: "无法连接你的账户,作品暂时保存在此设备上。点击“重试”即可同步。", retry: "重试", authRetryDiscards: "重试会刷新页面,打开后在此处所做的更改将会丢失。请先复制需要保留的内容。现在重试吗?", authUnavailable: "无法加载登录服务,因此无法打开已保存的文件,此处的更改也不会被保存。请检查网络连接或内容拦截插件,然后点击“重试”。", recoveredCopies: "部分只保存在此设备上的更改无法与账户中的最新版本对应。账户中的文件保持不变,此设备上的版本已放入“来自此设备”文件夹,不会丢失任何内容。", recoveredFolder: "来自此设备", guestImported: "登录前编写的文件已添加到“访客”文件夹。", }, }; diff --git a/apps/web/src/lib/analytics.test.ts b/apps/web/src/lib/analytics.test.ts index 7180540..b89f929 100644 --- a/apps/web/src/lib/analytics.test.ts +++ b/apps/web/src/lib/analytics.test.ts @@ -74,6 +74,32 @@ describe("analytics", () => { }); }); + it("drops harmless editor and browser exceptions but keeps real ones", () => { + const beforeSend = posthogConfig.before_send as (event: CaptureResult | null) => CaptureResult | null; + const exception = (...entries: Array<{ type: string; value: string }>): CaptureResult => ({ + uuid: "test", + event: "$exception", + properties: { $current_url: "https://pseudoeditor.dev/app?x=1", $exception_list: entries }, + }); + + expect(beforeSend(exception({ type: "Canceled", value: "Canceled" }))).toBeNull(); + expect( + beforeSend(exception({ type: "Error", value: "ResizeObserver loop completed with undelivered notifications." })), + ).toBeNull(); + expect(beforeSend(exception({ type: "Error", value: "ResizeObserver loop limit exceeded" }))).toBeNull(); + + const real = beforeSend( + exception({ type: "TypeError", value: "Cannot read properties of undefined (reading 'toLowerCase')" }), + ); + expect(real?.properties.$current_url).toBe("https://pseudoeditor.dev/app"); + // A real error alongside a harmless one is still reported. + expect( + beforeSend(exception({ type: "Canceled", value: "Canceled" }, { type: "TypeError", value: "x is undefined" })), + ).not.toBeNull(); + // Other events are never dropped. + expect(beforeSend(captureResult({ $current_url: "https://pseudoeditor.dev/" }))).not.toBeNull(); + }); + it("honours Do Not Track and Global Privacy Control", () => { const nav = (signals: { doNotTrack?: string | null; globalPrivacyControl?: boolean }) => ({ doNotTrack: null, ...signals }) as unknown as Navigator; diff --git a/apps/web/src/lib/analytics.ts b/apps/web/src/lib/analytics.ts index 39bc182..2e9eb2d 100644 --- a/apps/web/src/lib/analytics.ts +++ b/apps/web/src/lib/analytics.ts @@ -71,14 +71,30 @@ export const posthogConfig: Partial = { disable_web_experiments: true, disable_external_dependency_loading: true, advanced_disable_flags: true, - before_send: (event) => (isBenignEditorCancellation(event) ? null : scrubUrls(event)), + before_send: (event) => (isBenignBrowserError(event) ? null : scrubUrls(event)), }; -/** Monaco rejects in-flight work with `Canceled` when the editor switches models; that is not an app error. */ -function isBenignEditorCancellation(event: Parameters[0]): boolean { +// Chrome's and Firefox's wording, and the older "limit exceeded" Chrome used before version 109. +const RESIZE_OBSERVER_LOOP = /^ResizeObserver loop (completed with undelivered notifications|limit exceeded)/; + +/** + * Exceptions that are expected and harmless, so they never reach error tracking: + * - Monaco rejects in-flight work with `Canceled` when the editor switches models. + * - The browser reports a ResizeObserver loop when a resize callback (Monaco's automaticLayout) changes layout + * again in the same frame; the remaining notifications are simply delivered in the next frame. + */ +function isBenignBrowserError(event: Parameters[0]): boolean { if (!event || event.event !== "$exception") return false; const list = (event.properties?.$exception_list ?? []) as Array<{ type?: string; value?: string }>; - return list.length > 0 && list.every((entry) => entry.type === "Canceled" || entry.value === "Canceled"); + return ( + list.length > 0 && + list.every( + (entry) => + entry.type === "Canceled" || + entry.value === "Canceled" || + (typeof entry.value === "string" && RESIZE_OBSERVER_LOOP.test(entry.value)), + ) + ); } /** diff --git a/apps/web/src/lib/auth-components.tsx b/apps/web/src/lib/auth-components.tsx index 4410f76..732d30a 100644 --- a/apps/web/src/lib/auth-components.tsx +++ b/apps/web/src/lib/auth-components.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ComponentProps, ReactNode } from "react"; +import { useCallback, useSyncExternalStore, type ComponentProps, type ReactNode } from "react"; import { ClerkProvider as ClerkProviderBase, Show as ClerkShow, @@ -8,6 +8,7 @@ import { SignUpButton as ClerkSignUpButton, UserButton as ClerkUserButton, useAuth as useClerkAuth, + useClerk, } from "@clerk/nextjs"; import { zhCN } from "@clerk/localizations"; import { localePath } from "@/i18n/config"; @@ -201,3 +202,35 @@ export function useAuth() { // eslint-disable-next-line react-hooks/rules-of-hooks return useClerkAuth(); } + +/** + * True once Clerk has given up loading, usually because a school filter, content blocker or bad connection + * stopped its script. Clerk then never reports `isLoaded`, so anything waiting for it must stop waiting. + */ +export function useAuthUnavailable(): boolean { + if (!cloudAuthRequired() || !isCloudAuthConfigured()) { + return false; + } + + // Same stable runtime branch as useAuth above. + // eslint-disable-next-line react-hooks/rules-of-hooks + return useClerkStatusIsError(); +} + +// Relies on Clerk (checked against @clerk/react 6.18) updating `status` before it notifies `on("status")` listeners, +// and on "error" being final for the page. The unit test uses a fake Clerk, so recheck both when upgrading. +function useClerkStatusIsError(): boolean { + const clerk = useClerk(); + const subscribe = useCallback( + (onChange: () => void) => { + clerk.on("status", onChange); + return () => clerk.off("status", onChange); + }, + [clerk], + ); + return useSyncExternalStore( + subscribe, + () => clerk.status === "error", + () => false, + ); +} diff --git a/apps/web/src/lib/authUnavailable.test.tsx b/apps/web/src/lib/authUnavailable.test.tsx new file mode 100644 index 0000000..ff0193c --- /dev/null +++ b/apps/web/src/lib/authUnavailable.test.tsx @@ -0,0 +1,68 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type Listener = (status: string) => void; + +// A stand-in for Clerk's isomorphic instance: the public `status`, `on("status")` and `off("status")`. +const fakeClerk = vi.hoisted(() => { + const listeners = new Set(); + return { + status: "loading", + listeners, + on: (_event: "status", listener: Listener) => { + listeners.add(listener); + }, + off: (_event: "status", listener: Listener) => { + listeners.delete(listener); + }, + emit(status: string) { + this.status = status; + for (const listener of listeners) listener(status); + }, + }; +}); + +vi.mock("@clerk/nextjs", async (importOriginal) => ({ + ...(await importOriginal()), + useClerk: () => fakeClerk, +})); + +async function loadHook() { + // The publishable key is read when the module loads. + vi.resetModules(); + return (await import("@/lib/auth-components")).useAuthUnavailable; +} + +describe("useAuthUnavailable", () => { + beforeEach(() => { + fakeClerk.status = "loading"; + fakeClerk.listeners.clear(); + Object.defineProperty(window, "location", { configurable: true, value: new URL("https://pseudoeditor.dev/app") }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("turns true when Clerk gives up loading, and unsubscribes on unmount", async () => { + vi.stubEnv("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", "pk_test_example"); + const useAuthUnavailable = await loadHook(); + const { result, unmount } = renderHook(() => useAuthUnavailable()); + + expect(result.current).toBe(false); + act(() => fakeClerk.emit("ready")); + expect(result.current).toBe(false); + act(() => fakeClerk.emit("error")); + expect(result.current).toBe(true); + + unmount(); + expect(fakeClerk.listeners.size).toBe(0); + }); + + it("is always false where cloud sign-in is not used", async () => { + vi.stubEnv("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", ""); + fakeClerk.status = "error"; + const useAuthUnavailable = await loadHook(); + expect(renderHook(() => useAuthUnavailable()).result.current).toBe(false); + }); +}); diff --git a/apps/web/src/lib/clerk-electron-components.tsx b/apps/web/src/lib/clerk-electron-components.tsx index c33eab2..2ae5825 100644 --- a/apps/web/src/lib/clerk-electron-components.tsx +++ b/apps/web/src/lib/clerk-electron-components.tsx @@ -43,6 +43,10 @@ export function useAuth() { }; } +export function useClerk() { + return { status: "ready" as const, on: () => {}, off: () => {} }; +} + export function isCloudAuthConfigured() { return false; } diff --git a/apps/web/src/lib/shortcuts.test.ts b/apps/web/src/lib/shortcuts.test.ts index b49f627..d76c262 100644 --- a/apps/web/src/lib/shortcuts.test.ts +++ b/apps/web/src/lib/shortcuts.test.ts @@ -33,6 +33,17 @@ describe("shortcuts", () => { expect(matchesShortcut(key({ key: "`", code: "Backquote", metaKey: true }), panel, true)).toBe(false); }); + it("ignores keydown events without a key, as autofill and password managers send them", () => { + // A plain Event, not a KeyboardEvent: no key, no code, no modifier flags. + const synthetic = new Event("keydown") as KeyboardEvent; + for (const specs of Object.values(SHORTCUTS)) { + for (const spec of specs) { + expect(() => matchesShortcut(synthetic, spec, true)).not.toThrow(); + expect(matchesShortcut(synthetic, spec, false)).toBe(false); + } + } + }); + it("formats labels per platform", () => { expect(formatShortcut(SHORTCUTS.run[0], true)).toBe("⌘↩"); expect(formatShortcut(SHORTCUTS.run[0], false)).toBe("Ctrl+Enter"); diff --git a/apps/web/src/lib/shortcuts.ts b/apps/web/src/lib/shortcuts.ts index 612860f..947a366 100644 --- a/apps/web/src/lib/shortcuts.ts +++ b/apps/web/src/lib/shortcuts.ts @@ -34,7 +34,8 @@ function eventKeyMatches(event: KeyboardEvent, key: ShortcutKey): boolean { if (key.length > 1) return event.key === key; // Letters follow the typed character so AZERTY/Dvorak users get the shortcut printed on their key; fall back to // the physical key for non-Latin layouts, where the typed character is not a Latin letter. - const typed = event.key.toLowerCase(); + // Autofill and password managers dispatch bare `keydown` Events with no `key` (or `code`). + const typed = typeof event.key === "string" ? event.key.toLowerCase() : ""; if (/^[a-z]$/.test(typed)) return typed === key; return event.code === `Key${key.toUpperCase()}`; }