/
vshmidt
/
streamlit
Обзор
Документация
Войти
/
vshmidt
/
streamlit
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
frontend/app/src/App.tsx
2 591 строка
86 KB
Vincent Donato
Have server ack app_heartbeat messages received from client (#13810)
07 фев 2026, 01:16
Не верифицирован
07 фев 2026, 01:16
ed04c1c
Код
Авторство
О чём код?
/** * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2026) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { createRef, PureComponent, ReactNode } from "react" import classNames from "classnames" import { enableMapSet, enablePatches } from "immer" import { getLogger } from "loglevel" import { flushSync } from "react-dom" import Hotkeys from "react-hot-keys" import AppView from "@streamlit/app/src/components/AppView" import DeployButton from "@streamlit/app/src/components/DeployButton" import MainMenu from "@streamlit/app/src/components/MainMenu" import StatusWidget from "@streamlit/app/src/components/StatusWidget" import StreamlitContextProvider from "@streamlit/app/src/components/StreamlitContextProvider" import { ConnectionErrorProps, DialogProps, ScriptCompileErrorProps, StreamlitDialog, WarningProps, } from "@streamlit/app/src/components/StreamlitDialog" import { DialogType } from "@streamlit/app/src/components/StreamlitDialog/constants" import DialogErrorMessage from "@streamlit/app/src/components/StreamlitDialog/DialogErrorMessage" import { UserSettings } from "@streamlit/app/src/components/StreamlitDialog/UserSettings" import ToolbarActions from "@streamlit/app/src/components/ToolbarActions" import withScreencast, { ScreenCastHOC, } from "@streamlit/app/src/hocs/withScreencast/withScreencast" import { useViewportSize } from "@streamlit/app/src/hooks/useViewportSize" import { MetricsManager } from "@streamlit/app/src/MetricsManager" import { SessionEventDispatcher } from "@streamlit/app/src/SessionEventDispatcher" import { StyledApp } from "@streamlit/app/src/styled-components" import getBrowserInfo from "@streamlit/app/src/util/getBrowserInfo" import { AppConfig, ConnectionManager, ConnectionState, DefaultStreamlitEndpoints, ErrorDetails, IHostConfigProperties, isHostConfigBypassEnabled, LibConfig, parseUriIntoBaseParts, StreamlitEndpoints, } from "@streamlit/connection" import { AppRoot, CircularBuffer, ComponentRegistry, createAutoTheme, createCustomThemes, createFormsData, createPresetThemes, CUSTOM_THEME_AUTO_NAME, darkTheme, DeployedAppMetadata, ensureError, ensureHotkeysFilterConfigured, extractPageNameFromPathName, FileUploadClient, FormsData, generateUID, getElementId, getEmbeddingIdClassName, getIFrameEnclosingApp, getLocaleLanguage, getPreferredTheme, getQueryString, getScreencastTimestamp, getTimezone, getTimezoneOffset, getUrl, handleFavicon, hashString, hasLightBackgroundColor, HostCommunicationManager, IMenuItem, isEmbed, isInChildFrame, isKeyboardEventFromEditableTarget, isPaddingDisplayed, isPresetTheme, isScrollingHidden, isToolbarDisplayed, IToolbarItem, lightTheme, mark, measure, notUndefined, preserveEmbedQueryParams, PresetThemeName, ScriptRunState, SessionInfo, sortThemeInputKeys, ThemeConfig, toExportedTheme, WidgetStateManager, } from "@streamlit/lib" import { AuthRedirect, AutoRerun, BackMsg, Config, CustomThemeConfig, DeferredFileResponse, Delta, FileURLsResponse, ForwardMsg, ForwardMsgMetadata, GitInfo, IAppPage, ICustomThemeConfig, IGitInfo, Initialize, Logo, Navigation, NewSession, PageConfig, PageInfo, PageNotFound, PageProfile, ParentMessage, SessionEvent, SessionStatus, WidgetStates, } from "@streamlit/protobuf" import { isLocalhost, isNullOrUndefined, notNullOrUndefined, StreamlitConfig, } from "@streamlit/utils" import { showDevelopmentOptions } from "./showDevelopmentOptions" // Used to import fonts + responsive reboot items import "@streamlit/app/src/assets/css/theme.scss" import { AppNavigation, MaybeStateUpdate } from "./util/AppNavigation" import { includeIfDefined, reconcileHostConfigValues, } from "./util/hostConfigHelpers" import { ThemeManager } from "./util/useThemeManager" // vite config builds global variable PACKAGE_METADATA declare const PACKAGE_METADATA: { version: string } export interface Props { screenCast: ScreenCastHOC theme: ThemeManager streamlitExecutionStartedAt: number isMobileViewport: boolean } interface State { connectionState: ConnectionState elements: AppRoot isFullScreen: boolean scriptRunId: string scriptName: string appHash: string | null scriptRunState: ScriptRunState userSettings: UserSettings dialog?: DialogProps | null connectionErrorDismissed: boolean layout: PageConfig.Layout initialSidebarState: PageConfig.SidebarState initialSidebarWidth?: number menuItems?: PageConfig.IMenuItems | null allowRunOnSave: boolean scriptFinishedHandlers: (() => void)[] toolbarMode: Config.ToolbarMode showErrorLinks: Config.ShowErrorLinks themeHash: string gitInfo: IGitInfo | null formsData: FormsData hideTopBar: boolean hideSidebarNav: boolean expandSidebarNav: boolean navigationPosition: Navigation.Position appPages: IAppPage[] navSections: string[] // The hash of the current page executing currentPageScriptHash: string // In MPAv2, the main page is executed before and after the current // page. The main page is the script the app is started with, and the current // page is the dynamically loaded page-script. In MPAv1, the main page holds // no relevance as only one page loads at a time. mainScriptHash: string latestRunTime: number fragmentIdsThisRun: Array<string> // host communication info isOwner: boolean hostMenuItems: IMenuItem[] hostToolbarItems: IToolbarItem[] hostHideSidebarNav: boolean sidebarChevronDownshift: number pageLinkBaseUrl: string queryParams: string deployedAppMetadata: DeployedAppMetadata libConfig: LibConfig appConfig: AppConfig autoReruns: NodeJS.Timeout[] inputsDisabled: boolean scriptChangedOnDisk: boolean } const INITIAL_SCRIPT_RUN_ID = "<null>" export const LOG = getLogger("App") declare global { interface Window { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. streamlitDebug: any // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. iFrameResizer: any __streamlit_profiles__?: Record< string, CircularBuffer<{ phase: "mount" | "update" | "nested-update" actualDuration: number baseDuration: number startTime: number commitTime: number }> > } } export class App extends PureComponent<Props, State> { private readonly endpoints: StreamlitEndpoints private readonly sessionInfo = new SessionInfo() private readonly metricsMgr = new MetricsManager(this.sessionInfo) private readonly sessionEventDispatcher = new SessionEventDispatcher() private connectionManager: ConnectionManager | null private readonly widgetMgr: WidgetStateManager private readonly hostCommunicationMgr: HostCommunicationManager private readonly uploadClient: FileUploadClient private readonly componentRegistry: ComponentRegistry private readonly embeddingId: string = generateUID() /** * Ref to the root app container element. * Used by components like Sidebar to detect clicks inside/outside the app. */ private readonly appRootRef = createRef<HTMLDivElement>() // Listener registry for deferred file responses: fileId -> set of listeners private readonly deferredFileListeners = new Map< string, Set<(response: DeferredFileResponse) => void> >() private readonly appNavigation: AppNavigation private isInitializingConnectionManager: boolean = true // Whether we have received a NewSession message after the latest rerun request. // This is used to ensure that we only increment the message cache run count after // we have received a NewSession message after the latest rerun request. // This will allow us to ignore finished messages from previous script runs. private hasReceivedNewSession: boolean = false public constructor(props: Props) { super(props) // Register hotkey filter: ensureHotkeysFilterConfigured() // Initialize immerjs enablePatches() enableMapSet() this.state = { connectionState: ConnectionState.INITIAL, elements: AppRoot.empty("", true), // Blank Main Script Hash for initial render isFullScreen: false, scriptName: "", scriptRunId: INITIAL_SCRIPT_RUN_ID, appHash: null, scriptRunState: ScriptRunState.NOT_RUNNING, userSettings: { wideMode: false, runOnSave: false, }, connectionErrorDismissed: false, layout: PageConfig.Layout.CENTERED, initialSidebarState: PageConfig.SidebarState.AUTO, initialSidebarWidth: undefined, menuItems: undefined, allowRunOnSave: true, scriptFinishedHandlers: [], showErrorLinks: Config.ShowErrorLinks.SHOW_ERROR_LINKS_AUTO, // Initialize themeHash to empty string to ensure the first processThemeInput // call always processes the theme (whether null or custom theme from server). // This prevents the bug where a cached custom theme isn't cleared when the // server sends null, because null and undefined both hash to the same value. themeHash: "", gitInfo: null, formsData: createFormsData(), appPages: [], navSections: [], currentPageScriptHash: "", mainScriptHash: "", // We set hideTopBar to true by default because this information isn't // available on page load (we get it when the script begins to run), so // the user would see top bar elements for a few ms if this defaulted to // false. hideSidebarNav doesn't have this issue (app pages and the value // of the config option are received simultaneously), but we set it to // true as well for consistency. hideTopBar: true, hideSidebarNav: true, expandSidebarNav: false, toolbarMode: Config.ToolbarMode.MINIMAL, latestRunTime: performance.now(), fragmentIdsThisRun: [], // Information sent from the host isOwner: false, hostMenuItems: [], hostToolbarItems: [], hostHideSidebarNav: false, sidebarChevronDownshift: 0, pageLinkBaseUrl: "", // Initialize from URL so bound widget params from shared links are // preserved on first page navigation (before handlePageInfoChanged fires). queryParams: window.location?.search?.replace(/^\?/, "") ?? "", deployedAppMetadata: {}, libConfig: {}, appConfig: {}, autoReruns: [], inputsDisabled: false, navigationPosition: Navigation.Position.SIDEBAR, scriptChangedOnDisk: false, } this.connectionManager = null this.widgetMgr = new WidgetStateManager({ sendRerunBackMsg: this.sendRerunBackMsg, formsDataChanged: formsData => this.setState({ formsData }), }) // Sync widget URL changes to App state for page navigation preservation. this.widgetMgr.setQueryParamsChangeHandler( this.handleQueryParamsFromWidget ) this.hostCommunicationMgr = new HostCommunicationManager({ streamlitExecutionStartedAt: props.streamlitExecutionStartedAt, sendRerunBackMsg: this.sendRerunBackMsg, closeModal: this.closeDialog, stopScript: this.stopScript, rerunScript: this.rerunScript, clearCache: this.clearCache, sendAppHeartbeat: this.sendAppHeartbeat, setInputsDisabled: inputsDisabled => { this.setState({ inputsDisabled }) }, themeChanged: this.handleThemeMessage, pageChanged: pageScriptHash => this.onPageChange(pageScriptHash), isOwnerChanged: isOwner => this.setState({ isOwner }), fileUploadClientConfigChanged: config => { if (this.endpoints.setFileUploadClientConfig !== undefined) { this.endpoints.setFileUploadClientConfig(config) } }, hostMenuItemsChanged: hostMenuItems => { this.setState({ hostMenuItems }) }, hostToolbarItemsChanged: hostToolbarItems => { this.setState({ hostToolbarItems }) }, hostHideSidebarNavChanged: hostHideSidebarNav => { this.setState({ hostHideSidebarNav }) }, sidebarChevronDownshiftChanged: sidebarChevronDownshift => { this.setState({ sidebarChevronDownshift }) }, pageLinkBaseUrlChanged: pageLinkBaseUrl => { this.setState({ pageLinkBaseUrl }) }, queryParamsChanged: queryParams => { this.setState({ queryParams }) }, deployedAppMetadataChanged: deployedAppMetadata => { this.setState({ deployedAppMetadata }) }, restartWebsocketConnection: () => { if (!this.connectionManager) { // Performing an intentional restart - we want the script to rerun on load // so setting RERUN_REQUESTED so handleConnectionStateChanged triggers it this.setState({ scriptRunState: ScriptRunState.RERUN_REQUESTED }) this.initializeConnectionManager() } }, terminateWebsocketConnection: () => { this.connectionManager?.disconnect() this.connectionManager = null }, }) this.endpoints = new DefaultStreamlitEndpoints({ getServerUri: this.getBaseUriParts, csrfEnabled: true, sendClientError: ( component: string, error: string | number, message: string, source: string, customComponentName?: string ) => { this.hostCommunicationMgr.sendMessageToHost({ type: "CLIENT_ERROR", component, error, message, source, customComponentName, }) }, }) this.uploadClient = new FileUploadClient({ sessionInfo: this.sessionInfo, endpoints: this.endpoints, // A form cannot be submitted if it contains a FileUploader widget // that's currently uploading. We write that state here, in response // to a FileUploadClient callback. The FormSubmitButton element // reads the state. formsWithPendingRequestsChanged: formIds => this.widgetMgr.setFormsWithUploadsInProgress(formIds), requestFileURLs: this.requestFileURLs, }) this.componentRegistry = new ComponentRegistry(this.endpoints) this.appNavigation = new AppNavigation( this.hostCommunicationMgr, this.maybeUpdatePageUrl, this.onPageNotFound, this.onPageIconChanged ) window.streamlitDebug = { clearForwardMsgCache: this.debugClearForwardMsgCache, disconnectWebsocket: this.debugDisconnectWebsocket, shutdownRuntime: this.debugShutdownRuntime, } } private applyInitialHostConfig(): void { // Apply window host configuration early when bypass mode is enabled. // // When bypass is enabled: WebSocket connects immediately (before host-config endpoint), // so we need to apply window config early to enable host communication & apply app/lib configs // for the first render. // // When bypass is disabled: WebSocket waits for host-config endpoint anyway, // so window config will be applied during reconciliation in onHostConfigResp. // // This ensures we only set state when necessary and keeps the logic clear: // - Bypass path: Apply ONLY provided window config early, reconcile when endpoint responds // - Default path: Wait for endpoint, apply reconciled config once // // Note: We only set values that are explicitly provided. Components // are designed to handle undefined config values gracefully. // The endpoint response will fill in any missing values during reconciliation. if (!isHostConfigBypassEnabled()) { return } // isHostConfigBypassEnabled() guarantees HOST_CONFIG exists and has valid // allowedOrigins (non-empty array) and useExternalAuthToken (boolean) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const hostConfig = StreamlitConfig.HOST_CONFIG! // Build AppConfig with only provided fields - don't set defaults // Required fields are guaranteed by isHostConfigBypassEnabled() const appConfig: AppConfig = includeIfDefined<AppConfig>({ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion allowedOrigins: hostConfig.allowedOrigins!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion useExternalAuthToken: hostConfig.useExternalAuthToken!, enableCustomParentMessages: hostConfig.enableCustomParentMessages, blockErrorDialogs: hostConfig.blockErrorDialogs, }) this.hostCommunicationMgr.setAllowedOrigins(appConfig) this.setAppConfig(appConfig) // Build LibConfig with only provided fields // Note: Window config does not support deprecated setAnonymousCrossOriginPropertyOnMediaElements. // Use resourceCrossOriginMode instead. The deprecated field is only supported via endpoint. const libConfig: LibConfig = includeIfDefined<LibConfig>({ mapboxToken: hostConfig.mapboxToken, disableFullscreenMode: hostConfig.disableFullscreenMode, enforceDownloadInNewTab: hostConfig.enforceDownloadInNewTab, resourceCrossOriginMode: hostConfig.resourceCrossOriginMode, }) if (Object.keys(libConfig).length > 0) { this.setLibConfig(libConfig) } // Apply metrics config if provided if (hostConfig.metricsUrl !== undefined) { this.metricsMgr.setMetricsConfig(hostConfig.metricsUrl) } } initializeConnectionManager(): void { this.isInitializingConnectionManager = true this.applyInitialHostConfig() this.connectionManager = new ConnectionManager({ getLastSessionId: () => this.sessionInfo.last?.sessionId, endpoints: this.endpoints, onMessage: this.handleMessage, onConnectionError: this.handleConnectionError, connectionStateChanged: this.handleConnectionStateChanged, claimHostAuthToken: this.hostCommunicationMgr.claimAuthToken, resetHostAuthToken: this.hostCommunicationMgr.resetAuthToken, sendClientError: ( error: string | number, message: string, source: string ) => { this.hostCommunicationMgr.sendMessageToHost({ type: "CLIENT_ERROR", component: "Websocket Connection", error, message, source, }) }, onHostConfigResp: (response: IHostConfigProperties) => { // Reconcile window config values with endpoint response. // All provided window config values take precedence over endpoint values: // AppConfig: allowedOrigins, useExternalAuthToken, enableCustomParentMessages, blockErrorDialogs // LibConfig: mapboxToken, disableFullscreenMode, enforceDownloadInNewTab, resourceCrossOriginMode // MetricsConfig: metricsUrl const reconciledConfig = reconcileHostConfigValues( StreamlitConfig.HOST_CONFIG, response ) const { allowedOrigins, useExternalAuthToken, disableFullscreenMode, enableCustomParentMessages, mapboxToken, enforceDownloadInNewTab, metricsUrl, blockErrorDialogs, setAnonymousCrossOriginPropertyOnMediaElements, resourceCrossOriginMode, } = reconciledConfig const appConfig: AppConfig = { allowedOrigins, useExternalAuthToken, enableCustomParentMessages, blockErrorDialogs, } const libConfig: LibConfig = { mapboxToken, disableFullscreenMode, enforceDownloadInNewTab, // Use resourceCrossOriginMode if provided, otherwise fall back to // deprecated setAnonymousCrossOriginPropertyOnMediaElements (if true, use "anonymous") resourceCrossOriginMode: resourceCrossOriginMode ?? (setAnonymousCrossOriginPropertyOnMediaElements ? "anonymous" : undefined), } // Set the metrics configuration: this.metricsMgr.setMetricsConfig(metricsUrl) // Set the allowed origins configuration for the host communication: this.hostCommunicationMgr.setAllowedOrigins(appConfig) // Set the streamlit-app specific config settings in AppContext: this.setAppConfig(appConfig) // Set the streamlit-lib specific config settings in LibConfigContext: this.setLibConfig(libConfig) }, }) this.isInitializingConnectionManager = false } override componentDidMount(): void { // Initialize connection manager here, to avoid // "Can't call setState on a component that is not yet mounted." error. this.initializeConnectionManager() mark(this.state.scriptRunState) this.hostCommunicationMgr.sendMessageToHost({ type: "SCRIPT_RUN_STATE_CHANGED", scriptRunState: this.state.scriptRunState, }) if (isScrollingHidden()) { document.body.classList.add("embedded") } // Iframe resizer allows parent pages to get the height of the iframe // contents. The parent page can then reset the height to match and // avoid unnecessary scrollbars or large embeddings if (isInChildFrame()) { window.iFrameResizer = { heightCalculationMethod: () => { const taggedEls = document.querySelectorAll("[data-iframe-height]") // Use ceil to avoid fractional pixels creating scrollbars. const lowestBounds = Array.from(taggedEls).map(el => // eslint-disable-next-line streamlit-custom/no-force-reflow-access -- Existing usage Math.ceil(el.getBoundingClientRect().bottom) ) // The higher the value, the further down the page it is. // Use maximum value to get the lowest of all tagged elements. return Math.max(0, ...lowestBounds) }, } // @ts-expect-error void import("iframe-resizer/js/iframeResizer.contentWindow") } this.hostCommunicationMgr.sendMessageToHost({ type: "SET_THEME_CONFIG", themeInfo: toExportedTheme(this.props.theme.activeTheme.emotion), }) this.metricsMgr.enqueue("viewReport") window.addEventListener("popstate", this.onHistoryChange, false) } override componentDidUpdate( _prevProps: Readonly<Props>, prevState: Readonly<State> ): void { // @ts-expect-error if (window.prerenderReady === false && this.isAppInReadyState(prevState)) { // @ts-expect-error window.prerenderReady = true } if (this.state.scriptRunState !== prevState.scriptRunState) { mark(this.state.scriptRunState) if (this.state.scriptRunState === ScriptRunState.NOT_RUNNING) { try { measure( "script-run-cycle", ScriptRunState.RUNNING, ScriptRunState.NOT_RUNNING ) // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (err) { // It's okay if this fails, the `measure` call is for debugging/profiling } } this.hostCommunicationMgr.sendMessageToHost({ type: "SCRIPT_RUN_STATE_CHANGED", scriptRunState: this.state.scriptRunState, }) } } override componentWillUnmount(): void { // Needing to disconnect our connection manager + websocket connection is // only needed here to handle the case in dev mode where react hot-reloads // the client as a result of a source code change. In this scenario, the // previous websocket connection is still connected, and the client and // server end up in a reconnect loop because the server rejects attempts to // connect to an already-connected session. // // This situation doesn't exist outside of dev mode because the whole App // unmounting is either a page refresh or the browser tab closing. // // The optional chaining on connectionManager is needed to make typescript // happy since connectionManager's type is `ConnectionManager | null`, // but at this point it should always be set. this.connectionManager?.disconnect() this.hostCommunicationMgr.closeHostCommunication() window.removeEventListener("popstate", this.onHistoryChange, false) } /** * Checks whether to show error dialog or send error info * to be handled by the host. */ maybeShowErrorDialog( newDialog: WarningProps | ConnectionErrorProps | ScriptCompileErrorProps, errorMsg: string ): void { // Show dialog only if blockErrorDialogs host config is false const { blockErrorDialogs } = this.state.appConfig if (!blockErrorDialogs) { this.openDialog(newDialog) } const isScriptCompileError = newDialog.type === DialogType.SCRIPT_COMPILE_ERROR // script compile error has no title const error = isScriptCompileError ? newDialog.type : newDialog.title // Send error info to host via postMessage this.hostCommunicationMgr.sendMessageToHost({ type: "CLIENT_ERROR_DIALOG", error, message: errorMsg, }) } showError( title: string, errorDetails: ErrorDetails, dialogType: | DialogType.WARNING | DialogType.CONNECTION_ERROR = DialogType.WARNING ): void { LOG.error(errorDetails.message) const newDialog: WarningProps | ConnectionErrorProps = { type: dialogType, title, msg: ( <DialogErrorMessage message={errorDetails.message} codeBlock={errorDetails.codeBlock} /> ), onClose: () => {}, } this.maybeShowErrorDialog(newDialog, errorDetails.message) } showDeployError = ( title: string, errorNode: ReactNode, onContinue?: () => void ): void => { const newDialog: DialogProps = { type: DialogType.DEPLOY_ERROR, title, msg: errorNode, onContinue, onClose: () => {}, onTryAgain: this.sendLoadGitInfoBackMsg, } this.openDialog(newDialog) } /** * Checks if the code version from the backend is different than the frontend */ private hasStreamlitVersionChanged(initializeMsg: Initialize): boolean { let currentStreamlitVersion: string | undefined = undefined if ( StreamlitConfig.ENABLE_RELOAD_BASED_ON_HARDCODED_STREAMLIT_VERSION === true ) { currentStreamlitVersion = PACKAGE_METADATA.version } else if (this.sessionInfo.isSet) { currentStreamlitVersion = this.sessionInfo.current.streamlitVersion } if (currentStreamlitVersion) { const { environmentInfo } = initializeMsg if ( notNullOrUndefined(environmentInfo) && notNullOrUndefined(environmentInfo.streamlitVersion) ) { return currentStreamlitVersion != environmentInfo.streamlitVersion } } return false } /** * Handles theme changes from host communication. */ handleThemeMessage = ( themeName?: PresetThemeName, theme?: ICustomThemeConfig ): void => { const [, lightTheme, darkTheme] = createPresetThemes() const isUsingPresetTheme = isPresetTheme(this.props.theme.activeTheme) if (themeName === lightTheme.name && isUsingPresetTheme) { this.props.theme.setTheme(lightTheme) } else if (themeName === darkTheme.name && isUsingPresetTheme) { this.props.theme.setTheme(darkTheme) } else if (theme) { this.props.theme.setImportedTheme(theme) } } /** * Called by ConnectionManager when our connection state changes */ handleConnectionStateChanged = (newState: ConnectionState): void => { LOG.info( `Connection state changed from ${this.state.connectionState} to ${newState}` ) if (newState === ConnectionState.CONNECTED) { LOG.info("Reconnected to server.") // Reset the connection error dismissed state when we reconnect if (this.state.connectionErrorDismissed) { this.setState({ connectionErrorDismissed: false }) } // We request a script rerun if: // 1. this is the first time we establish a websocket connection to the // server, or // 2. our last script run attempt was interrupted by the websocket // connection dropping, or // 3. the host explicitly requested a reconnect (we trigger scriptRunState to be RERUN_REQUESTED) // 4. there is an indication that the script is using fragments (fragments in last run or auto-rerun), // which might need a rerun to be reinitialized if a new app session got created. const lastRunWasInterrupted = this.state.scriptRunState === ScriptRunState.RUNNING const wasRerunRequested = this.state.scriptRunState === ScriptRunState.RERUN_REQUESTED if ( !this.sessionInfo.last || lastRunWasInterrupted || wasRerunRequested || // Script is using fragments (fragments in last run or // fragment auto-reruns configured): this.state.fragmentIdsThisRun.length > 0 || this.state.autoReruns.length > 0 ) { LOG.info("Requesting a script run.") this.widgetMgr.sendUpdateWidgetsMessage(undefined) this.setState({ dialog: null }) } else if (this.state.dialog?.type === DialogType.CONNECTION_ERROR) { // Rescind the "Connection error" dialog if currently shown. this.setState({ dialog: null }) } this.hostCommunicationMgr.sendMessageToHost({ type: "WEBSOCKET_CONNECTED", }) } else { // If we're starting from the CONNECTED state and going to any other // state, we must be disconnecting. if (this.state.connectionState === ConnectionState.CONNECTED) { this.hostCommunicationMgr.sendMessageToHost({ type: "WEBSOCKET_DISCONNECTED", attemptingToReconnect: newState !== ConnectionState.DISCONNECTED_FOREVER, }) } if (this.sessionInfo.isSet) { this.sessionInfo.disconnect() } } if (this.isInitializingConnectionManager) { // If we use `flushSync` while the component is mounting, we will see a warning about // "Warning: flushSync was called from inside a lifecycle method." // The setState will be applied in the expected render cycle in this case. this.setState({ connectionState: newState }) } else { /* eslint-disable-next-line @eslint-react/dom/no-flush-sync -- * We are using `flushSync` here because there is code that expects every * state to be observed. With React batched updates, it is possible that * multiple `connectionState` changes are applied in 1 render cycle, leading * to the last state change being the only one observed. Utilizing * `flushSync` ensures that we apply every state change. */ flushSync(() => { this.setState({ connectionState: newState }) }) } } handleGitInfoChanged = (gitInfo: IGitInfo): void => { this.setState({ gitInfo, }) } handleCustomParentMessage = (parentMessage: ParentMessage): void => { if (this.state.appConfig.enableCustomParentMessages) { this.hostCommunicationMgr.sendMessageToHost({ type: "CUSTOM_PARENT_MESSAGE", message: parentMessage.message, }) } else { LOG.error( "Sending messages to the host is disabled in line with the platform policy." ) } } /** * Callback when we get a message from the server. */ handleMessage = (msgProto: ForwardMsg): void => { // We don't have an immutableProto here, so we can't use // the dispatchOneOf helper // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO: Replace 'any' with a more specific type. const dispatchProto = (obj: any, name: string, funcs: any): any => { const whichOne = obj[name] if (whichOne in funcs) { return funcs[whichOne](obj[whichOne]) } throw new Error(`Cannot handle ${name} "${whichOne}".`) } try { dispatchProto(msgProto, "type", { newSession: (newSessionMsg: NewSession) => this.handleNewSession(newSessionMsg), sessionStatusChanged: (msg: SessionStatus) => this.handleSessionStatusChanged(msg), sessionEvent: (evtMsg: SessionEvent) => this.handleSessionEvent(evtMsg), delta: (deltaMsg: Delta) => this.handleDeltaMsg( deltaMsg, msgProto.metadata as ForwardMsgMetadata ), pageConfigChanged: (pageConfig: PageConfig) => this.handlePageConfigChanged(pageConfig), pageInfoChanged: (pageInfo: PageInfo) => this.handlePageInfoChanged(pageInfo), pageNotFound: (pageNotFound: PageNotFound) => this.handlePageNotFound(pageNotFound), gitInfoChanged: (gitInfo: GitInfo) => this.handleGitInfoChanged(gitInfo), scriptFinished: (status: ForwardMsg.ScriptFinishedStatus) => this.handleScriptFinished(status), pageProfile: (pageProfile: PageProfile) => this.handlePageProfileMsg(pageProfile), autoRerun: (autoRerun: AutoRerun) => this.handleAutoRerun(autoRerun), fileUrlsResponse: (fileURLsResponse: FileURLsResponse) => this.uploadClient.onFileURLsResponse(fileURLsResponse), deferredFileResponse: (deferredFileResponse: DeferredFileResponse) => this.handleDeferredFileResponse(deferredFileResponse), parentMessage: (parentMessage: ParentMessage) => this.handleCustomParentMessage(parentMessage), logo: (logo: Logo) => this.handleLogo(logo, msgProto.metadata as ForwardMsgMetadata), navigation: (navigation: Navigation) => this.handleNavigation(navigation), authRedirect: (authRedirect: AuthRedirect) => { if (isInChildFrame()) { this.hostCommunicationMgr.sendMessageToSameOriginHost({ type: "REDIRECT_TO_URL", url: authRedirect.url, }) } else { window.location.href = authRedirect.url } }, heartbeatAck: () => this.handleHeartbeatAck(), }) } catch (e) { const err = ensureError(e) LOG.error(err) this.showError("Bad message format", { message: err.message }) } } handleLogo = (logo: Logo, metadata: ForwardMsgMetadata): void => { this.setState(prevState => { return { elements: prevState.elements.appRootWithLogo(logo, { // Pass the current page & run ID for cleanup activeScriptHash: metadata.activeScriptHash, scriptRunId: prevState.scriptRunId, }), } }) } handlePageConfigChanged = (pageConfig: PageConfig): void => { const { title, favicon, layout, initialSidebarState, initialSidebarWidth, menuItems, } = pageConfig this.appNavigation.handlePageConfigChanged(pageConfig) if (title) { this.hostCommunicationMgr.sendMessageToHost({ type: "SET_PAGE_TITLE", title, }) document.title = title } if (favicon) { this.onPageIconChanged(favicon) } // Only change layout/sidebar when the page config has changed. // This preserves the user's previous choice/default, and prevents extra re-renders. if ( layout !== this.state.layout && layout !== PageConfig.Layout.LAYOUT_UNSET ) { this.setState((prevState: State) => ({ layout, userSettings: { ...prevState.userSettings, wideMode: layout === PageConfig.Layout.WIDE, }, })) } if ( initialSidebarState !== this.state.initialSidebarState && initialSidebarState !== PageConfig.SidebarState.SIDEBAR_UNSET ) { this.setState(() => ({ initialSidebarState, })) } // Extract pixelWidth from SidebarWidthConfig message const sidebarWidthPixels = initialSidebarWidth?.pixelWidth !== undefined ? initialSidebarWidth.pixelWidth : undefined if ( notNullOrUndefined(sidebarWidthPixels) && sidebarWidthPixels !== this.state.initialSidebarWidth ) { this.setState(() => ({ initialSidebarWidth: sidebarWidthPixels, })) } // Check if menu items defined to prevent unnecessary state updates. if (menuItems) { // Now that we allow multiple set page config calls, menu items are additive // for behavior consistency with other page config properties. this.setState((prevState: State) => { if (menuItems.clearAboutMd) { menuItems.aboutSectionMd = "" } return { menuItems: { ...prevState.menuItems, ...menuItems }, } }) } } /** Callback for WidgetStateManager when bound widgets update URL params. */ handleQueryParamsFromWidget = (queryString: string): void => { this.setState({ queryParams: queryString }) this.hostCommunicationMgr.sendMessageToHost({ type: "SET_QUERY_PARAM", queryParams: queryString ? `?${queryString}` : "", }) } handlePageInfoChanged = (pageInfo: PageInfo): void => { const { queryString } = pageInfo const targetUrl = document.location.pathname + (queryString ? `?${queryString}` : "") window.history.pushState({}, "", targetUrl) this.setState({ queryParams: queryString }) this.hostCommunicationMgr.sendMessageToHost({ type: "SET_QUERY_PARAM", queryParams: queryString ? `?${queryString}` : "", }) } onPageNotFound = (pageName?: string): void => { const errMsg = pageName ? `You have requested page /${pageName}, but no corresponding file was found in the app's pages/ directory` : "The page that you have requested does not seem to exist" this.showError("Page not found", { message: `${errMsg}. Running the app's main page.`, }) } handlePageNotFound = (pageNotFound: PageNotFound): void => { const { pageName } = pageNotFound this.maybeSetState(this.appNavigation.handlePageNotFound(pageName)) } onPageIconChanged = (iconUrl: string): void => { handleFavicon( iconUrl, this.hostCommunicationMgr.sendMessageToHost, this.endpoints ) } handleNavigation = (navigation: Navigation): void => { this.setState({ navigationPosition: navigation.position }) this.maybeSetState(this.appNavigation.handleNavigation(navigation)) } handlePageProfileMsg = (pageProfile: PageProfile): void => { const pageProfileObj = PageProfile.toObject(pageProfile) const browserInfo = getBrowserInfo() this.metricsMgr.enqueue("pageProfile", { ...pageProfileObj, isFragmentRun: Boolean(pageProfileObj.isFragmentRun), numPages: this.state.appPages?.length, pageScriptHash: this.state.currentPageScriptHash, activeTheme: this.props.theme?.activeTheme?.name, totalLoadTime: Math.round( (performance.now() - this.state.latestRunTime) * 1000 ), browserInfo, }) } handleAutoRerun = (autoRerun: AutoRerun): void => { const intervalId = setInterval(() => { this.widgetMgr.sendUpdateWidgetsMessage(autoRerun.fragmentId, true) }, autoRerun.interval * 1000) this.setState((prevState: State) => { return { autoReruns: [...prevState.autoReruns, intervalId], } }) } /** * Handler for ForwardMsg.sessionStatusChanged messages * @param statusChangeProto a SessionStatus protobuf */ handleSessionStatusChanged = (statusChangeProto: SessionStatus): void => { this.setState((prevState: State) => { // Determine our new ScriptRunState let { scriptRunState } = prevState let { dialog } = prevState if ( statusChangeProto.scriptIsRunning && prevState.scriptRunState !== ScriptRunState.STOP_REQUESTED ) { // If the script is running, we change our ScriptRunState only // if we don't have a pending stop request scriptRunState = ScriptRunState.RUNNING // If the scriptCompileError dialog is open and the script starts // running, close it. if ( notNullOrUndefined(dialog) && dialog.type === DialogType.SCRIPT_COMPILE_ERROR ) { dialog = undefined } } else if ( !statusChangeProto.scriptIsRunning && prevState.scriptRunState !== ScriptRunState.RERUN_REQUESTED && prevState.scriptRunState !== ScriptRunState.COMPILATION_ERROR ) { // If the script is not running, we change our ScriptRunState only // if we don't have a pending rerun request, and we don't have // a script compilation failure scriptRunState = ScriptRunState.NOT_RUNNING } return { userSettings: { ...prevState.userSettings, runOnSave: Boolean(statusChangeProto.runOnSave), }, dialog, scriptRunState, // Reset scriptChangedOnDisk when script starts running scriptChangedOnDisk: statusChangeProto.scriptIsRunning ? false : prevState.scriptChangedOnDisk, } }) } /** * Handler for ForwardMsg.sessionEvent messages * @param sessionEvent a SessionEvent protobuf */ handleSessionEvent = (sessionEvent: SessionEvent): void => { this.sessionEventDispatcher.handleSessionEventMsg(sessionEvent) if (sessionEvent.type === "scriptCompilationException") { this.setState({ scriptRunState: ScriptRunState.COMPILATION_ERROR }) const newDialog: DialogProps = { type: DialogType.SCRIPT_COMPILE_ERROR, exception: sessionEvent.scriptCompilationException, onClose: () => {}, } this.maybeShowErrorDialog( newDialog, sessionEvent.scriptCompilationException?.message ?? "No message" ) } else if (sessionEvent.type === "scriptChangedOnDisk") { this.setState({ scriptChangedOnDisk: true }) } } /** * Updates the page url if the page has changed * @param mainPageName the name of the main page * @param newPageName the name of the new page * @param isViewingMainPage whether the user is viewing the main page */ maybeUpdatePageUrl = ( mainPageName: string, newPageName: string, isViewingMainPage: boolean ): void => { const baseUriParts = this.getBaseUriParts() if (baseUriParts) { let pathname if (StreamlitConfig.MAIN_PAGE_BASE_URL) { pathname = parseUriIntoBaseParts( StreamlitConfig.MAIN_PAGE_BASE_URL ).pathname } else { pathname = baseUriParts.pathname } const prevPageNameInPath = extractPageNameFromPathName( document.location.pathname, pathname ) const prevPageName = prevPageNameInPath === "" ? mainPageName : prevPageNameInPath // It is important to compare `newPageName` with the previous one encoded in the URL // to handle new session runs triggered by URL changes through the `onHistoryChange()` callback, // e.g. the case where the user clicks the back button. // See https://github.com/streamlit/streamlit/pull/6271#issuecomment-1465090690 for the discussion. if (prevPageName !== newPageName) { const pagePath = isViewingMainPage ? "" : newPageName const queryString = this.state.queryParams || preserveEmbedQueryParams() const qs = queryString ? `?${queryString}` : "" const basePathPrefix = pathname === "/" ? "" : pathname const pageUrl = `${basePathPrefix}/${pagePath}${qs}` window.history.pushState({}, "", pageUrl) } } } maybeSetState(stateUpdate: MaybeStateUpdate): void { if (stateUpdate) { const [newState, callback] = stateUpdate this.setState(newState as State, callback) } } /** * Handler for ForwardMsg.newSession messages. This runs on each rerun * @param newSessionProto a NewSession protobuf */ handleNewSession = (newSessionProto: NewSession): void => { const initialize = newSessionProto.initialize as Initialize if (this.hasStreamlitVersionChanged(initialize)) { window.location.reload() return } // Set this flag to indicate that we have received a NewSession message // after the latest rerun request: this.hasReceivedNewSession = true // First, handle initialization logic. Each NewSession message has // initialization data. If this is the _first_ time we're receiving // the NewSession message (or the first time since disconnect), we // perform some one-time initialization. if (!this.sessionInfo.isSet || !this.sessionInfo.current.isConnected) { // We're not initialized (this is our first time, or we are reconnected) this.handleInitialization(newSessionProto) } const { appHash, currentPageScriptHash: prevPageScriptHash } = this.state const { scriptRunId, name: scriptName, mainScriptPath, fragmentIdsThisRun, pageScriptHash: newPageScriptHash, mainScriptHash, } = newSessionProto if (!fragmentIdsThisRun.length) { // This is a normal rerun, remove all the auto reruns intervals this.cleanupAutoReruns() const config = newSessionProto.config as Config const themeInput = newSessionProto.customTheme as CustomThemeConfig this.processThemeInput(themeInput) this.setState({ allowRunOnSave: config.allowRunOnSave, hideTopBar: config.hideTopBar, toolbarMode: config.toolbarMode, showErrorLinks: config.showErrorLinks, latestRunTime: performance.now(), mainScriptHash, // If we're here, the fragmentIdsThisRun variable is always the // empty array. fragmentIdsThisRun, }) this.maybeSetState(this.appNavigation.handleNewSession(newSessionProto)) // Only set default favicon once per page load, and only if no custom icon has been set if ( !this.appNavigation.hasSetDefaultFavicon && !this.appNavigation.isPageIconSet ) { this.appNavigation.hasSetDefaultFavicon = true this.onPageIconChanged(`${import.meta.env.BASE_URL}favicon.png`) } } else { this.setState({ fragmentIdsThisRun, latestRunTime: performance.now(), }) } const newSessionHash = hashString( this.sessionInfo.current.installationId + mainScriptPath ) this.metricsMgr.setMetadata(this.state.deployedAppMetadata) this.metricsMgr.setAppHash(newSessionHash) this.metricsMgr.enqueue("updateReport") if ( appHash === newSessionHash && prevPageScriptHash === newPageScriptHash ) { this.setState(prevState => ({ // Clear the transient nodes before executing everything else. elements: prevState.elements.clearTransientNodes(fragmentIdsThisRun), scriptRunId, })) } else { this.clearAppState( newSessionHash, scriptRunId, scriptName, mainScriptHash ) } } /** * Performs initialization based on first connection and reconnection. * This is called from `handleNewSession`. */ handleInitialization = (newSessionProto: NewSession): void => { const initialize = newSessionProto.initialize as Initialize const config = newSessionProto.config as Config this.sessionInfo.setCurrent( SessionInfo.propsFromNewSessionMessage(newSessionProto) ) // eslint-disable-next-line @typescript-eslint/no-floating-promises -- TODO: Fix this this.metricsMgr.initialize({ gatherUsageStats: config.gatherUsageStats, sendMessageToHost: this.hostCommunicationMgr.sendMessageToHost, }) // Protobuf typing cannot handle complex types, so we need to cast to what // we know it should be this.handleSessionStatusChanged(initialize.sessionStatus as SessionStatus) } /** * Handler called when the history state changes, e.g. `popstate` event. */ onHistoryChange = (): void => { const { currentPageScriptHash } = this.state const targetAppPage = this.appNavigation.findPageByUrlPath( document.location.pathname ) // do not cause a rerun when an anchor is clicked and we aren't changing pages const hasAnchor = document.location.toString().includes("#") const isSamePage = targetAppPage?.pageScriptHash === currentPageScriptHash if (isNullOrUndefined(targetAppPage) || (hasAnchor && isSamePage)) { return } // Pass preserveQueryParams=true to preserve query params from the URL when // navigating via browser history (back/forward buttons). This ensures that // query params present in the URL after history navigation are sent to the // server on the first script run. this.onPageChange(targetAppPage.pageScriptHash as string, undefined, true) } /** * Both sets the given theme locally and sends it to the host. */ setAndSendTheme = (themeConfig: ThemeConfig): void => { this.props.theme.setTheme(themeConfig) this.hostCommunicationMgr.sendMessageToHost({ type: "SET_THEME_CONFIG", themeInfo: toExportedTheme(themeConfig.emotion), }) } createThemeHash = (themeInput?: CustomThemeConfig): string => { if (!themeInput) { // If themeInput is null, then we didn't receive a custom theme for this // app from the server. We use a hardcoded string literal for the // themeHash in this case. return "hash_for_undefined_custom_theme" } // Convert to JSON and back to get a plain JS object without protobuf methods/metadata. // JSON.stringify automatically filters out functions and non-enumerable properties. // This ensures we hash only the actual theme data, not the protobuf object structure. const plainObject = JSON.parse(JSON.stringify(themeInput)) // Recursively sort all keys (including nested objects like sidebar, light, dark) // to ensure consistent hashing regardless of key order const sorted = sortThemeInputKeys(plainObject) // Hash the sorted representation return hashString(JSON.stringify(sorted)) } processThemeInput(themeInput: CustomThemeConfig): void { const themeHash = this.createThemeHash(themeInput) if (themeHash === this.state.themeHash) { return } this.setState({ themeHash }) const usingCustomTheme = !isPresetTheme(this.props.theme.activeTheme) if (themeInput) { // createCustomThemes can return either 1 theme ("Custom Theme") // or 3 themes ("Custom Theme Light", "Custom Theme Dark", and "Custom Theme Auto") const customThemes = createCustomThemes(themeInput) // Add the new custom themes to the theme manager and remove the preset themes this.props.theme.addThemes(customThemes, { keepPresetThemes: false }) const mappedTheme = getPreferredTheme(customThemes) if (mappedTheme) { // User has a mappable preference - apply the full server config // while preserving their light/dark selection this.setAndSendTheme(mappedTheme) } else { // No mappable preference - set to default custom theme // This handles cases where: // - No user preference exists (userPreference === null) // - User has a preset theme cached but custom themes are now available // - User has an old custom theme that no longer matches if (customThemes.length > 1) { // When Custom Theme Light & Custom Theme Dark present, we create an auto theme based // on the system preference and set this as the active theme const autoThemeIndex = customThemes.findIndex( theme => theme.name === CUSTOM_THEME_AUTO_NAME ) this.setAndSendTheme(customThemes[autoThemeIndex]) } else { // Set to singular Custom Theme this.setAndSendTheme(customThemes[0]) } } } else { // Remove the custom theme menu option. this.props.theme.addThemes([]) if (usingCustomTheme) { const presetThemes = [lightTheme, darkTheme] const mappedTheme = getPreferredTheme(presetThemes) if (mappedTheme) { // User had a custom theme preference that maps to a preset - preserve their choice this.setAndSendTheme(mappedTheme) } else { // Reset to the auto theme this.setAndSendTheme(createAutoTheme()) } } } if ( (themeInput?.fontFaces && themeInput.fontFaces.length > 0) || (themeInput?.fontSources && themeInput.fontSources.length > 0) ) { // If font faces or font sources are provided, we need to set the imported // theme with the theme manager to make the fonts available. this.props.theme.setFonts(themeInput) } } /** * Handler for ForwardMsg.scriptFinished messages * @param status the ScriptFinishedStatus that the script finished with */ handleScriptFinished(status: ForwardMsg.ScriptFinishedStatus): void { if ( status === ForwardMsg.ScriptFinishedStatus.FINISHED_SUCCESSFULLY || status === ForwardMsg.ScriptFinishedStatus.FINISHED_EARLY_FOR_RERUN || status === ForwardMsg.ScriptFinishedStatus.FINISHED_FRAGMENT_RUN_SUCCESSFULLY ) { // eslint-disable-next-line @typescript-eslint/no-floating-promises -- TODO: Fix this Promise.resolve().then(() => { // Notify any subscribers of this event (and do it on the next cycle of // the event loop) this.state.scriptFinishedHandlers.forEach(handler => handler()) }) if ( status === ForwardMsg.ScriptFinishedStatus.FINISHED_SUCCESSFULLY || status === ForwardMsg.ScriptFinishedStatus.FINISHED_FRAGMENT_RUN_SUCCESSFULLY ) { // Clear any stale elements left over from the previous run. // We only do that for completed runs, not for runs that were finished early // due to reruns; this is to avoid flickering of elements where they disappear for // a moment and then are readded by a new session. After the new session finished, // leftover elements will be cleared after finished successfully. // We also don't do this if our script had a compilation error and didn't // finish successfully. this.setState( ({ scriptRunId, fragmentIdsThisRun, elements }) => { return { // Apply any pending elements that haven't been applied. elements: elements.clearStaleNodes( scriptRunId, fragmentIdsThisRun ), } }, () => { // Tell the WidgetManager which widgets still exist. It will remove // widget state for widgets that have been removed. const activeWidgetIds = new Set( // TODO: Update to match React best practices // eslint-disable-next-line @eslint-react/no-access-state-in-setstate Array.from(this.state.elements.getElements()) .map(element => getElementId(element)) .filter(notUndefined) ) this.widgetMgr.removeInactive(activeWidgetIds) } ) } // Tell the ConnectionManager to increment the message cache run // count. This will result in expired ForwardMsgs being removed from // the cache. We expect the sessionInfo to be populated at this point, // but we have observed race conditions tied to a rerun occurring // before a NewSession message is processed. This issue should not // disrupt users and is not a critical need for the message cache if ( this.connectionManager !== null && status !== ForwardMsg.ScriptFinishedStatus.FINISHED_EARLY_FOR_RERUN && this.sessionInfo.isSet && // We only increment the message cache run count if we have received // a NewSession message after the latest rerun request. This is done // to ignore finished messages from previous script runs, which would // cause issues with deleting cached messages that are needed for the // current script run. this.hasReceivedNewSession ) { this.connectionManager.incrementMessageCacheRunCount( this.sessionInfo.current.maxCachedMessageAge, this.state.fragmentIdsThisRun ) } } } /* * Clear all elements from the state. */ clearAppState( appHash: string, scriptRunId: string, scriptName: string, mainScriptHash: string ): void { this.setState( prevState => { const nextElements = this.appNavigation.clearPageElements( prevState.elements, mainScriptHash ) return { scriptRunId, scriptName, appHash, elements: nextElements, } }, () => { const activeWidgetIds = new Set( // TODO: Update to match React best practices // eslint-disable-next-line @eslint-react/no-access-state-in-setstate Array.from(this.state.elements.getElements()) .map(element => getElementId(element)) .filter(notUndefined) ) this.widgetMgr.removeInactive(activeWidgetIds) } ) } /** * Opens a dialog with the specified state. */ openDialog(dialogProps: DialogProps): void { this.setState({ dialog: dialogProps }) } /** * Closes the upload dialog if it's open. */ closeDialog = (): void => { // If we're closing a connection error dialog, mark it as dismissed if (this.state.dialog?.type === DialogType.CONNECTION_ERROR) { this.setState({ dialog: undefined, connectionErrorDismissed: true, }) } else { this.setState({ dialog: undefined }) } } /** * Saves a UserSettings object. */ saveSettings = (newSettings: UserSettings): void => { const { runOnSave: prevRunOnSave } = this.state.userSettings const { runOnSave } = newSettings this.setState({ userSettings: newSettings }) if (prevRunOnSave !== runOnSave && this.isServerConnected()) { const backMsg = new BackMsg({ setRunOnSave: runOnSave }) backMsg.type = "setRunOnSave" this.sendBackMsg(backMsg) } } /** * Update pendingElementsBuffer with the given Delta and set up a timer to * update state.elements. This buffer allows us to process Deltas quickly * without spamming React with too many of render() calls. */ handleDeltaMsg = ( deltaMsg: Delta, metadataMsg: ForwardMsgMetadata ): void => { // Use functional state update to ensure we have latest elements this.setState(prevState => ({ elements: prevState.elements.applyDelta( prevState.scriptRunId, deltaMsg, metadataMsg ), })) } /** * Test-only method used by e2e tests to test disabling widgets. */ debugShutdownRuntime = (): void => { if (this.isServerConnected()) { const backMsg = new BackMsg({ debugShutdownRuntime: true }) backMsg.type = "debugShutdownRuntime" this.sendBackMsg(backMsg) } } /** * Test-only method used by e2e tests to test reconnect behavior. */ debugDisconnectWebsocket = (): void => { if (this.isServerConnected()) { const backMsg = new BackMsg({ debugDisconnectWebsocket: true }) backMsg.type = "debugDisconnectWebsocket" this.sendBackMsg(backMsg) } } /** * Test-only method used by e2e tests to test fetching cached ForwardMsgs * from the server. */ debugClearForwardMsgCache = (): void => { if (!isLocalhost()) { return } // It's not a problem that we're mucking around with private fields since // this is a test-only method anyway. // @ts-expect-error this.connectionManager?.websocketConnection?.cache.messages.clear() } /** * Clear all auto reruns that were registered. This should be called whenever * the content of the auto rerun function might not be valid anymore and could * lead to issues, e.g. when a new full app-rerun session is started or the active page changed. */ cleanupAutoReruns = (): void => { this.state.autoReruns.forEach((value: NodeJS.Timeout) => { clearInterval(value) }) this.setState({ autoReruns: [] }) } /** * Reruns the script. * * @param alwaysRunOnSave a boolean. If true, UserSettings.runOnSave * will be set to true, which will result in a request to the Server * to enable runOnSave for this session. */ rerunScript = (alwaysRunOnSave = false): void => { this.closeDialog() if (!this.isServerConnected()) { LOG.error("Cannot rerun script when disconnected from server.") return } if ( this.state.scriptRunState === ScriptRunState.RUNNING || this.state.scriptRunState === ScriptRunState.RERUN_REQUESTED ) { // Don't queue up multiple rerunScript requests return } this.setState({ scriptRunState: ScriptRunState.RERUN_REQUESTED }) // Note: `rerunScript` is incorrectly called in some places. // We can remove `=== true` after adding type information if (alwaysRunOnSave === true) { // Update our run-on-save setting *before* calling rerunScript. // The rerunScript message currently blocks all BackMsgs from // being processed until the script has completed executing. this.saveSettings({ ...this.state.userSettings, runOnSave: true }) } // Trigger a full app rerun: this.widgetMgr.sendUpdateWidgetsMessage(undefined) } sendLoadGitInfoBackMsg = (): void => { if (!this.isServerConnected()) { LOG.error("Cannot load git information when disconnected from server.") return } this.sendBackMsg( new BackMsg({ loadGitInfo: true, }) ) } onPageChange = ( pageScriptHash: string, queryString?: string, preserveQueryParams?: boolean ): void => { const { elements, mainScriptHash } = this.state // We are about to change the page, so clear all auto reruns // This also happens in handleNewSession, but it might be too late compared // to small interval values, which might trigger a rerun before the new // session message is processed this.cleanupAutoReruns() // We want to keep widget states for widgets that are still active // from the common script const nextPageElements = this.appNavigation.clearPageElements( elements, mainScriptHash ) const activeWidgetIds = new Set( Array.from(nextPageElements.getElements()) .map(element => getElementId(element)) .filter(notUndefined) ) this.sendRerunBackMsg( this.widgetMgr.getActiveWidgetStates(activeWidgetIds), undefined, pageScriptHash, undefined, queryString, preserveQueryParams ) } isAppInReadyState = (prevState: Readonly<State>): boolean => { return ( this.state.connectionState === ConnectionState.CONNECTED && this.state.scriptRunState === ScriptRunState.NOT_RUNNING && prevState.scriptRunState === ScriptRunState.RUNNING && prevState.connectionState === ConnectionState.CONNECTED ) } sendRerunBackMsg = ( widgetStates?: WidgetStates, fragmentId?: string, pageScriptHash?: string, isAutoRerun?: boolean, queryStringOverride?: string, preserveQueryParams?: boolean ): void => { const baseUriParts = this.getBaseUriParts() if (!baseUriParts) { // If we don't have a connectionManager or if it doesn't have an active // websocket connection to the server (in which case // connectionManager.getBaseUriParts() returns undefined), we can't send a // rerun backMessage so just return early. LOG.error("Cannot send rerun backMessage when disconnected from server.") return } const { currentPageScriptHash } = this.state let queryString = queryStringOverride ?? this.getQueryString() let pageName = "" const contextInfo = { timezone: getTimezone(), timezoneOffset: getTimezoneOffset(), locale: getLocaleLanguage(), url: getUrl(), isEmbedded: isEmbed(), colorScheme: this.getThemeColorScheme(), } if (pageScriptHash) { // The user specified exactly which page to run. We can simply use this // value in the BackMsg we send to the server. if (pageScriptHash !== currentPageScriptHash && !preserveQueryParams) { // When switching pages, preserve only embed params and widget-bound params. // All other params (like ?foo=bar) should be cleared. const filteredParams = this.widgetMgr.filterParamsForPageChange( preserveEmbedQueryParams() ) queryString = getQueryString(queryStringOverride, filteredParams) this.setState({ queryParams: queryString }) this.hostCommunicationMgr.sendMessageToHost({ type: "SET_QUERY_PARAM", queryParams: queryString ? `?${queryString}` : "", }) } } else if (currentPageScriptHash) { // The user didn't specify which page to run, which happens when they // click the "Rerun" button in the main menu. In this case, we // rerun the current page. pageScriptHash = currentPageScriptHash } else { let pathname if (StreamlitConfig.MAIN_PAGE_BASE_URL) { pathname = parseUriIntoBaseParts( StreamlitConfig.MAIN_PAGE_BASE_URL ).pathname } else { pathname = baseUriParts.pathname } // We must be in the case where the user is navigating directly to a // non-main page of this app. Since we haven't received the list of the // app's pages from the server at this point, we fall back to requesting // the page to run via pageName, which we extract from // document.location.pathname. pageName = extractPageNameFromPathName( document.location.pathname, pathname ) pageScriptHash = "" } const cachedMessageHashes = this.connectionManager?.getCachedMessageHashes() ?? [] this.sendBackMsg( new BackMsg({ rerunScript: { queryString, widgetStates, pageScriptHash, pageName, fragmentId, isAutoRerun, cachedMessageHashes, contextInfo, }, }) ) // Reset hasReceivedNewSession to false to ensure that we are aware // if a finished message is from a previous script run. this.hasReceivedNewSession = false } /** Requests that the server stop running the script */ stopScript = (): void => { if (!this.isServerConnected()) { LOG.error("Cannot stop app when disconnected from server.") return } if ( this.state.scriptRunState === ScriptRunState.NOT_RUNNING || this.state.scriptRunState === ScriptRunState.STOP_REQUESTED ) { // Don't queue up multiple stopScript requests return } const backMsg = new BackMsg({ stopScript: true }) backMsg.type = "stopScript" this.sendBackMsg(backMsg) this.setState({ scriptRunState: ScriptRunState.STOP_REQUESTED }) } /** * Shows a dialog asking the user to confirm they want to clear the cache */ openClearCacheDialog = (): void => { if (this.isServerConnected()) { const newDialog: DialogProps = { type: DialogType.CLEAR_CACHE, confirmCallback: this.clearCache, defaultAction: this.clearCache, onClose: () => {}, } // This will be called if enter is pressed. this.openDialog(newDialog) } else { LOG.error("Cannot clear cache: disconnected from server") } } /** * Shows a dialog with Deployment instructions */ openDeployDialog = (): void => { const deployDialogProps: DialogProps = { type: DialogType.DEPLOY_DIALOG, onClose: this.closeDialog, showDeployError: this.showDeployError, isDeployErrorModalOpen: this.state.dialog?.type === DialogType.DEPLOY_ERROR, metricsMgr: this.metricsMgr, gitInfo: this.state.gitInfo, } this.openDialog(deployDialogProps) } /** * Asks the server to clear the st_cache and st_cache_data and st_cache_resource */ clearCache = (): void => { this.closeDialog() if (this.isServerConnected()) { const backMsg = new BackMsg({ clearCache: true }) backMsg.type = "clearCache" this.sendBackMsg(backMsg) } else { LOG.error("Cannot clear cache: disconnected from server") } } /** * Sends an app heartbeat message through the websocket. * @param ackTimeoutMilliseconds - If non-zero, starts a timeout expecting a * heartbeat_ack from the server within the specified milliseconds. If the * ack is not received in time, the frontend will attempt to reconnect. * This allows hosts to opt-in to connection health monitoring and configure * the timeout. */ sendAppHeartbeat = (ackTimeoutMilliseconds: number): void => { if (this.isServerConnected()) { const backMsg = new BackMsg({ appHeartbeat: true }) backMsg.type = "appHeartbeat" this.sendBackMsg(backMsg) this.connectionManager?.onHeartbeatSent(ackTimeoutMilliseconds) } else { LOG.error("Cannot send app heartbeat: disconnected from server") } } /** * Handles heartbeat acknowledgment from the server. * This confirms the connection is healthy. */ handleHeartbeatAck = (): void => { this.connectionManager?.onHeartbeatAckReceived() } /** * Sends a message back to the server. */ private readonly sendBackMsg = (msg: BackMsg): void => { if (this.connectionManager) { LOG.info(msg) this.connectionManager.sendMessage(msg) } else { // eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions -- TODO: Fix this LOG.error(`Not connected. Cannot send back message: ${msg}`) } } /** * Updates the app body when there's a connection error. */ handleConnectionError = (errDetails: ErrorDetails): void => { // Don't show the error dialog if it has been dismissed for this session if (this.state.connectionErrorDismissed) { return } // This is just a regular error dialog, but with type CONNECTION_ERROR // instead of WARNING, so we can rescind the dialog later when reconnected. this.showError("Connection error", errDetails, DialogType.CONNECTION_ERROR) } /** * Indicates whether we're connected to the server. */ isServerConnected = (): boolean => { return this.connectionManager ? this.connectionManager.isConnected() : false } settingsCallback = (animateModal = true): void => { const newDialog: DialogProps = { type: DialogType.SETTINGS, sessionInfo: this.sessionInfo, isServerConnected: this.isServerConnected(), settings: this.state.userSettings, allowRunOnSave: this.state.allowRunOnSave && showDevelopmentOptions(this.state.isOwner, this.state.toolbarMode), onSave: this.saveSettings, onClose: () => {}, animateModal, metricsMgr: this.metricsMgr, } this.openDialog(newDialog) } aboutCallback = (): void => { const { menuItems } = this.state const newDialog: DialogProps = { type: DialogType.ABOUT, onClose: this.closeDialog, aboutSectionMd: menuItems?.aboutSectionMd, } this.openDialog(newDialog) } /** * Prints the app, if the app is in IFrame * it prints the content of the IFrame. * Before printing this function ensures the app has fully loaded, * by checking if we're in ScriptRunState.NOT_RUNNING state. */ printCallback = (): void => { const { scriptRunState } = this.state if (scriptRunState !== ScriptRunState.NOT_RUNNING) { setTimeout(this.printCallback, 500) return } let windowToPrint try { const htmlIFrameElement = getIFrameEnclosingApp(this.embeddingId) if (htmlIFrameElement?.contentWindow) { windowToPrint = htmlIFrameElement.contentWindow.window } else { windowToPrint = window } // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (err) { windowToPrint = window } finally { if (!windowToPrint) windowToPrint = window windowToPrint.print() } } screencastCallback = (): void => { const { scriptName } = this.state const { startRecording } = this.props.screenCast const date = getScreencastTimestamp() startRecording(`streamlit-${scriptName}-${date}`) } handleFullScreen = (isFullScreen: boolean): void => { this.setState({ isFullScreen }) } /** * Set streamlit-lib specific configurations. */ setLibConfig = (libConfig: LibConfig): void => { this.setState({ libConfig }) } /** * Set streamlit-app specific configurations. */ setAppConfig = (appConfig: AppConfig): void => { this.setState({ appConfig }) } addScriptFinishedHandler = (func: () => void): void => { this.setState((prevState, _) => { return { scriptFinishedHandlers: prevState.scriptFinishedHandlers.concat(func), } }) } removeScriptFinishedHandler = (func: () => void): void => { this.setState((prevState, _) => { return { scriptFinishedHandlers: prevState.scriptFinishedHandlers.filter( h => h !== func ), } }) } getBaseUriParts = (): URL | undefined => this.connectionManager ? this.connectionManager.getBaseUriParts() : undefined getQueryString = (): string => { const { queryParams } = this.state const queryString = queryParams && queryParams.length > 0 ? queryParams : document.location.search return queryString.startsWith("?") ? queryString.substring(1) : queryString } getThemeColorScheme = (): string => { const { activeTheme } = this.props.theme if (hasLightBackgroundColor(activeTheme.emotion)) { return "light" } return "dark" } isInCloudEnvironment = (): boolean => { const { hostMenuItems } = this.state return hostMenuItems && hostMenuItems?.length > 0 } showDeployButton = (): boolean => { return ( showDevelopmentOptions(this.state.isOwner, this.state.toolbarMode) && !this.isInCloudEnvironment() && this.sessionInfo.isSet && !this.sessionInfo.isHello ) } deployButtonClicked = (): void => { this.metricsMgr.enqueue("menuClick", { label: "deployButtonInApp", }) this.sendLoadGitInfoBackMsg() this.openDeployDialog() } requestFileURLs = (requestId: string, files: File[]): void => { const isConnected = this.isServerConnected() const isSessionInfoSet = this.sessionInfo.isSet if (isConnected && isSessionInfoSet) { const backMsg = new BackMsg({ fileUrlsRequest: { requestId, fileNames: files.map(f => f.name), sessionId: this.sessionInfo.current.sessionId, }, }) backMsg.type = "fileUrlsRequest" this.sendBackMsg(backMsg) } else { // Reject the request immediately with an error. This can happen on mobile // browsers when the file picker is open for an extended period causing // the WebSocket connection to time out. // // We can't queue and retry because reconnection triggers a script rerun, // which remounts the FileUploader component and invalidates the promise // callback. The user needs to re-select the file after reconnection. LOG.warn( `Cannot request file URLs (isServerConnected: ${isConnected}, isSessionInfoSet: ${isSessionInfoSet})` ) this.uploadClient.onFileURLsResponse({ responseId: requestId, errorMsg: "Connection lost. Please wait for the app to reconnect, then try again.", }) } } requestDeferredFile = (fileId: string): Promise<DeferredFileResponse> => { const isConnected = this.isServerConnected() const isSessionInfoSet = this.sessionInfo.isSet if (!isConnected || !isSessionInfoSet) { return Promise.reject( new Error("Not connected to server or session not initialized") ) } const resolver = Promise.withResolvers<DeferredFileResponse>() // Register a one-time listener for this fileId const listeners = this.deferredFileListeners.get(fileId) ?? new Set<(response: DeferredFileResponse) => void>() const once = (response: DeferredFileResponse): void => { listeners.delete(once) resolver.resolve(response) } listeners.add(once) this.deferredFileListeners.set(fileId, listeners) const backMsg = new BackMsg({ deferredFileRequest: { fileId, sessionId: this.sessionInfo.current.sessionId, }, }) backMsg.type = "deferredFileRequest" this.sendBackMsg(backMsg) return resolver.promise } handleDeferredFileResponse = (response: DeferredFileResponse): void => { const listeners = this.deferredFileListeners.get(response.fileId) if (!listeners || listeners.size === 0) return // Notify and clear all listeners for this fileId for (const listener of Array.from(listeners)) { try { listener(response) } catch { // Swallow listener errors to avoid breaking notification fanout } } this.deferredFileListeners.delete(response.fileId) } handleKeyDown = (keyName: string, keyboardEvent?: KeyboardEvent): void => { // See `isKeyboardEventFromEditableTarget` for editable/shadow DOM behavior. // We never fire global single-letter shortcuts while the user is typing. if ( (keyName === "c" || keyName === "r") && isKeyboardEventFromEditableTarget(keyboardEvent) ) { return } switch (keyName) { case "c": // CLEAR CACHE if ( showDevelopmentOptions(this.state.isOwner, this.state.toolbarMode) ) { this.openClearCacheDialog() } break case "r": // RERUN this.rerunScript() break } } handleKeyUp = (keyName: string): void => { if (keyName === "esc") { this.props.screenCast.stopRecording() } } /** * Checks if there are any app-defined menu items configured via st.set_page_config */ private readonly hasAppDefinedMenuItems = (): boolean => { const { menuItems } = this.state return Boolean( menuItems?.aboutSectionMd || (menuItems?.getHelpUrl && !menuItems?.hideGetHelp) || (menuItems?.reportABugUrl && !menuItems?.hideReportABug) ) } /** * Determines whether the toolbar should be visible based on embed mode, * toolbar mode settings, and availability of host menu/toolbar items. */ private readonly shouldShowToolbar = ( hostMenuItems: IMenuItem[], hostToolbarItems: IToolbarItem[] ): boolean => { // Show toolbar if not embedded or if specifically configured to display in embed mode const isToolbarAllowedInEmbed = !isEmbed() || isToolbarDisplayed() // Determine if toolbar has content to show based on toolbar mode let hasContentToShow: boolean if (this.state.toolbarMode === Config.ToolbarMode.MINIMAL) { // In minimal mode, only show toolbar if there are menu items to display hasContentToShow = hostMenuItems.length > 0 || hostToolbarItems.length > 0 || this.hasAppDefinedMenuItems() } else { // In non-minimal modes, always show the toolbar hasContentToShow = true } return isToolbarAllowedInEmbed && hasContentToShow } override render(): JSX.Element { const { allowRunOnSave, connectionState, dialog, elements, initialSidebarState, menuItems, isFullScreen, scriptRunId, scriptRunState, userSettings, hideTopBar, hideSidebarNav, expandSidebarNav, currentPageScriptHash, hostHideSidebarNav, pageLinkBaseUrl, sidebarChevronDownshift, hostMenuItems, hostToolbarItems, libConfig, inputsDisabled, appPages, navSections, navigationPosition, scriptChangedOnDisk, } = this.state // Always use sidebar navigation on mobile, regardless of the server setting const effectiveNavigationPosition = this.props.isMobileViewport ? Navigation.Position.SIDEBAR : navigationPosition const developmentMode = showDevelopmentOptions( this.state.isOwner, this.state.toolbarMode ) const outerDivClass = classNames( "stApp", getEmbeddingIdClassName(this.embeddingId), { "streamlit-embedded": isEmbed(), "streamlit-wide": userSettings.wideMode, } ) const renderedDialog: React.ReactNode = dialog ? StreamlitDialog({ ...dialog, onClose: this.closeDialog, }) : null // Determine toolbar visibility using helper method const showToolbar = this.shouldShowToolbar(hostMenuItems, hostToolbarItems) const showPadding = !isEmbed() || isPaddingDisplayed() const disableScrolling = isScrollingHidden() return ( <StreamlitContextProvider initialSidebarState={initialSidebarState} initialSidebarWidth={this.state.initialSidebarWidth} appRootRef={this.appRootRef} pageLinkBaseUrl={pageLinkBaseUrl} currentPageScriptHash={currentPageScriptHash} onPageChange={this.onPageChange} navSections={navSections} appPages={appPages} appLogo={elements.logo} sidebarChevronDownshift={sidebarChevronDownshift} expandSidebarNav={expandSidebarNav} hideSidebarNav={ hideSidebarNav || hostHideSidebarNav || effectiveNavigationPosition === Navigation.Position.TOP } isFullScreen={isFullScreen} setFullScreen={this.handleFullScreen} activeTheme={this.props.theme.activeTheme} setTheme={this.setAndSendTheme} availableThemes={this.props.theme.availableThemes} fragmentIdsThisRun={this.state.fragmentIdsThisRun} locale={window.navigator.language} formsData={this.state.formsData} scriptRunState={scriptRunState} scriptRunId={scriptRunId} // LibConfig properties mapboxToken={libConfig.mapboxToken} enforceDownloadInNewTab={libConfig.enforceDownloadInNewTab} resourceCrossOriginMode={libConfig.resourceCrossOriginMode} showErrorLinks={this.state.showErrorLinks} requestDeferredFile={this.requestDeferredFile} > <Hotkeys keyName="r,c,esc" onKeyDown={this.handleKeyDown} onKeyUp={this.handleKeyUp} > <StyledApp ref={this.appRootRef} className={outerDivClass} data-testid="stApp" data-test-script-state={ scriptRunId == INITIAL_SCRIPT_RUN_ID ? "initial" : scriptRunState } data-test-connection-state={connectionState} > <AppView endpoints={this.endpoints} sendMessageToHost={this.hostCommunicationMgr.sendMessageToHost} elements={elements} widgetMgr={this.widgetMgr} uploadClient={this.uploadClient} navigationPosition={effectiveNavigationPosition} wideMode={userSettings.wideMode} embedded={isEmbed()} showPadding={showPadding} disableScrolling={disableScrolling} addScriptFinishedHandler={this.addScriptFinishedHandler} removeScriptFinishedHandler={this.removeScriptFinishedHandler} widgetsDisabled={ inputsDisabled || connectionState !== ConnectionState.CONNECTED } showToolbar={showToolbar} disableFullscreenMode={libConfig.disableFullscreenMode} componentRegistry={this.componentRegistry} topRightContent={ <> {!hideTopBar && ( <StatusWidget connectionState={connectionState} scriptRunState={scriptRunState} rerunScript={this.rerunScript} stopScript={this.stopScript} allowRunOnSave={allowRunOnSave} showScriptChangedActions={scriptChangedOnDisk} /> )} {!hideTopBar && ( <ToolbarActions hostToolbarItems={hostToolbarItems} sendMessageToHost={ this.hostCommunicationMgr.sendMessageToHost } metricsMgr={this.metricsMgr} /> )} {this.showDeployButton() && !scriptChangedOnDisk && ( <DeployButton onClick={this.deployButtonClicked} /> )} {!hideTopBar && ( <MainMenu isServerConnected={this.isServerConnected()} quickRerunCallback={this.rerunScript} clearCacheCallback={this.openClearCacheDialog} settingsCallback={this.settingsCallback} aboutCallback={this.aboutCallback} printCallback={this.printCallback} screencastCallback={this.screencastCallback} screenCastState={this.props.screenCast.currentState} hostMenuItems={hostMenuItems} developmentMode={developmentMode} sendMessageToHost={ this.hostCommunicationMgr.sendMessageToHost } menuItems={menuItems} metricsMgr={this.metricsMgr} toolbarMode={this.state.toolbarMode} /> )} </> } /> {renderedDialog} </StyledApp> </Hotkeys> </StreamlitContextProvider> ) } } const AppWithScreenCast = withScreencast(App) // Wrapper component to handle viewport size const AppWrapper: React.FC< Omit<Props, "isMobileViewport" | "screenCast"> > = props => { const { isMobile } = useViewportSize() return <AppWithScreenCast {...props} isMobileViewport={isMobile} /> } export default AppWrapper