Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions apps/web/src/app/[locale]/app/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
Expand Down Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(<HomePage />);

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<typeof vi.fn>;
let confirm: ReturnType<typeof vi.spyOn>;
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(<HomePage />);
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(<HomePage />);

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());
Expand Down
55 changes: 50 additions & 5 deletions apps/web/src/app/[locale]/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import {
isCloudAuthConfigured,
mayHaveClerkSession,
useAuth,
useAuthUnavailable,
} from "@/lib/auth-components";
import { track } from "@/lib/analytics";
import { afterNextPaint } from "@/lib/afterNextPaint";
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand All @@ -906,6 +917,7 @@ export default function HomePage() {
});
}, [
authLoading,
authUnavailable,
canSaveWorkspace,
clearManualSaveStatusTimer,
cloudSavingRequired,
Expand Down Expand Up @@ -1166,6 +1178,22 @@ export default function HomePage() {
return null;
}

if (authUnavailable) {
return (
<button
type="button"
className="flex h-7 items-center gap-1.5 rounded-[var(--radius-control)] px-2.5 text-[var(--text3)]"
aria-label={t.auth.unavailable}
disabled
>
<LogIn size={18} />
<span className={compact ? "sr-only" : "max-w-[8rem] truncate text-xs font-medium"}>
{t.auth.unavailable}
</span>
</button>
);
}

// Still a placeholder until Clerk loads, even when the workspace did not wait for it.
if (!authLoaded) {
return (
Expand Down Expand Up @@ -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 ? (
<Dialog labelledBy="sign-in-save-dialog-title" onClose={() => setShowSignInPrompt(false)} className="ui-dialog max-w-sm">
<div className="flex items-start gap-3">
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[var(--accent)]">
Expand Down Expand Up @@ -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 (
<div className="flex items-center gap-3" role="status">
<span className="text-[var(--red)]">{t.sync.authUnavailable}</span>
<button type="button" className="ui-btn ui-btn-secondary h-6 px-2 text-xs" onClick={retry}>
{t.sync.retry}
</button>
</div>
);
}
if (!syncNotice) return null;
const unreachable = syncNotice === "account-unreachable";
return (
Expand Down Expand Up @@ -2184,7 +2229,7 @@ export default function HomePage() {
style={{ height: "100dvh", minHeight: "100svh" }}
>
<div className="flex h-full min-h-0 flex-col overflow-hidden" style={touchSafeAreaStyle}>
{(saveError || appNotice || syncNotice) && (
{(saveError || appNotice || syncNotice || showAuthUnavailableNotice) && (
<div className="shrink-0 border-b border-[var(--separator)] bg-[var(--surface)] px-4 py-1.5">
<div className="flex flex-wrap items-center gap-3 text-[11px]">
{renderSyncNotice()}
Expand Down Expand Up @@ -2665,7 +2710,7 @@ export default function HomePage() {
</header>

{/* ════════════ Notice Bar ════════════ */}
{(saveError || appNotice || syncNotice) && (
{(saveError || appNotice || syncNotice || showAuthUnavailableNotice) && (
<div className="shrink-0 border-b border-[var(--separator)] bg-[var(--surface)] px-4 py-1.5">
<div className="flex flex-wrap items-center gap-3 text-[11px]">
{renderSyncNotice()}
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/i18n/messages/editor.en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand Down Expand Up @@ -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.",
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/i18n/messages/editor.zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "文档路径" },
Expand All @@ -27,7 +27,7 @@ export const editorZh: typeof editorEn = {
conflict: "此工作区也在其他标签页或设备上被修改。已保留你的版本,另一版本中不同的文件已复制到“云端副本”文件夹。",
conflictFolder: "云端副本",
guestFolder: "访客",
accountUnreachable: "无法连接你的账户,作品暂时保存在此设备上。点击“重试”即可同步。", retry: "重试", recoveredCopies: "部分只保存在此设备上的更改无法与账户中的最新版本对应。账户中的文件保持不变,此设备上的版本已放入“来自此设备”文件夹,不会丢失任何内容。", recoveredFolder: "来自此设备",
accountUnreachable: "无法连接你的账户,作品暂时保存在此设备上。点击“重试”即可同步。", retry: "重试", authRetryDiscards: "重试会刷新页面,打开后在此处所做的更改将会丢失。请先复制需要保留的内容。现在重试吗?", authUnavailable: "无法加载登录服务,因此无法打开已保存的文件,此处的更改也不会被保存。请检查网络连接或内容拦截插件,然后点击“重试”。", recoveredCopies: "部分只保存在此设备上的更改无法与账户中的最新版本对应。账户中的文件保持不变,此设备上的版本已放入“来自此设备”文件夹,不会丢失任何内容。", recoveredFolder: "来自此设备",
guestImported: "登录前编写的文件已添加到“访客”文件夹。",
},
};
Loading
Loading