diff --git a/frontend/src/__mocks__/requests.ts b/frontend/src/__mocks__/requests.ts index 87596fce8f1..098fa9d4f57 100644 --- a/frontend/src/__mocks__/requests.ts +++ b/frontend/src/__mocks__/requests.ts @@ -33,6 +33,7 @@ export const MockRequestClient = { saveAppConfig: vi.fn().mockResolvedValue({}), saveCellConfig: vi.fn().mockResolvedValue({}), sendFunctionRequest: vi.fn().mockResolvedValue({}), + sendUpdateQueryParams: vi.fn().mockResolvedValue(null), sendInstallMissingPackages: vi.fn().mockResolvedValue({}), readCode: vi.fn().mockResolvedValue({ contents: "" }), readSnippets: vi.fn().mockResolvedValue({ snippets: [] }), diff --git a/frontend/src/core/islands/bridge.ts b/frontend/src/core/islands/bridge.ts index 0696d08c4ee..4411437be27 100644 --- a/frontend/src/core/islands/bridge.ts +++ b/frontend/src/core/islands/bridge.ts @@ -175,6 +175,16 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests { return null; }; + sendUpdateQueryParams: RunRequests["sendUpdateQueryParams"] = async ( + request, + ): Promise => { + await this.putControlRequest({ + type: "update-query-params", + ...request, + }); + return null; + }; + sendFunctionRequest: RunRequests["sendFunctionRequest"] = async ( request, ): Promise => { diff --git a/frontend/src/core/kernel/queryParamHandlers.ts b/frontend/src/core/kernel/queryParamHandlers.ts index e076db6863c..3ff057b6fce 100644 --- a/frontend/src/core/kernel/queryParamHandlers.ts +++ b/frontend/src/core/kernel/queryParamHandlers.ts @@ -37,3 +37,24 @@ export const queryParamHandlers = { return; }, }; + +/** + * Parse URL query parameters into the format expected by the kernel. + */ +export function parseQueryParams(): Record { + const url = new URL(window.location.href); + const params: Record = {}; + + for (const [key, value] of url.searchParams.entries()) { + const existing = params[key]; + if (existing === undefined) { + params[key] = value; + } else if (Array.isArray(existing)) { + existing.push(value); + } else { + params[key] = [existing, value]; + } + } + + return params; +} diff --git a/frontend/src/core/network/requests-lazy.ts b/frontend/src/core/network/requests-lazy.ts index 9840cb86ff8..5e54ac99c7e 100644 --- a/frontend/src/core/network/requests-lazy.ts +++ b/frontend/src/core/network/requests-lazy.ts @@ -63,6 +63,7 @@ const ACTIONS: Record = { sendSave: "waitForConnectionOpen", invokeAiTool: "waitForConnectionOpen", sendFunctionRequest: "waitForConnectionOpen", + sendUpdateQueryParams: "waitForConnectionOpen", // Session-based operations that wait for connection sendRename: "waitForConnectionOpen", diff --git a/frontend/src/core/network/requests-network.ts b/frontend/src/core/network/requests-network.ts index 6a394a54a79..7f231fd9bb2 100644 --- a/frontend/src/core/network/requests-network.ts +++ b/frontend/src/core/network/requests-network.ts @@ -55,6 +55,14 @@ export function createNetworkRequests(): EditRequests & RunRequests { }) .then(handleResponseReturnNull); }, + sendUpdateQueryParams: (request) => { + return getClient() + .POST("/api/kernel/update_query_params", { + body: request, + params: getParams(), + }) + .then(handleResponseReturnNull); + }, sendRestart: () => { return getClient() .POST("/api/kernel/restart_session", { diff --git a/frontend/src/core/network/requests-static.ts b/frontend/src/core/network/requests-static.ts index 285d117b65a..77a0c3558d2 100644 --- a/frontend/src/core/network/requests-static.ts +++ b/frontend/src/core/network/requests-static.ts @@ -26,6 +26,10 @@ export function createStaticRequests(): EditRequests & RunRequests { Logger.log("Viewing as static notebook"); return null; }, + sendUpdateQueryParams: async () => { + Logger.log("Query params not updated in static mode"); + return null; + }, sendFunctionRequest: async () => { toast({ title: "Static notebook", diff --git a/frontend/src/core/network/requests-toasting.tsx b/frontend/src/core/network/requests-toasting.tsx index f91e960df19..5a34e197a95 100644 --- a/frontend/src/core/network/requests-toasting.tsx +++ b/frontend/src/core/network/requests-toasting.tsx @@ -20,6 +20,7 @@ export function createErrorToastingRequests( sendModelValue: "Failed to update model value", sendInstantiate: "Failed to instantiate", sendFunctionRequest: "Failed to send function request", + sendUpdateQueryParams: "Failed to update query params", sendRestart: "Failed to restart", sendDocumentTransaction: "Failed to sync document transaction", sendRun: "Failed to run", diff --git a/frontend/src/core/network/types.ts b/frontend/src/core/network/types.ts index c1ade525fb8..ed910ec434b 100644 --- a/frontend/src/core/network/types.ts +++ b/frontend/src/core/network/types.ts @@ -104,6 +104,7 @@ export type StdinRequest = schemas["StdinRequest"]; export type SuccessResponse = schemas["SuccessResponse"]; export type UpdateUIElementValuesRequest = schemas["UpdateUIElementValuesRequest"]; +export type UpdateQueryParamsRequest = schemas["UpdateQueryParamsRequest"]; export type UsageResponse = paths["/api/usage"]["get"]["responses"]["200"]["content"]["application/json"]; export type WorkspaceFilesRequest = schemas["WorkspaceFilesRequest"]; @@ -131,6 +132,7 @@ export interface RunRequests { sendModelValue: (request: ModelRequest) => Promise; sendInstantiate: (request: InstantiateNotebookRequest) => Promise; sendFunctionRequest: (request: InvokeFunctionRequest) => Promise; + sendUpdateQueryParams: (request: UpdateQueryParamsRequest) => Promise; } /** diff --git a/frontend/src/core/wasm/bridge.ts b/frontend/src/core/wasm/bridge.ts index 5cc30aa5b68..081a722ad4a 100644 --- a/frontend/src/core/wasm/bridge.ts +++ b/frontend/src/core/wasm/bridge.ts @@ -421,6 +421,16 @@ export class PyodideBridge implements RunRequests, EditRequests { return null; }; + sendUpdateQueryParams: RunRequests["sendUpdateQueryParams"] = async ( + request, + ) => { + await this.putControlRequest({ + type: "update-query-params", + ...request, + }); + return null; + }; + sendFunctionRequest: RunRequests["sendFunctionRequest"] = async (request) => { await this.putControlRequest({ type: "invoke-function", diff --git a/frontend/src/core/websocket/useMarimoKernelConnection.tsx b/frontend/src/core/websocket/useMarimoKernelConnection.tsx index 73f610bbba3..c5aceaba971 100644 --- a/frontend/src/core/websocket/useMarimoKernelConnection.tsx +++ b/frontend/src/core/websocket/useMarimoKernelConnection.tsx @@ -1,7 +1,7 @@ /* Copyright 2026 Marimo. All rights reserved. */ import { useAtom, useSetAtom } from "jotai"; -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import { useErrorBoundary } from "react-error-boundary"; import { toast } from "@/components/ui/use-toast"; import { getNotebook, useCellActions } from "@/core/cells/cells"; @@ -54,7 +54,10 @@ import { handleKernelReady, handleRemoveUIElements, } from "../kernel/handlers"; -import { queryParamHandlers } from "../kernel/queryParamHandlers"; +import { + queryParamHandlers, + parseQueryParams, +} from "../kernel/queryParamHandlers"; import type { SessionId } from "../kernel/session"; import { kernelStateAtom } from "../kernel/state"; import { type LayoutState, useLayoutActions } from "../layout/layout"; @@ -543,5 +546,20 @@ export function useMarimoKernelConnection(opts: { }, }); + // Listen for browser back/forward navigation to update query params + useEffect(() => { + const handlePopState = () => { + const queryParams = parseQueryParams(); + // Import at runtime to avoid circular dependency + import("../network/requests").then(({ getRequestClient }) => { + const client = getRequestClient(); + void client.sendUpdateQueryParams({ queryParams }); + }); + }; + + window.addEventListener("popstate", handlePopState); + return () => window.removeEventListener("popstate", handlePopState); + }, []); + return { connection, reconnect }; } diff --git a/marimo/_cli/development/commands.py b/marimo/_cli/development/commands.py index 8a06b019221..6043ae5170e 100644 --- a/marimo/_cli/development/commands.py +++ b/marimo/_cli/development/commands.py @@ -341,6 +341,7 @@ def _generate_server_api_schema() -> dict[str, Any]: snippets.Snippet, snippets.Snippets, commands.UpdateUIElementCommand, + commands.UpdateQueryParamsCommand, # Requests/responses completion.VariableContext, completion.SchemaColumn, @@ -454,6 +455,7 @@ def _generate_server_api_schema() -> dict[str, Any]: models.NotebookDocumentTransactionRequest, models.FocusCellRequest, models.UpdateUIElementValuesRequest, + models.UpdateQueryParamsRequest, models.UpdateUIElementRequest, models.UpdateUserConfigRequest, models.ModelRequest, diff --git a/marimo/_runtime/commands.py b/marimo/_runtime/commands.py index 6134dec0afc..da88d6a09f2 100644 --- a/marimo/_runtime/commands.py +++ b/marimo/_runtime/commands.py @@ -456,6 +456,19 @@ def ids_and_values(self) -> list[tuple[UIElementId, Any]]: return list(zip(self.object_ids, self.values, strict=False)) +class UpdateQueryParamsCommand(Command): + """Update query parameters from browser navigation. + + Triggered when user navigates using browser back/forward buttons. + Updates query params state and re-executes dependent cells. + + Attributes: + query_params: New query parameter values from URL. + """ + + query_params: SerializedQueryParams + + class InvokeFunctionCommand(Command): """Invoke a function from a UI element. @@ -903,6 +916,7 @@ class GetCacheInfoCommand(Command): | InstallPackagesCommand # UI element and widget model operations | UpdateUIElementCommand + | UpdateQueryParamsCommand | ModelCommand | InvokeFunctionCommand # User/configuration operations diff --git a/marimo/_runtime/kernel_request_handlers.py b/marimo/_runtime/kernel_request_handlers.py index d271b75c217..965d0b152ce 100644 --- a/marimo/_runtime/kernel_request_handlers.py +++ b/marimo/_runtime/kernel_request_handlers.py @@ -28,6 +28,7 @@ StopKernelCommand, SyncGraphCommand, UpdateCellConfigCommand, + UpdateQueryParamsCommand, UpdateUIElementCommand, UpdateUserConfigCommand, ) @@ -67,6 +68,9 @@ def register(self, router: RequestRouter) -> None: router.register( UpdateUIElementCommand, self._handle_set_ui_element_value ) + router.register( + UpdateQueryParamsCommand, self._handle_update_query_params + ) router.register(ModelCommand, self._handle_receive_model_message) router.register(UpdateUserConfigCommand, self._handle_set_user_config) router.register(StopKernelCommand, self._handle_stop) @@ -129,6 +133,17 @@ async def _handle_set_ui_element_value( ) broadcast_notification(CompletedRunNotification()) + async def _handle_update_query_params( + self, request: UpdateQueryParamsCommand + ) -> None: + k = self._kernel + k.query_params._params.clear() + k.query_params._params.update(request.query_params) + k.query_params._set_value(k.query_params._params) + if k.state_updates: + await k._run_cells(set()) + broadcast_notification(CompletedRunNotification()) + async def _handle_pdb_request(self, request: DebugCellCommand) -> None: await self._kernel.pdb_request(request.cell_id) diff --git a/marimo/_server/api/endpoints/execution.py b/marimo/_server/api/endpoints/execution.py index e4aa718fbc1..886f61bf762 100644 --- a/marimo/_server/api/endpoints/execution.py +++ b/marimo/_server/api/endpoints/execution.py @@ -26,6 +26,7 @@ InvokeFunctionRequest, ModelRequest, SuccessResponse, + UpdateQueryParamsRequest, UpdateUIElementValuesRequest, ) from marimo._server.router import APIRouter @@ -119,6 +120,35 @@ async def set_model_values( return await dispatch_control_request(request, ModelRequest) +@router.post("/update_query_params") +@requires("read") +async def update_query_params( + *, + request: Request, +) -> BaseResponse: + """ + parameters: + - in: header + name: Marimo-Session-Id + schema: + type: string + required: true + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateQueryParamsRequest" + responses: + 200: + description: Update query parameters from browser navigation + content: + application/json: + schema: + $ref: "#/components/schemas/SuccessResponse" + """ + return await dispatch_control_request(request, UpdateQueryParamsRequest) + + @router.post("/instantiate") @requires("edit") async def instantiate( diff --git a/marimo/_server/models/models.py b/marimo/_server/models/models.py index c21a652adac..3567162b9da 100644 --- a/marimo/_server/models/models.py +++ b/marimo/_server/models/models.py @@ -28,6 +28,7 @@ StorageDownloadCommand, StorageListEntriesCommand, UpdateCellConfigCommand, + UpdateQueryParamsCommand, UpdateUIElementCommand, UpdateUserConfigCommand, ValidateSQLCommand, @@ -80,6 +81,13 @@ def as_command(self) -> UpdateUIElementCommand: ) +class UpdateQueryParamsRequest(UpdateQueryParamsCommand, tag=False): + def as_command(self) -> UpdateQueryParamsCommand: + return UpdateQueryParamsCommand( + query_params=self.query_params, + ) + + class ModelRequest(ModelCommand, tag=False): def as_command(self) -> ModelCommand: return ModelCommand( diff --git a/packages/openapi/api.yaml b/packages/openapi/api.yaml index 27dc721cb70..aac9b531edd 100644 --- a/packages/openapi/api.yaml +++ b/packages/openapi/api.yaml @@ -2539,6 +2539,7 @@ components: - $ref: '#/components/schemas/UpdateCellConfigCommand' - $ref: '#/components/schemas/InstallPackagesCommand' - $ref: '#/components/schemas/UpdateUIElementCommand' + - $ref: '#/components/schemas/UpdateQueryParamsCommand' - $ref: '#/components/schemas/ModelCommand' - $ref: '#/components/schemas/InvokeFunctionCommand' - $ref: '#/components/schemas/UpdateUserConfigCommand' @@ -2582,6 +2583,7 @@ components: storage-list-entries: '#/components/schemas/StorageListEntriesCommand' sync-graph: '#/components/schemas/SyncGraphCommand' update-cell-config: '#/components/schemas/UpdateCellConfigCommand' + update-query-params: '#/components/schemas/UpdateQueryParamsCommand' update-ui-element: '#/components/schemas/UpdateUIElementCommand' update-user-config: '#/components/schemas/UpdateUserConfigCommand' validate-sql: '#/components/schemas/ValidateSQLCommand' @@ -5248,6 +5250,42 @@ components: - cellIdsToOutput title: UpdateCellOutputsRequest type: object + UpdateQueryParamsCommand: + description: "Update query parameters from browser navigation.\n\n Triggered\ + \ when user navigates using browser back/forward buttons.\n Updates query\ + \ params state and re-executes dependent cells.\n\n Attributes:\n \ + \ query_params: New query parameter values from URL." + properties: + queryParams: + additionalProperties: + anyOf: + - type: string + - items: + type: string + type: array + type: object + type: + enum: + - update-query-params + required: + - type + - queryParams + title: UpdateQueryParamsCommand + type: object + UpdateQueryParamsRequest: + properties: + queryParams: + additionalProperties: + anyOf: + - type: string + - items: + type: string + type: array + type: object + required: + - queryParams + title: UpdateQueryParamsRequest + type: object UpdateUIElementCommand: description: "Update UI element values.\n\n Triggered when users interact\ \ with UI elements (sliders, inputs, dropdowns, etc.).\n Updates element\ @@ -6758,6 +6796,26 @@ paths: type: string type: object description: Successfully closed existing sessions + /api/kernel/update_query_params: + post: + parameters: + - in: header + name: Marimo-Session-Id + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateQueryParamsRequest' + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + description: Update query parameters from browser navigation /api/lsp/health: get: responses: diff --git a/packages/openapi/src/api.ts b/packages/openapi/src/api.ts index e4b05c760a1..d4e6d133ffa 100644 --- a/packages/openapi/src/api.ts +++ b/packages/openapi/src/api.ts @@ -2604,6 +2604,47 @@ export interface paths { patch?: never; trace?: never; }; + "/api/kernel/update_query_params": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header: { + "Marimo-Session-Id": string; + }; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateQueryParamsRequest"]; + }; + }; + responses: { + /** @description Update query parameters from browser navigation */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SuccessResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/lsp/health": { parameters: { query?: never; @@ -4908,6 +4949,7 @@ export interface components { | components["schemas"]["UpdateCellConfigCommand"] | components["schemas"]["InstallPackagesCommand"] | components["schemas"]["UpdateUIElementCommand"] + | components["schemas"]["UpdateQueryParamsCommand"] | components["schemas"]["ModelCommand"] | components["schemas"]["InvokeFunctionCommand"] | components["schemas"]["UpdateUserConfigCommand"] @@ -6574,6 +6616,29 @@ export interface components { ]; }; }; + /** + * UpdateQueryParamsCommand + * @description Update query parameters from browser navigation. + * + * Triggered when user navigates using browser back/forward buttons. + * Updates query params state and re-executes dependent cells. + * + * Attributes: + * query_params: New query parameter values from URL. + */ + UpdateQueryParamsCommand: { + queryParams: { + [key: string]: string | string[]; + }; + /** @enum {unknown} */ + type: "update-query-params"; + }; + /** UpdateQueryParamsRequest */ + UpdateQueryParamsRequest: { + queryParams: { + [key: string]: string | string[]; + }; + }; /** * UpdateUIElementCommand * @description Update UI element values.