feat: API for retained WASM island lifecycle#10207
Conversation
Allow callers to inspect island apps without materializing payloads. Retain payload identities and rendered source so repeated initialization reconstructs the same app after custom elements take ownership of their children.
Own the Python session task and stop callable so an app can be cancelled and collected while Pyodide remains active. Serialize dependency loading and skip initialization work from superseded sessions.
Export canReplaceApp(), initialize(), and stopApp() for hosts that preserve the browser realm across page navigation. Reuse the global worker while serializing app replacement and scoping worker requests by app ID and session generation so stale work cannot reach the active app.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
All reported issues were addressed across 10 files
Architecture diagram
sequenceDiagram
participant Host as Host Page
participant Bridge as IslandsPyodideBridge
participant Worker as Worker Thread
participant Controller as WasmController
participant Pyodide as Pyodide Runtime
participant DOM as DOM / Custom Elements
Note over Host,DOM: NEW: Retained Island Lifecycle Flow
Host->>Bridge: canReplaceApp()
Bridge->>DOM: query islands & parse (materialize: false)
DOM-->>Bridge: apps list
alt exactly one app
Bridge-->>Host: true
else
Bridge-->>Host: false
end
Host->>Bridge: initialize()
Bridge->>Bridge: enqueueAppTransition()
Bridge->>Worker: wait for workerReady
Worker-->>Bridge: ready
Bridge->>DOM: parseMarimoIslandApps()
DOM-->>Bridge: apps with cells
alt no previous session
Bridge->>Bridge: generate new sessionGeneration, set session
Bridge->>Worker: startSession({appId, code, sessionGeneration})
Worker->>Controller: mountFilesystem & startSession
Controller->>Pyodide: runPython (create session resources)
Pyodide-->>Controller: bridge, init, packages, stop
Controller->>Controller: loadNotebookDeps (serialized on packageLoadQueue)
Controller-->>Worker: resolved bridge
Worker-->>Bridge: session started
Bridge->>Bridge: resolve sessionReady
else previous session exists and code unchanged
Bridge->>Bridge: skip (duplicate)
else previous session exists and code changed (replace)
Bridge->>Bridge: new sessionGeneration, create deferred sessionReady
Bridge->>Worker: replaceSession({appId, code, sessionGeneration})
Worker->>Worker: enqueueSession -> stopActiveSession()
Worker->>Controller: stopSession()
Controller->>Controller: increment sessionGeneration, destroy stopCurrentSession
Controller->>Pyodide: stop() -> cancel kernel, gc
Pyodide-->>Controller: done
Controller-->>Worker: stopped
Worker->>Controller: startSession (as above)
Controller-->>Worker: new bridge
Worker-->>Bridge: session replaced
Bridge->>Bridge: resolve sessionReady (if pending)
end
Note over Host,Bridge: Stopping an app
Host->>Bridge: stopApp(appId)
Bridge->>Bridge: enqueueAppTransition
Bridge->>Worker: stopSession({appId, sessionGeneration})
Worker->>Worker: enqueueSession -> check activeSession matches
Worker->>Controller: stopSession()
Controller->>Pyodide: stop() + destroy resources
Pyodide-->>Controller: done
Controller-->>Worker: stopped
Worker-->>Bridge: stopped
Bridge->>Bridge: clear session
Note over Host,DOM: Control requests scoped to session
Host->>Bridge: sendComponentValues (ui-element update)
Bridge->>Bridge: getActiveSession() -> wait for appTransition + sessionReady
Bridge->>Worker: bridge({appId, sessionGeneration, functionName: "put_control_request", payload})
Worker->>Worker: enqueueSession -> requireActiveBridge() validates generation
Worker->>Pyodide: call bridge[functionName](payload)
Pyodide-->>Worker: response
Worker-->>Bridge: response
Bridge-->>Host: result
Note over Host,DOM: Replacement failure fallback
alt replaceSession fails (error)
Bridge->>Bridge: catch error, clear session if generation matches
Bridge->>Bridge: keep previous session (fallback)
Bridge-->>Host: throw error
Host->>Bridge: initialize() retry
Bridge->>Bridge: startApps -> sees no session, calls startSession (not replace)
Bridge->>Worker: startSession (fresh start)
end
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
There was a problem hiding this comment.
All reported issues were addressed across 12 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| * Cleanup when element is removed from DOM | ||
| */ | ||
| disconnectedCallback(): void { | ||
| this.connectionGeneration += 1; |
There was a problem hiding this comment.
are we incrementing on connection and disconnection?
There was a problem hiding this comment.
the idea was for connectedCallback() to start a new lifecycle and disconnectedCallback() to explicitly invalidate its pending work, so every lifecycle transition advances the epoch. looking at it again it is probably overkill though because isConnected and the next connection generation already cover the same race.
There was a problem hiding this comment.
i think thats actually fine and makes total sense now that i think about it
| import { shouldLoadDuckDBPackages } from "../utils"; | ||
|
|
||
| const MAKE_SNAPSHOT = false; | ||
| type SessionResources = [PyProxy, PyCallable, PyProxy, PyCallable]; |
There was a problem hiding this comment.
nit: could do a typed tuple here [one: PyProxy, two: PyCallable, three: PyProxy, four: PyCallable]
mscolnick
left a comment
There was a problem hiding this comment.
looks great! minor comments
|
oh one last idea, could we add a test that validates the exports and possible the typedef API and snapshots it? since now its a public API that cannot break. can do in a followup |
Oh yes I'll do it in a next pr |
|
🚀 Development release published. You may be able to view the changes at https://marimo.app?v=0.23.15-dev32 |
📝 Summary
Adds a retained-runtime lifecycle for static sites that navigate between pages containing marimo islands. Page transitions can replace the active Python app while keeping the worker and Pyodide environment ready.
The islands entry module exports:
canReplaceApp()returns whether the current document contains one replaceable appstopApp(appId?)stops the matching active app sessioninitialize()starts, reuses, or replaces the app in the current documentHosts can call these methods around client-side navigation:
App transitions are serialized and scoped by app ID and session generation. Rapid or failed transitions preserve session ownership, prevent stale controls from reaching another app, and allow teardown or replacement to be retried.
Worker startup creates and tracks the Python resources for each session. Package loading is serialized, superseded sessions skip initialization, and successful teardown releases the session task and Python references while retaining Pyodide.
Also, island elements now support page content that is upgraded or reused during navigation. They bind after compiled cell IDs become available, refresh when an existing cell ID receives new source, and release static staging output after mounting.