/
vshmidt
/
streamlit
Обзор
Документация
Войти
/
vshmidt
/
streamlit
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
frontend/lib/src/components/widgets/CustomComponent/ComponentInstance.tsx
472 строки
16 KB
Ken McGrady
Update copyright header to 2026 (#13491)
02 янв 2026, 16:21
Не верифицирован
02 янв 2026, 16:21
374540a
Код
Авторство
О чём код?
/** * 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 { memo, ReactElement, useEffect, useMemo, useRef, useState, } from "react" import { getLogger } from "loglevel" import queryString from "query-string" import { flushSync } from "react-dom" import { ComponentInstance as ComponentInstanceProto, ISpecialArg, Skeleton as SkeletonProto, } from "@streamlit/protobuf" import { StreamlitConfig } from "@streamlit/utils" import { withCalculatedWidth } from "~lib/components/core/Layout/withCalculatedWidth" import AlertElement from "~lib/components/elements/AlertElement" import { Skeleton } from "~lib/components/elements/Skeleton" import { Kind } from "~lib/components/shared/AlertContainer" import ErrorElement from "~lib/components/shared/ErrorElement" import { useEmotionTheme } from "~lib/hooks/useEmotionTheme" import useTimeout from "~lib/hooks/useTimeout" import { COMMUNITY_URL, COMPONENT_DEVELOPER_URL } from "~lib/urls" import { ensureError } from "~lib/util/ErrorHandling" import { DEFAULT_IFRAME_FEATURE_POLICY, DEFAULT_IFRAME_SANDBOX_POLICY, } from "~lib/util/IFrameUtil" import { isNullOrUndefined, notNullOrUndefined } from "~lib/util/utils" import { WidgetStateManager } from "~lib/WidgetStateManager" import { ComponentRegistry } from "./ComponentRegistry" import { Args, createIframeMessageHandler, DataframeArg, IframeMessageHandlerProps, parseArgs, sendRenderMessage, } from "./componentUtils" import { StyledComponentIframe } from "./styled-components" const LOG = getLogger("ComponentInstance") /** * If we haven't received a COMPONENT_READY message this many seconds * after the component has been created, explain to the user that there * may be a problem with their component, and offer troubleshooting advice. */ export const COMPONENT_READY_WARNING_TIME_MS = 60000 // 60 seconds export interface Props { widgetMgr: WidgetStateManager disabled: boolean element: ComponentInstanceProto width: number fragmentId?: string componentRegistry: ComponentRegistry } /** * Create the iFrame `src` based on the passed `url` or, if missing, from the ComponentRegistry. Adds a `streamlitUrl` query parameter. * @param componentName name of the component. Only used when `url` is empty * @param componentRegistry component registry to get the `url` for the passed component name if `url` is empty * @param url used as the `src` if passed * @returns the iFrame src including a `streamlitUrl` query parameter */ function getSrc( componentName: string, componentRegistry: ComponentRegistry, url?: string ): string { let src: string if (notNullOrUndefined(url) && url !== "") { src = url } else { src = componentRegistry.getComponentURL(componentName, "index.html") } // Add streamlitUrl query parameter to src const customComponentClientId = StreamlitConfig.CUSTOM_COMPONENT_CLIENT_ID const currentUrl = new URL(window.location.href) src = queryString.stringifyUrl({ url: src, query: { streamlitUrl: currentUrl.origin + currentUrl.pathname, ...(customComponentClientId && { __streamlit_parent_client_id: customComponentClientId, }), }, }) return src } /** * Creates a warn message. The message is different based on whether or not a `url` is provided. * @param componentName * @param url * @returns the created warn message */ function getWarnMessage(componentName: string, url?: string): string { let message: string if (url && url !== "") { message = `Your app is having trouble loading the **${componentName}** component.` + `\nThe app is attempting to load the component from **${url}**,` + `\nand hasn't received its \`Streamlit.setComponentReady()\` message.` + `\n\nIf this is a development build, have you started the dev server?` + `\n\nFor more troubleshooting help, please see the [Streamlit Component docs](${COMPONENT_DEVELOPER_URL}) or visit our [forums](${COMMUNITY_URL}).` } else { message = `Your app is having trouble loading the **${componentName}** component.` + `\n\nIf this is an installed component that works locally, the app may be having trouble accessing the component frontend assets due to network latency or proxy settings in your app deployment.` + `\n\nFor more troubleshooting help, please see the [Streamlit Component docs](${COMPONENT_DEVELOPER_URL}) or visit our [forums](${COMMUNITY_URL}).` } return message } function tryParseArgs( jsonArgs: string, specialArgs: ISpecialArg[], setComponentError: (e: Error) => void, componentError?: Error ): [newArgs: Args, dataframeArgs: DataframeArg[]] { if (!componentError) { try { return parseArgs(jsonArgs, specialArgs) } catch (e) { const error = ensureError(e) setComponentError(error) } } return [{}, []] } /** * Compare the two DataframeArg arrays * * @param previousDataframeArgs * @param newDataframeArgs * @returns true if the two DataframeArg arrays are equal or if all their key-value pairs (first level only) are equal */ function compareDataframeArgs( previousDataframeArgs: DataframeArg[], newDataframeArgs: DataframeArg[] ): boolean { return ( previousDataframeArgs === newDataframeArgs || (previousDataframeArgs.length === newDataframeArgs.length && previousDataframeArgs.every((previousDataframeArg, i) => { const newDataframeArg = newDataframeArgs[i] return ( previousDataframeArg.key === newDataframeArg.key && previousDataframeArg.value === newDataframeArg.value ) })) ) } /** * Render the component element. If an error occurs when parsing the arguments, * an error element is rendered instead. If the component assets take too long to load as specified * by {@link COMPONENT_READY_WARNING_TIME_MS}, a warning element is rendered instead. */ function ComponentInstance(props: Props): ReactElement { const theme = useEmotionTheme() const [componentError, setComponentError] = useState<Error>() const { disabled, element, widgetMgr, width, fragmentId, componentRegistry: registry, } = props const { componentName, jsonArgs, specialArgs, url } = element const [parsedNewArgs, parsedDataframeArgs] = tryParseArgs( jsonArgs, specialArgs, setComponentError, componentError ) const componentSourceUrl = useMemo( () => getSrc(componentName, registry, url), [componentName, registry, url] ) // Use a ref for the args so that we can use them inside the useEffect calls without the linter complaining // as in the useEffect dependencies array, we don't use the parsed arg objects, but their string representation // and a comparing function result for the jsonArgs and dataframeArgs, respectively, for deep-equal checks and to // prevent calling useEffect too often const parsedArgsRef = useRef<{ args: Args; dataframeArgs: DataframeArg[] }>({ args: {}, dataframeArgs: [], }) const haveDataframeArgsChanged = compareDataframeArgs( // eslint-disable-next-line react-hooks/refs -- TODO: Do not access ref during render parsedArgsRef.current.dataframeArgs, parsedDataframeArgs ) // eslint-disable-next-line react-hooks/refs -- TODO: Do not access ref during render parsedArgsRef.current.args = parsedNewArgs // eslint-disable-next-line react-hooks/refs -- TODO: Do not access ref during render parsedArgsRef.current.dataframeArgs = parsedDataframeArgs const [isReadyTimeout, setIsReadyTimeout] = useState<boolean>() // By passing the args.height here, we can derive the initial height for // custom components that define a height property, e.g. in Python // my_custom_component(height=100). undefined means no explicit height // was specified, but will be set to the default height of 0. const [frameHeight, setFrameHeight] = useState<number | undefined>(() => isNaN(parsedNewArgs.height) ? undefined : parsedNewArgs.height ) // Use a ref for the ready-state so that we can differentiate between sending renderMessages due to props-changes // and when the componentReady callback is called (for the first time) const isReadyRef = useRef<boolean>(false) const iframeRef = useRef<HTMLIFrameElement>(null) const onBackMsgRef = useRef<IframeMessageHandlerProps>() // Show a log in the console as a soft-warning to the developer before showing the more disrupting warning element const { clear: clearTimeoutLog } = useTimeout( () => LOG.warn(getWarnMessage(componentName, url)), COMPONENT_READY_WARNING_TIME_MS / 4 ) const { clear: clearTimeoutWarningElement } = useTimeout(() => { /* eslint-disable-next-line @eslint-react/dom/no-flush-sync -- To keep * behavior the same as before introducing `createRoot` and after, we ensure * that the state updates are flushed immediately. */ flushSync(() => { setIsReadyTimeout(true) }) }, COMPONENT_READY_WARNING_TIME_MS) useEffect(() => { // Iframe onerror event unreliable - check custom component // src on mount to catch iframe load errors // eslint-disable-next-line @typescript-eslint/no-floating-promises registry.checkSourceUrlResponse(componentSourceUrl, componentName) }, [componentSourceUrl, componentName, registry]) useEffect(() => { if (isReadyTimeout && !isReadyRef.current) { // Send timeout error if we've timed out waiting for the READY message from the component LOG.error( `Client Error: Custom Component ${componentName} timeout error` ) registry.sendTimeoutError(componentSourceUrl, componentName) } }, [isReadyTimeout, componentSourceUrl, componentName, registry]) // Send a render message to the custom component everytime relevant props change, such as the // input args or the theme / width useEffect(() => { if (!isReadyRef.current) { return } sendRenderMessage( parsedArgsRef.current.args, parsedArgsRef.current.dataframeArgs, disabled, theme, iframeRef.current ?? undefined ) }, [disabled, frameHeight, haveDataframeArgsChanged, jsonArgs, theme, width]) useEffect(() => { const handleSetFrameHeight = (height: number | undefined): void => { if (height === undefined) { LOG.warn(`handleSetFrameHeight: missing 'height' prop`) return } if (height === frameHeight) { // Nothing to do! return } if (isNullOrUndefined(iframeRef.current)) { // This should not be possible. LOG.warn(`handleSetFrameHeight: missing our iframeRef!`) return } // We shove our new frameHeight directly into our iframe, to avoid // triggering a re-render. Otherwise, components will receive the RENDER // event several times during startup (because they will generally // immediately change their frameHeight after mounting). This is wasteful, // and it also breaks certain components. iframeRef.current.height = height.toString() /* eslint-disable-next-line @eslint-react/dom/no-flush-sync -- To keep * behavior the same as before introducing `createRoot` and after, we ensure * that the state updates are flushed immediately. */ flushSync(() => { setFrameHeight(height) }) } const componentReadyCallback = (): void => { // Send a render message whenever the custom component sends a ready message sendRenderMessage( parsedArgsRef.current.args, parsedArgsRef.current.dataframeArgs, disabled, theme, iframeRef.current ?? undefined ) clearTimeoutLog() clearTimeoutWarningElement() isReadyRef.current = true /* eslint-disable-next-line @eslint-react/dom/no-flush-sync -- To keep * behavior the same as before introducing `createRoot` and after, we ensure * that the state updates are flushed immediately. */ flushSync(() => { setIsReadyTimeout(false) }) } // Update the reference fields for the callback that we // passed to the componentRegistry onBackMsgRef.current = { // isReady is a callback to ensure the caller receives the latest value isReady: () => isReadyRef.current, element, widgetMgr, setComponentError, componentReadyCallback, frameHeightCallback: handleSetFrameHeight, fragmentId, } }, [ componentName, disabled, element, frameHeight, haveDataframeArgsChanged, isReadyTimeout, jsonArgs, theme, widgetMgr, clearTimeoutWarningElement, clearTimeoutLog, fragmentId, ]) useEffect(() => { const contentWindow: Window | undefined = iframeRef.current?.contentWindow ?? undefined if (!contentWindow) { return } // By creating the callback using the reference variable, we // can access up-to-date information from the component when the callback // is called without the need to re-register the callback registry.registerListener( contentWindow, createIframeMessageHandler(onBackMsgRef) ) // De-register component when unmounting and when effect is re-running return () => { if (!contentWindow) { return } registry.deregisterListener(contentWindow) } }, [registry, componentName]) if (componentError) { return ( <ErrorElement name={componentError.name} message={componentError.message} /> ) } // Show the loading Skeleton while we have not received the ready message from the custom component // but while we also have not waited until the ready timeout // eslint-disable-next-line react-hooks/refs -- TODO: Do not access ref during render const loadingSkeleton = !isReadyRef.current && !isReadyTimeout && // if height is explicitly set to 0, we don’t want to show the skeleton at all frameHeight !== 0 && ( // Skeletons will have a default height if no frameHeight was specified <Skeleton element={SkeletonProto.create({ height: frameHeight, style: SkeletonProto.SkeletonStyle.ELEMENT, })} /> ) // If we've timed out waiting for the READY message from the component, // display a warning. const warns = // eslint-disable-next-line react-hooks/refs -- TODO: Do not access ref during render !isReadyRef.current && isReadyTimeout ? ( <AlertElement body={getWarnMessage(componentName, url)} kind={Kind.WARNING} /> ) : null // Render the iframe. We set scrolling="no", because we don't want // scrollbars to appear; instead, we want components to properly auto-size // themselves. // // Without this, there is a potential for a scrollbar to // appear for a brief moment after an iframe's content gets bigger, // and before it sends the "setFrameHeight" message back to Streamlit. // // We may ultimately want to give components control over the "scrolling" // property. // // While the custom component is not in ready-state, show the loading Skeleton instead // // TODO: make sure horizontal scrolling still works! return ( <> {/* eslint-disable-next-line react-hooks/refs -- TODO: Do not access ref during render */} {loadingSkeleton} {warns} <StyledComponentIframe className="stCustomComponentV1" data-testid="stCustomComponentV1" allow={DEFAULT_IFRAME_FEATURE_POLICY} ref={iframeRef} src={componentSourceUrl} width={width} // for undefined height we set the height to 0 to avoid inconsistent behavior height={frameHeight ?? 0} scrolling="no" sandbox={DEFAULT_IFRAME_SANDBOX_POLICY} title={componentName} // eslint-disable-next-line react-hooks/refs -- TODO: Do not access ref during render componentReady={isReadyRef.current} tabIndex={element.tabIndex ?? undefined} /> </> ) } export default withCalculatedWidth(memo(ComponentInstance))