-
Notifications
You must be signed in to change notification settings - Fork 3k
/
ComponentInstance.tsx
395 lines (362 loc) · 13.4 KB
/
ComponentInstance.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
/**
* Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2024)
*
* 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 React, { ReactElement, useEffect, useState, useRef } from "react"
import { withTheme } from "@emotion/react"
import queryString from "query-string"
import AlertElement from "@streamlit/lib/src/components/elements/AlertElement"
import { Skeleton } from "@streamlit/lib/src/components/elements/Skeleton"
import ErrorElement from "@streamlit/lib/src/components/shared/ErrorElement"
import { Kind } from "@streamlit/lib/src/components/shared/AlertContainer"
import useTimeout from "@streamlit/lib/src/hooks/useTimeout"
import {
ComponentInstance as ComponentInstanceProto,
ISpecialArg,
} from "@streamlit/lib/src/proto"
import { EmotionTheme } from "@streamlit/lib/src/theme"
import {
DEFAULT_IFRAME_FEATURE_POLICY,
DEFAULT_IFRAME_SANDBOX_POLICY,
} from "@streamlit/lib/src/util/IFrameUtil"
import { logWarning } from "@streamlit/lib/src/util/log"
import { WidgetStateManager } from "@streamlit/lib/src/WidgetStateManager"
import {
COMMUNITY_URL,
COMPONENT_DEVELOPER_URL,
} from "@streamlit/lib/src/urls"
import { ensureError } from "@streamlit/lib/src/util/ErrorHandling"
import { ComponentRegistry } from "./ComponentRegistry"
import {
Args,
DataframeArg,
IframeMessageHandlerProps,
createIframeMessageHandler,
parseArgs,
sendRenderMessage,
} from "./componentUtils"
/**
* 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 {
registry: ComponentRegistry
widgetMgr: WidgetStateManager
disabled: boolean
element: ComponentInstanceProto
width: number
theme: EmotionTheme
}
/**
* 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 (url != null && url !== "") {
src = url
} else {
src = componentRegistry.getComponentURL(componentName, "index.html")
}
// Add streamlitUrl query parameter to src
const currentUrl = new URL(window.location.href)
src = queryString.stringifyUrl({
url: src,
query: { streamlitUrl: currentUrl.origin + currentUrl.pathname },
})
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 [componentError, setComponentError] = useState<Error>()
const { disabled, element, registry, theme, widgetMgr, width } = props
const { componentName, jsonArgs, specialArgs, url } = element
const [parsedNewArgs, parsedDataframeArgs] = tryParseArgs(
jsonArgs,
specialArgs,
setComponentError,
componentError
)
// 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(
parsedArgsRef.current.dataframeArgs,
parsedDataframeArgs
)
parsedArgsRef.current.args = parsedNewArgs
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 clearTimeoutLog = useTimeout(
() => logWarning(getWarnMessage(componentName, url)),
COMPONENT_READY_WARNING_TIME_MS / 4
)
const clearTimeoutWarningElement = useTimeout(
() => setIsReadyTimeout(true),
COMPONENT_READY_WARNING_TIME_MS
)
// 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) {
logWarning(`handleSetFrameHeight: missing 'height' prop`)
return
}
if (height === frameHeight) {
// Nothing to do!
return
}
if (iframeRef.current == null) {
// This should not be possible.
logWarning(`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()
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
setIsReadyTimeout(false)
}
// Update the reference fields for the callback that we
// passed to the componentRegistry
onBackMsgRef.current = {
isReady: isReadyRef.current,
element,
widgetMgr,
setComponentError,
componentReadyCallback,
frameHeightCallback: handleSetFrameHeight,
}
}, [
componentName,
disabled,
element,
frameHeight,
haveDataframeArgsChanged,
isReadyTimeout,
jsonArgs,
theme,
widgetMgr,
clearTimeoutWarningElement,
clearTimeoutLog,
])
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
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
height={frameHeight === undefined ? undefined : `${frameHeight}px`}
/>
)
// If we've timed out waiting for the READY message from the component,
// display a warning.
const warns =
!isReadyRef.current && isReadyTimeout ? (
<AlertElement
width={width}
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 Skeletion instead
//
// TODO: make sure horizontal scrolling still works!
return (
<>
{loadingSkeleton}
{warns}
<iframe
allow={DEFAULT_IFRAME_FEATURE_POLICY}
ref={iframeRef}
src={getSrc(componentName, registry, url)}
width={width}
// for undefined height we set the height to 0 to avoid inconsistent behavior
height={frameHeight ?? 0}
style={{
colorScheme: "light dark",
display: isReadyRef.current ? "initial" : "none",
}}
scrolling="no"
sandbox={DEFAULT_IFRAME_SANDBOX_POLICY}
title={componentName}
/>
</>
)
}
export default withTheme(ComponentInstance)