-
Notifications
You must be signed in to change notification settings - Fork 272
/
Copy pathindex.tsx
306 lines (290 loc) · 8.51 KB
/
index.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
import React, { useEffect, useMemo, useRef, useState } from 'react';
import css from './style.module.css';
import BrowserChrome from '../browser-chrome';
import {
selectActiveSiteError,
useActiveSite,
useAppDispatch,
useAppSelector,
} from '../../lib/state/redux/store';
import { removeClientInfo } from '../../lib/state/redux/slice-clients';
import { bootSiteClient } from '../../lib/state/redux/boot-site-client';
import { SiteError } from '../../lib/state/redux/slice-ui';
import { Button, Spinner } from '@wordpress/components';
import {
removeSite,
selectSiteBySlug,
selectSitesLoaded,
selectTemporarySites,
} from '../../lib/state/redux/slice-sites';
import classNames from 'classnames';
export const supportedDisplayModes = [
'browser-full-screen',
'seamless',
] as const;
export type DisplayMode = (typeof supportedDisplayModes)[number];
interface PlaygroundViewportProps {
displayMode?: DisplayMode;
children?: React.ReactNode;
siteSlug?: string;
hideToolbar?: boolean;
className?: string;
}
export const PlaygroundViewport = ({
displayMode = 'browser-full-screen',
hideToolbar,
className,
}: PlaygroundViewportProps) => {
if (displayMode === 'seamless') {
return <KeepAliveTemporarySitesViewport />;
}
return (
<BrowserChrome hideToolbar={hideToolbar} className={className}>
<KeepAliveTemporarySitesViewport />
</BrowserChrome>
);
};
/**
* A multi-viewport component that keeps all rendered temporary sites alive.
* Technically, it retains their iframe node in the DOM. When the user switches
* to another site, the iframe is hidden but not removed. This way, the state
* of each temporary site is preserved as long as the browser tab remains open.
*
* Persistent sites are not affected by this. They are unmounted and rendered as usual
* as there's no risk of data loss
*/
export const KeepAliveTemporarySitesViewport = () => {
const temporarySites = useAppSelector(selectTemporarySites);
const activeSite = useActiveSite();
const siteSlugsToRender = useMemo(() => {
let sites = temporarySites.filter(
(site) => site.slug !== activeSite?.slug
);
if (activeSite) {
sites = [...sites, activeSite];
}
return sites.map((site) => site.slug);
}, [temporarySites, activeSite]);
/**
* ## Critical data loss prevention mechanism
*
* The `slugsSeenSoFar` array is necessary to keep the Playground sites running
* without being implicitly destroyed by React.
*
* ## The problem
*
* When an iframe is moved around in the DOM, its internal state is reset
* and the Playground site is lost. Unfortunately, React liberally moves
* DOM nodes around even when the `key` prop is set.
*
* Imagine we're rendering five site viewports, and a sixth site viewport is
* added at the beginning of the list in the next render.
*
* The only way to preserve the state the existing viewports, is to create a new
* DOM node for the sixth site viewport and insert it before the already existing
* viewports without moving any of the iframes or their parent nodes. Unfortunately,
* that's not what React does.
*
* I don't know exactly which DOM operations React performs, but the existing
* iframes are moved around and the Playground sites inside them are trashed
* in the process.
*
* ## The solution
*
* We never trash, reorder, or remove any DOM nodes.
*
* This append-only list of slugs is used to keep track of all the sites this
* component was ever asked to render. Every site stays in its own div, always
* at the same index in the DOM. Every new site is appended to the end of the list,
* never in the middle. When a site is deleted, we keep the top-level wrapper div
* and only remove the iframe inside it.
*
* This way, React never reassigns which div is which site and never moves our
* precious iframes around.
*
* The cost is that we render more and more divs over time. That's not a problem.
* We're talking about maybe a 100 empty divs in an extreme scenario. That's nothing.
*/
const [slugsSeenSoFar, setSlugsSeenSoFar] = useState<string[]>([]);
useEffect(() => {
setSlugsSeenSoFar((prev) => [
...prev,
...siteSlugsToRender.filter((slug) => !prev.includes(slug)),
]);
}, [siteSlugsToRender]);
const sitesFinishedLoading = useAppSelector(selectSitesLoaded);
if (!sitesFinishedLoading) {
return (
<div
className={css.fullSize}
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Spinner style={{ width: '60px', height: '60px' }} />
</div>
);
}
return (
<>
{!activeSite && (
// @TODO: Use the dedicated design for this
// (the one in Figma with white background and pretty fonts.)
<div className={css.fullSize}>
<div className={css.siteError}>
<div
className={css.siteErrorContent}
style={{ textAlign: 'center' }}
>
<h2>No site is selected</h2>
<p>
Select a site from the site manager to explore
Playground.
</p>
</div>
</div>
</div>
)}
{slugsSeenSoFar.map((slug) => (
<div
key={slug}
className={classNames(css.fullSize, {
[css.hidden]: slug !== activeSite?.slug,
})}
>
{siteSlugsToRender.includes(slug) ? (
<JustViewport key={slug} siteSlug={slug} />
) : null}
</div>
))}
</>
);
};
export const JustViewport = function JustViewport({
siteSlug,
}: {
siteSlug: string;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const site = useAppSelector((state) => selectSiteBySlug(state, siteSlug))!;
const dispatch = useAppDispatch();
const runtimeConfigString = JSON.stringify(
site.metadata.runtimeConfiguration
);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) {
return;
}
const abortController = new AbortController();
dispatch(
bootSiteClient(siteSlug, iframe, {
signal: abortController.signal,
})
);
return () => {
abortController.abort();
dispatch(removeClientInfo(siteSlug));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [siteSlug, iframeRef, runtimeConfigString]);
const error = useAppSelector(selectActiveSiteError);
if (error) {
return (
<div className={css.siteError}>
<div className={css.siteErrorContent}>
<SiteErrorMessage error={error} siteSlug={siteSlug} />
</div>
</div>
);
}
return (
<iframe
key={siteSlug}
title="WordPress Playground wrapper (the actual WordPress site is in another, nested iframe)"
className={classNames('playground-viewport', css.fullSize)}
ref={iframeRef}
/>
);
};
function SiteErrorMessage({
error,
siteSlug,
}: {
error: SiteError;
siteSlug: string;
}) {
const dispatch = useAppDispatch();
if (
error === 'directory-handle-not-found-in-indexeddb' ||
error === 'directory-handle-permission-denied'
) {
/**
* Displayed either when the directory permissions truly expired OR when we
* expected to find the directory handle in IndexedDB, but it wasn't actually there.
*
* In the latter scenario, this error message states an untrue failure reason. This
* is to keep things simple. We don't want to start explaining IndexedDB, OPFS handles
* etc. What matters is that the directory handle is gone and the site won't work until
* the user to provide a new one.
*/
return (
<>
<h1>Local directory permissions expired</h1>
<p>
You previously granted WordPress Playground access to your
local directory, but the browser no longer allows Playground
to access it.
</p>
<p>
There's no way to recover from this today. We are working on
a way of selecting the local directory again. Stay tuned,
and if you urgently need to work with this site, tell us at{' '}
<a
target="_blank"
rel="noopener noreferrer"
href="https://github.com/WordPress/wordpress-playground/issues/1746"
>
GitHub
</a>
.
</p>
</>
);
}
if (error === 'directory-handle-directory-does-not-exist') {
return (
<>
<h1>Local directory was deleted</h1>
<p>
It seems like you deleted the local directory you previously
selected.
</p>
<p>Unforunately, this site won't work anymore.</p>
<Button
onClick={() => {
dispatch(removeSite(siteSlug));
dispatch(removeClientInfo(siteSlug));
}}
>
Delete this site
</Button>
</>
);
}
return (
<>
<h1>Something went wrong</h1>
<p>An error occurred while loading your site. Please try again.</p>
<Button
onClick={() => {
window.location.reload();
}}
>
Reload the browser tab to try again
</Button>
</>
);
}