Skip to content

Commit 167a01e

Browse files
authored
refactor(ui): live preview iframe loader (#16004)
Continuation of #15999. Without optimistically loading the live preview window, the initial loading state is left unhandled when toggling into live preview for the first time. This causes users to see blank content until the iframe has loaded, without indication that something is happening. Now, we display a loading indicator while the iframe request is in flight, that notably: 1. Only shows if the load takes longer than x ms. 2. If shown, is forced to remain in place for _at least_ x ms to avoid flickering, even if the request finishes before then. Before: https://github.com/user-attachments/assets/70852e80-ac0e-4b96-b59e-8817c65629a6 After: https://github.com/user-attachments/assets/5fda30d9-8c87-42bc-ba20-a8f7ae8f1245 Related: - The `ShimmerEffect` component now accepts arbitrary HTML attributes and supports a new `transparent` prop --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1213727956884086
1 parent e90fb96 commit 167a01e

11 files changed

Lines changed: 311 additions & 90 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
@layer payload-default {
2+
.iframe-loader__container {
3+
width: 100%;
4+
height: 100%;
5+
position: relative;
6+
flex: 1;
7+
min-height: 0;
8+
}
9+
10+
.iframe-loader__iframe {
11+
background-color: white;
12+
border: 0;
13+
width: 100%;
14+
height: 100%;
15+
transform-origin: top left;
16+
}
17+
18+
.iframe-loader__iframe--is-loading {
19+
opacity: 0;
20+
}
21+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react'
2+
3+
import { ShimmerEffect } from '../ShimmerEffect/index.js'
4+
import './index.scss'
5+
6+
export type IframeLoaderProps = {
7+
ref?: React.Ref<HTMLIFrameElement>
8+
} & React.IframeHTMLAttributes<HTMLIFrameElement>
9+
10+
const shimmerDelays = {
11+
minVisible: 300,
12+
show: 1000,
13+
}
14+
15+
const baseClass = 'iframe-loader'
16+
17+
/**
18+
* Loads an `iframe` element with the given source behind a loading indicator.
19+
* Notable behaviors:
20+
* 1. Only show if the load takes longer than x ms.
21+
* 2. Once shown, force it to be visible for at least x ms to avoid flickering, even if the iframe loads before then.
22+
*/
23+
export const IframeLoader: React.FC<IframeLoaderProps> = ({
24+
onLoad: onLoadFromProps,
25+
src,
26+
title,
27+
...rest
28+
}) => {
29+
const [isLoading, setIsLoading] = useState(Boolean(src))
30+
const [showShimmer, setShowShimmer] = useState(false)
31+
const shimmerShownAtRef = useRef(0)
32+
33+
const showTimerRef = useRef<null | ReturnType<typeof setTimeout>>(null)
34+
35+
const hideTimerRef = useRef<null | ReturnType<typeof setTimeout>>(null)
36+
37+
const clearTimers = useCallback(() => {
38+
if (showTimerRef.current) {
39+
clearTimeout(showTimerRef.current)
40+
showTimerRef.current = null
41+
}
42+
43+
if (hideTimerRef.current) {
44+
clearTimeout(hideTimerRef.current)
45+
hideTimerRef.current = null
46+
}
47+
}, [])
48+
49+
useEffect(() => {
50+
clearTimers()
51+
setIsLoading(Boolean(src))
52+
setShowShimmer(false)
53+
shimmerShownAtRef.current = 0
54+
55+
if (!src) {
56+
return
57+
}
58+
59+
showTimerRef.current = setTimeout(() => {
60+
setShowShimmer(true)
61+
shimmerShownAtRef.current = Date.now()
62+
showTimerRef.current = null
63+
}, shimmerDelays.show)
64+
65+
return () => {
66+
if (showTimerRef.current) {
67+
clearTimeout(showTimerRef.current)
68+
showTimerRef.current = null
69+
}
70+
}
71+
}, [clearTimers, src])
72+
73+
useEffect(() => {
74+
return () => {
75+
clearTimers()
76+
}
77+
}, [clearTimers])
78+
79+
const onLoad = useCallback<React.IframeHTMLAttributes<HTMLIFrameElement>['onLoad']>(
80+
(e) => {
81+
if (typeof onLoadFromProps === 'function') {
82+
onLoadFromProps(e)
83+
}
84+
85+
setIsLoading(false)
86+
87+
if (showTimerRef.current) {
88+
clearTimeout(showTimerRef.current)
89+
showTimerRef.current = null
90+
}
91+
92+
if (!showShimmer) {
93+
return
94+
}
95+
96+
const elapsed = Date.now() - shimmerShownAtRef.current
97+
const remainingVisible = Math.max(0, shimmerDelays.minVisible - elapsed)
98+
99+
if (remainingVisible === 0) {
100+
setShowShimmer(false)
101+
return
102+
}
103+
104+
hideTimerRef.current = setTimeout(() => {
105+
setShowShimmer(false)
106+
hideTimerRef.current = null
107+
}, remainingVisible)
108+
},
109+
[showShimmer, onLoadFromProps],
110+
)
111+
112+
return (
113+
<div className={`${baseClass}__container`}>
114+
{showShimmer && <ShimmerEffect aria-live="polite" height="100%" role="status" transparent />}
115+
<iframe
116+
{...rest}
117+
className={[`${baseClass}__iframe`, isLoading && `${baseClass}__iframe--is-loading`]
118+
.filter(Boolean)
119+
.join(' ')}
120+
onLoad={onLoad}
121+
// eslint-disable-next-line
122+
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-downloads"
123+
src={src}
124+
title={title}
125+
/>
126+
</div>
127+
)
128+
}

packages/ui/src/elements/LivePreview/IFrame/index.scss

Lines changed: 0 additions & 9 deletions
This file was deleted.

packages/ui/src/elements/LivePreview/IFrame/index.tsx

Lines changed: 0 additions & 27 deletions
This file was deleted.

packages/ui/src/elements/LivePreview/Window/index.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,8 @@ import { useDocumentEvents } from '../../../providers/DocumentEvents/index.js'
1010
import { useDocumentInfo } from '../../../providers/DocumentInfo/index.js'
1111
import { useLivePreviewContext } from '../../../providers/LivePreview/context.js'
1212
import { useLocale } from '../../../providers/Locale/index.js'
13-
import { ShimmerEffect } from '../../ShimmerEffect/index.js'
13+
import { IframeLoader } from '../../IframeLoader/index.js'
1414
import { DeviceContainer } from '../Device/index.js'
15-
import { IFrame } from '../IFrame/index.js'
1615
import { LivePreviewToolbar } from '../Toolbar/index.js'
1716
import './index.scss'
1817

@@ -27,8 +26,10 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
2726
loadedURL,
2827
popupRef,
2928
previewWindowType,
29+
setLoadedURL,
3030
shouldRenderIframe,
3131
url,
32+
zoom,
3233
} = useLivePreviewContext()
3334

3435
const locale = useLocale()
@@ -135,7 +136,20 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
135136
<LivePreviewToolbar {...props} />
136137
<div className={`${baseClass}__main`}>
137138
<DeviceContainer>
138-
{url && shouldRenderIframe ? <IFrame /> : <ShimmerEffect height="100%" />}
139+
{shouldRenderIframe && (
140+
<IframeLoader
141+
className="live-preview-iframe"
142+
id="live-preview-iframe"
143+
onLoad={() => {
144+
setLoadedURL(url)
145+
}}
146+
ref={iframeRef}
147+
src={url}
148+
style={{
149+
transform: typeof zoom === 'number' ? `scale(${zoom}) ` : undefined,
150+
}}
151+
/>
152+
)}
139153
</DeviceContainer>
140154
</div>
141155
</div>

packages/ui/src/elements/ShimmerEffect/index.scss

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
@layer payload-default {
22
.shimmer-effect {
3+
--shine-bg: var(--theme-elevation-50);
4+
--shine-fg: var(--theme-elevation-150);
5+
36
position: relative;
47
overflow: hidden;
5-
background-color: var(--theme-elevation-50);
8+
background-color: var(--shine-bg);
69

710
&__shine {
811
position: absolute;
@@ -14,13 +17,18 @@
1417
opacity: 0.75;
1518
background: linear-gradient(
1619
100deg,
17-
var(--theme-elevation-50) 0%,
18-
var(--theme-elevation-50) 15%,
19-
var(--theme-elevation-150) 50%,
20-
var(--theme-elevation-50) 85%,
21-
var(--theme-elevation-50) 100%
20+
var(--shine-bg) 0%,
21+
var(--shine-bg) 15%,
22+
var(--shine-fg) 50%,
23+
var(--shine-bg) 85%,
24+
var(--shine-bg) 100%
2225
);
2326
}
27+
28+
&--transparent {
29+
--shine-bg: transparent;
30+
--shine-fg: var(--theme-elevation-100);
31+
}
2432
}
2533

2634
@keyframes shimmer {

packages/ui/src/elements/ShimmerEffect/index.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,32 @@ export type ShimmerEffectProps = {
99
readonly className?: string
1010
readonly disableInlineStyles?: boolean
1111
readonly height?: number | string
12+
/**
13+
* When true, adjusts the gradient to allow the natural background of the element to shine through.
14+
*/
15+
transparent?: boolean
1216
readonly width?: number | string
13-
}
17+
} & React.HTMLAttributes<HTMLDivElement>
18+
19+
const baseClass = 'shimmer-effect'
1420

1521
export const ShimmerEffect: React.FC<ShimmerEffectProps> = ({
1622
animationDelay = '0ms',
1723
className,
1824
disableInlineStyles = false,
1925
height = '60px',
26+
transparent,
2027
width = '100%',
28+
...rest
2129
}) => {
2230
return (
2331
<div
24-
className={['shimmer-effect', className].filter(Boolean).join(' ')}
32+
{...rest}
33+
className={[baseClass, transparent && `${baseClass}--transparent`, className]
34+
.filter(Boolean)
35+
.join(' ')}
2536
style={{
37+
...(rest?.style || {}),
2638
height: !disableInlineStyles && (typeof height === 'number' ? `${height}px` : height),
2739
width: !disableInlineStyles && (typeof width === 'number' ? `${width}px` : width),
2840
}}

test/__helpers/e2e/live-preview/toggleLivePreview.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,42 @@
1-
import type { Page, Route } from '@playwright/test'
1+
import type { FrameLocator, Page, Route } from '@playwright/test'
22

33
import { expect } from '@playwright/test'
44

5+
/**
6+
* Match this to whatever the component uses.
7+
*/
8+
const livePreviewIframeID = 'live-preview-iframe'
9+
10+
export const getLivePreviewIframe = async (
11+
page: Page,
12+
options?: {
13+
expectIframeSrcToMatch?: RegExp
14+
},
15+
): Promise<{
16+
frame: FrameLocator
17+
iframe: ReturnType<Page['locator']>
18+
}> => {
19+
const { expectIframeSrcToMatch } = options || {}
20+
21+
const iframe = page.locator(`#${livePreviewIframeID}`)
22+
23+
if (expectIframeSrcToMatch) {
24+
await expect.poll(async () => iframe.getAttribute('src')).toMatch(expectIframeSrcToMatch)
25+
}
26+
27+
const frame = getLivePreviewIframeFrame(page)
28+
29+
return {
30+
iframe,
31+
frame,
32+
}
33+
}
34+
35+
export const getLivePreviewIframeFrame = (page: Page): FrameLocator => {
36+
const frame = page.frameLocator(`#${livePreviewIframeID}`)
37+
return frame
38+
}
39+
540
const endpoint = '**/api/payload-preferences/**'
641

742
export const toggleLivePreview = async (
@@ -38,14 +73,16 @@ export const toggleLivePreview = async (
3873
await toggler.click()
3974
hasClickedToggler = true
4075
await expect(toggler).not.toHaveClass(/live-preview-toggler--active/)
41-
await expect(page.locator('iframe.live-preview-iframe')).toBeHidden()
76+
const { iframe } = await getLivePreviewIframe(page)
77+
await expect(iframe).toBeHidden()
4278
}
4379

4480
if (!isActive && (options?.targetState === 'on' || !options?.targetState)) {
4581
await toggler.click()
4682
hasClickedToggler = true
4783
await expect(toggler).toHaveClass(/live-preview-toggler--active/)
48-
await expect(page.locator('iframe.live-preview-iframe')).toBeVisible()
84+
const { iframe } = await getLivePreviewIframe(page)
85+
await expect(iframe).toBeVisible()
4986
}
5087

5188
if (hasClickedToggler) {

0 commit comments

Comments
 (0)