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
387 changes: 341 additions & 46 deletions frontend/src/core/islands/__tests__/bridge.test.ts

Large diffs are not rendered by default.

105 changes: 105 additions & 0 deletions frontend/src/core/islands/__tests__/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
parseIslandElement,
parseIslandElementsIntoApps,
parseMarimoIslandApps,
retainIslandSource,
} from "../parse";
import { createMockIslandElement, createMockIslands } from "./test-utils.tsx";

Expand Down Expand Up @@ -53,6 +54,7 @@ function appendPayload(
script.type = ISLANDS_JSON_SCRIPT_TYPE;
script.textContent = JSON.stringify(payload);
root.appendChild(script);
return script;
}

describe("createMarimoFile", () => {
Expand Down Expand Up @@ -317,6 +319,39 @@ describe("parseIslandElement", () => {

expect(result).toBeNull();
});

it("uses retained source after the custom element renders", () => {
const element = document.createElement(ISLAND_TAG_NAMES.ISLAND);
retainIslandSource(element, {
code: 'print("retained")',
output: "<div>retained output</div>",
});

expect(parseIslandElement(element)).toEqual({
code: 'print("retained")',
output: "<div>retained output</div>",
});

retainIslandSource(element, { code: "", output: "<div>static</div>" });
expect(parseIslandElement(element)).toBeNull();
});

it("prefers new live source when an island element is reused", () => {
const element = createMockIslandElement({
code: 'print("new")',
innerHTML: "<div>new output</div>",
});
element.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
retainIslandSource(element, {
code: 'print("retained")',
output: "<div>retained output</div>",
});

expect(parseIslandElement(element)).toEqual({
code: 'print("new")',
output: "<div>new output</div>",
});
});
});

describe("parseIslandElementsIntoApps", () => {
Expand Down Expand Up @@ -630,6 +665,76 @@ describe("parseMarimoIslandApps", () => {
);
});

it("preserves payload-backed cells after a non-materializing capability probe", () => {
const island = createMockIslandElement({
appId: "app1",
cellId: "cell-2",
code: "dom_code = True",
innerHTML: "<div>dom output</div>",
});
island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
container.appendChild(island);
appendPayload(container, {
schemaVersion: 1,
appId: "app1",
cells: [createPayloadCell({ cellId: "cell-2" })],
});
const originalIsland = island.outerHTML;

const probed = parseMarimoIslandApps(container, { materialize: false });

expect(probed).toHaveLength(1);
expect(island.outerHTML).toBe(originalIsland);

expect(parseMarimoIslandApps(container)).toEqual(probed);
expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_ID)).toBeNull();
expect(island.getAttribute(ISLAND_DATA_ATTRIBUTES.CELL_IDX)).toBe("0");
});

it("refreshes a reused payload anchor when its content changes", () => {
const island = createMockIslandElement({
appId: "app1",
cellId: "cell-1",
code: 'print("dom")',
innerHTML: "<div>dom output</div>",
});
island.insertAdjacentHTML(
"beforeend",
'<div data-marimo-element><marimo-code-editor data-initial-value="old"></marimo-code-editor></div>',
);
island.setAttribute(ISLAND_DATA_ATTRIBUTES.REACTIVE, "true");
container.appendChild(island);
const script = appendPayload(container, {
schemaVersion: 1,
appId: "app1",
cells: [createPayloadCell()],
});
parseMarimoIslandApps(container);

script.textContent = JSON.stringify({
schemaVersion: 1,
appId: "app1",
cells: [
createPayloadCell({
code: 'print("updated")',
outputHtml: "<div>updated output</div>",
}),
],
});

parseMarimoIslandApps(container);

expect(extractIslandCodeFromEmbed(island)).toBe('print("updated")');
expect(island.querySelector(ISLAND_TAG_NAMES.CELL_OUTPUT)?.innerHTML).toBe(
"<div>updated output</div>",
);
expect(
island
.querySelector(ISLAND_TAG_NAMES.CODE_EDITOR)
?.getAttribute("data-initial-value"),
).toBe(JSON.stringify('print("updated")'));
});

