Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { useAtomValue } from "jotai";
import { AlertCircleIcon } from "lucide-react";
import type React from "react";
import { Spinner } from "@/components/icons/spinner";
import { Tooltip } from "@/components/ui/tooltip";
import { wasmInitializationAtom, wasmInitStatusAtom } from "@/core/wasm/state";
import { isWasm } from "@/core/wasm/utils";

/**
* Footer indicator that surfaces Pyodide initialization progress. Mirrors
* the "Kernel" indicator but tracks the WASM runtime instead of the server
* connection. Hides itself once Pyodide is ready.
*/
export const PyodideStatus: React.FC = () => {
const status = useAtomValue(wasmInitStatusAtom);
const message = useAtomValue(wasmInitializationAtom);

if (!isWasm() || status === "ready") {
return null;
}

const icon =
status === "error" ? (
<AlertCircleIcon className="w-4 h-4 text-destructive" />
) : (
<Spinner size="small" />
);

const tooltip = status === "error" ? "Pyodide failed to initialize" : message;

return (
<Tooltip
content={<div className="text-sm whitespace-pre-line">{tooltip}</div>}
data-testid="footer-pyodide-status"
>
<div
className="p-1 hover:bg-accent rounded flex items-center gap-1.5 text-xs text-muted-foreground"
data-testid="pyodide-status"
>
{icon}
<span>Pyodide</span>
</div>
</Tooltip>
);
};
2 changes: 2 additions & 0 deletions frontend/src/components/editor/chrome/wrapper/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "./footer-items/backend-status";
import { CopilotStatusIcon } from "./footer-items/copilot-status";
import { MachineStats } from "./footer-items/machine-stats";
import { PyodideStatus } from "./footer-items/pyodide-status";
import { RTCStatus } from "./footer-items/rtc-status";
import { RuntimeSettings } from "./footer-items/runtime-settings";
import { useSetDependencyPanelTab } from "./useDependencyPanelTab";
Expand Down Expand Up @@ -85,6 +86,7 @@ export const Footer: React.FC = () => {

<div className="mx-auto" />

<PyodideStatus />
<ConnectingKernelIndicatorItem />

<ShowInKioskMode>
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/core/run-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { buttonVariants } from "@/components/ui/button";
import { DelayMount } from "@/components/utils/delay-mount";
import { cn } from "@/utils/cn";
import { CellsRenderer } from "../components/editor/renderers/cells-renderer";
import { notebookIsRunningAtom, useCellActions } from "./cells/cells";
import {
hasCellsAtom,
notebookIsRunningAtom,
useCellActions,
} from "./cells/cells";
import type { AppConfig } from "./config/config-schema";
import { RuntimeState } from "./kernel/RuntimeState";
import { getSessionId } from "./kernel/session";
Expand Down Expand Up @@ -42,10 +46,13 @@ export const RunApp: React.FC<AppProps> = ({ appConfig }) => {

const isRunning = useAtomValue(notebookIsRunningAtom);
const isConnecting = isAppConnecting(connection.state);
// Skip the "Connecting..." gate when we already have cells to show — from
// an embedded snapshot or a prior connection.
const hasExistingCells = useAtomValue(hasCellsAtom);

const renderCells = () => {
// If we are connecting for more than 2 seconds, show a spinner
if (isConnecting) {
if (isConnecting && !hasExistingCells) {
return (
<DelayMount milliseconds={2000} fallback={null}>
<Spinner className="mx-auto" />
Expand Down
70 changes: 54 additions & 16 deletions frontend/src/core/wasm/PyodideLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@
import { useAtomValue } from "jotai";
import type React from "react";
import type { PropsWithChildren } from "react";
import { useEffect, useRef } from "react";
import { LargeSpinner } from "@/components/icons/large-spinner";
import { toast } from "@/components/ui/use-toast";
import { hasCellsAtom } from "@/core/cells/cells";
import { showCodeInRunModeAtom } from "@/core/meta/state";
import { store } from "@/core/state/jotai";
import { useAsyncData } from "@/hooks/useAsyncData";
import { prettyError } from "@/utils/errors";
import { Logger } from "@/utils/Logger";
import { hasQueryParam } from "@/utils/urls";
import { KnownQueryParams } from "../constants";
import { getInitialAppMode } from "../mode";
import { type AppMode, getInitialAppMode } from "../mode";
import { PyodideBridge } from "./bridge";
import { hasAnyOutputAtom, wasmInitializationAtom } from "./state";
import { isWasm } from "./utils";
Expand All @@ -26,30 +31,44 @@ export const PyodideLoader: React.FC<PropsWithChildren> = ({ children }) => {
};

const PyodideLoaderInner: React.FC<PropsWithChildren> = ({ children }) => {
// isPyodide() is constant, so this is safe
const { isPending, error } = useAsyncData(async () => {
// Don't block render on Pyodide: a hydrated snapshot can paint immediately
// while Pyodide downloads in the background.
const { error } = useAsyncData(async () => {
await PyodideBridge.INSTANCE.initialized.promise;
return true;
}, []);

const hasCells = useAtomValue(hasCellsAtom);
const hasOutput = useAtomValue(hasAnyOutputAtom);
const nothingToShow = shouldShowSpinner({
hasCells,
hasOutput,
mode: getInitialAppMode(),
codeHidden: isCodeHidden(),
});

if (isPending) {
return <WasmSpinner />;
}
const didToastErrorRef = useRef(false);
useEffect(() => {
// With snapshot content on-screen, toast instead of throwing so the
// snapshot stays readable. The ref ensures we only toast once even if
// nothingToShow toggles later.
if (error && !nothingToShow && !didToastErrorRef.current) {
didToastErrorRef.current = true;
Logger.error("Pyodide failed to initialize", error);
toast({
title: "Failed to start the notebook runtime",
description: prettyError(error),
variant: "danger",
});
}
}, [error, nothingToShow]);

// If ALL are true:
// - are in read mode
// - we are not showing the code
// - and there is no output
// then show the spinner
if (!hasOutput && getInitialAppMode() === "read" && isCodeHidden()) {
return <WasmSpinner />;
if (error && nothingToShow) {
throw error;
}

// Propagate back up to our error boundary
if (error) {
throw error;
if (nothingToShow) {
return <WasmSpinner />;
}

return children;
Expand All @@ -65,6 +84,25 @@ function isCodeHidden() {
);
}

/**
* Pure predicate: should the WASM loader render a spinner instead of its
* children? We block render only when nothing user-visible would appear:
* - no cells have been hydrated (Pyodide hasn't parsed the notebook), or
* - we are in headless run mode (code hidden) with no outputs to display.
*/
export function shouldShowSpinner(input: {
hasCells: boolean;
hasOutput: boolean;
mode: AppMode;
codeHidden: boolean;
}): boolean {
const { hasCells, hasOutput, mode, codeHidden } = input;
if (!hasCells) {
return true;
}
return !hasOutput && mode === "read" && codeHidden;
}

export const WasmSpinner: React.FC<PropsWithChildren> = ({ children }) => {
const wasmInitialization = useAtomValue(wasmInitializationAtom);

Expand Down
72 changes: 72 additions & 0 deletions frontend/src/core/wasm/__tests__/PyodideLoader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { describe, expect, it } from "vitest";
import { shouldShowSpinner } from "../PyodideLoader";

describe("shouldShowSpinner", () => {
it("shows the spinner when there are no cells yet (Pyodide hasn't parsed)", () => {
expect(
shouldShowSpinner({
hasCells: false,
hasOutput: false,
mode: "read",
codeHidden: false,
}),
).toBe(true);

expect(
shouldShowSpinner({
hasCells: false,
hasOutput: true,
mode: "edit",
codeHidden: false,
}),
).toBe(true);
});

it("renders children once cells exist with code visible", () => {
// run mode, code visible, no outputs yet — the user can read the code
expect(
shouldShowSpinner({
hasCells: true,
hasOutput: false,
mode: "read",
codeHidden: false,
}),
).toBe(false);
});

it("renders children once cells exist with cached outputs (snapshot case)", () => {
expect(
shouldShowSpinner({
hasCells: true,
hasOutput: true,
mode: "read",
codeHidden: true,
}),
).toBe(false);
});

it("keeps the spinner up in headless run mode with no outputs", () => {
// read mode + code hidden + no outputs = nothing visible to render
expect(
shouldShowSpinner({
hasCells: true,
hasOutput: false,
mode: "read",
codeHidden: true,
}),
).toBe(true);
});

it("never blocks edit mode once cells exist", () => {
expect(
shouldShowSpinner({
hasCells: true,
hasOutput: false,
mode: "edit",
codeHidden: true,
}),
).toBe(false);
});
});
4 changes: 3 additions & 1 deletion frontend/src/core/wasm/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import type { IConnectionTransport } from "../websocket/transports/transport";
import { PyodideRouter } from "./router";
import { getWorkerRPC } from "./rpc";
import { createShareableLink } from "./share";
import { wasmInitializationAtom } from "./state";
import { wasmInitializationAtom, wasmInitStatusAtom } from "./state";
import { fallbackFileStore, notebookFileStore } from "./store";
import { isWasm } from "./utils";
import type { SaveWorkerSchema } from "./worker/save-worker";
Expand Down Expand Up @@ -119,6 +119,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
// By initializing after, we get hits on cached network requests
this.saveRpc = this.getSaveWorker();
this.setInterruptBuffer();
store.set(wasmInitStatusAtom, "ready");
this.initialized.resolve();
});
this.rpc.addMessageListener("initializingMessage", ({ message }) => {
Expand All @@ -134,6 +135,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
variant: "danger",
});
}
store.set(wasmInitStatusAtom, "error");
this.initialized.reject(new Error(error));
});
Comment on lines 128 to 142
this.rpc.addMessageListener("kernelMessage", ({ message }) => {
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/core/wasm/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { isOutputEmpty } from "../cells/outputs";

export const wasmInitializationAtom = atom<string>("Initializing...");

export type WasmInitStatus = "loading" | "ready" | "error";
export const wasmInitStatusAtom = atom<WasmInitStatus>("loading");

export const hasAnyOutputAtom = atom<boolean>((get) => {
const notebook = get(notebookAtom);
const runtimeStates = Object.values(notebook.cellRuntime);
Expand Down
Loading