it("should parse Python-generated island payload snapshots", () => {
const html = readFileSync(
new URL(
Expand Down
11 changes: 8 additions & 3 deletions frontend/src/core/islands/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,17 @@ export async function initializeIslands(
// Loading indicator: dim islands while Pyodide initializes
store.sub(shouldShowIslandsWarningIndicatorAtom, () => {
const showing = store.get(shouldShowIslandsWarningIndicatorAtom);
const currentIslands = root.querySelectorAll<HTMLElement>(
ISLAND_TAG_NAMES.ISLAND,
);
if (showing) {
toastIslandsLoading();
for (const island of islands) {
for (const island of currentIslands) {
island.style.setProperty("opacity", "0.5");
}
} else {
dismissIslandsLoadingToast();
for (const island of islands) {
for (const island of currentIslands) {
island.style.removeProperty("opacity");
}
}
Expand Down Expand Up @@ -208,7 +211,9 @@ function handleMessage(
setKernelState: Functions.NOOP,
onError: Logger.error,
});
defineCustomElement(ISLAND_TAG_NAMES.ISLAND, MarimoIslandElement);
if (!window.customElements?.get(ISLAND_TAG_NAMES.ISLAND)) {
defineCustomElement(ISLAND_TAG_NAMES.ISLAND, MarimoIslandElement);
}
return;

case "send-ui-element-message":
Expand Down
139 changes: 116 additions & 23 deletions frontend/src/core/islands/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { throwNotImplemented } from "@/utils/functions";
import type { JsonString } from "@/utils/json/base64";
import { Logger } from "@/utils/Logger";
import { generateUUID } from "@/utils/uuid";
import { initialNotebookState, notebookAtom } from "../cells/cells";
import type { CommandMessage, NotificationPayload } from "../kernel/messages";
import type { EditRequests, RunRequests } from "../network/types";
import { store as defaultStore } from "../state/jotai";
Expand Down Expand Up @@ -35,11 +36,12 @@ export interface IslandsBridgeConfig {
* Optional root element for parsing islands (for testing)
*/
root?: Document | Element;
}

/**
* Whether to auto-start sessions on worker ready (default: true)
*/
autoStartSessions?: boolean;
interface AppSession {
appId: string;
code?: string;
sessionGeneration: number;
}

/**
Expand All @@ -51,8 +53,8 @@ export interface IslandsBridgeConfig {
* @example
* ```ts
* const bridge = new IslandsPyodideBridge();
* await bridge.initialized;
* bridge.consumeMessages(message => console.log(message));
* await bridge.initializeApps();
* ```
*/
export class IslandsPyodideBridge implements RunRequests, EditRequests {
Expand All @@ -62,14 +64,17 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
| undefined;
private readonly store: typeof defaultStore;
private readonly root: Document | Element;
private readonly autoStartSessions: boolean;
private session: AppSession | undefined;
private sessionReady = new Deferred<AppSession>();
private nextSessionGeneration = 0;
private appTransition = Promise.resolve();
private workerReady = new Deferred<void>();

public initialized = new Deferred<void>();

constructor(config: IslandsBridgeConfig = {}) {
this.store = config.store || defaultStore;
this.root = config.root || document;
this.autoStartSessions = config.autoStartSessions ?? true;

try {
const factory = config.workerFactory || new DefaultWorkerFactory();
Expand All @@ -88,9 +93,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
*/
private setupMessageListeners(): void {
this.rpc.addMessageListener("ready", () => {
if (this.autoStartSessions) {
this.startSessionsForAllApps();
}
this.workerReady.resolve();
});

this.rpc.addMessageListener("initialized", () => {
Expand All @@ -115,11 +118,16 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
);
}

/**
* Starts sessions for all apps found in the DOM
*/
private startSessionsForAllApps(): void {
async initializeApps(): Promise<void> {
await this.enqueueAppTransition(async () => {
await this.workerReady.promise;
await this.startApps();
});
}

private async startApps(): Promise<void> {
const apps = parseMarimoIslandApps(this.root);
const managesSingleApp = apps.length === 1;
Logger.debug(
`Starting sessions for ${apps.length} app(s):`,
apps.map((a) => `${a.id} (${a.cells.length} cells)`),
Expand All @@ -134,20 +142,83 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
const notebookCode = exportContext?.notebookCode;
for (const app of apps) {
const file = notebookCode || createMarimoFile(app);
if (
managesSingleApp &&
this.session?.appId === app.id &&
this.session.code === file
) {
return;
}
Logger.debug(`App ${app.id} marimo file:\n`, file);
this.startSession({
const request = {
code: file,
appId: app.id,
}).catch((error) => {
sessionGeneration: ++this.nextSessionGeneration,
};
const previousSession = this.session;
const replacesSession = managesSingleApp && previousSession !== undefined;
if (replacesSession) {
this.store.set(notebookAtom, initialNotebookState());
}
if (managesSingleApp || !previousSession) {
if (managesSingleApp && this.sessionReady.status !== "pending") {
this.sessionReady = new Deferred<AppSession>();
}
this.session = {
...request,
code: managesSingleApp ? file : undefined,
};
}
const operation = replacesSession
? this.rpc.proxy.request.replaceSession(request)
: this.startSession(request);
try {
await operation;
if (this.sessionReady.status === "pending" && this.session) {
this.sessionReady.resolve(this.session);
}
} catch (error) {
if (this.session?.sessionGeneration === request.sessionGeneration) {
this.session = previousSession;
}
Logger.error(`Failed to start session for app ${app.id}:`, error);
});
if (managesSingleApp) {
throw error;
}
}
}
}

async stopSession(appId?: string): Promise<void> {
await this.enqueueAppTransition(async () => {
const session = this.session;
if (session?.code === undefined || (appId && session.appId !== appId)) {
return;
}
await this.rpc.proxy.request.stopSession({
appId: session.appId,
sessionGeneration: session.sessionGeneration,
});
this.session = undefined;
this.sessionReady = new Deferred<AppSession>();
this.store.set(notebookAtom, initialNotebookState());
});
}

private enqueueAppTransition(operation: () => Promise<void>): Promise<void> {
const result = this.appTransition.then(operation);
this.appTransition = result.catch(() => undefined);
return result;
}

/**
* Starts a new Python session for an app
*/
async startSession(opts: { code: string; appId: string }): Promise<void> {
async startSession(opts: {
code: string;
appId: string;
sessionGeneration: number;
}): Promise<void> {
await this.rpc.proxy.request.startSession(opts);
}

Expand Down Expand Up @@ -203,11 +274,19 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
// ============================================================================

sendRun: EditRequests["sendRun"] = async (request): Promise<null> => {
await this.rpc.proxy.request.loadPackages(request.codes.join("\n"));
await this.putControlRequest({
type: "execute-cells",
...request,
const session = await this.getActiveSession();
await this.rpc.proxy.request.loadPackages({
appId: session.appId,
code: request.codes.join("\n"),
sessionGeneration: session.sessionGeneration,
});
await this.putControlRequest(
{
type: "execute-cells",
...request,
},
session,
);
return null;
};

Expand Down Expand Up @@ -278,13 +357,27 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {

// The kernel uses msgspec to parse control requests, which requires a 'type'
// field for discriminated union deserialization.
private async putControlRequest(operation: CommandMessage): Promise<void> {
private async putControlRequest(
operation: CommandMessage,
session?: AppSession,
): Promise<void> {
session ??= await this.getActiveSession();
await this.rpc.proxy.request.bridge({
appId: session.appId,
functionName: "put_control_request",
payload: operation,
sessionGeneration: session.sessionGeneration,
});
}

private async getActiveSession(): Promise<AppSession> {
const transition = this.appTransition;
const session = this.session;
const ready = this.sessionReady;
await transition;
return session ?? ready.promise;
}

/**
* Cleans up resources (for testing)
*/
Expand Down
Loading
Loading