Skip to content

Commit 071c74c

Browse files
committed
Add autosaved site lifecycle APIs
1 parent aa15a20 commit 071c74c

10 files changed

Lines changed: 901 additions & 80 deletions

File tree

packages/playground/website/playwright/e2e/sites-api.spec.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ test('playgroundSites.list() returns the active site', async ({ website }) => {
2020
const active = sites.find((s: any) => s.isActive);
2121
expect(active).toBeTruthy();
2222
expect(active.slug).toBeTruthy();
23-
expect(active.storage).toBe('temporary');
23+
expect(['opfs', 'local-fs', 'temporary']).toContain(active.storage);
2424
});
2525

2626
test('playgroundSites.saveInBrowser() persists a temporary site', async ({
@@ -32,7 +32,7 @@ test('playgroundSites.saveInBrowser() persists a temporary site', async ({
3232
`This test relies on OPFS which isn't available in Playwright's flavor of ${browserName}.`
3333
);
3434

35-
await website.goto('./');
35+
await website.goto('./?storage=temp');
3636
await website.page.waitForFunction(() =>
3737
Boolean((window as any).playgroundSites?.getClient())
3838
);
@@ -60,7 +60,6 @@ test('playgroundSites.rename() renames a saved site', async ({
6060

6161
const newName = await website.page.evaluate(async () => {
6262
const api = (window as any).playgroundSites;
63-
await api.saveInBrowser();
6463
const name = 'Renamed Via API';
6564
await api.rename(name);
6665
const sites = api.list();

packages/playground/website/src/lib/state/redux/boot-site-client.ts

Lines changed: 142 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
isBlueprintBundle,
1818
} from '@wp-playground/blueprints';
1919
import { logger } from '@php-wasm/logger';
20-
import { setupPostMessageRelay } from '@php-wasm/web';
20+
import { type SyncProgress, setupPostMessageRelay } from '@php-wasm/web';
2121
import { startPlaygroundWeb } from '@wp-playground/client';
2222
import type { PlaygroundClient } from '@wp-playground/remote';
2323
import { getRemoteUrl } from '../../config';
@@ -27,7 +27,11 @@ import {
2727
setGitHubAuthRepoUrl,
2828
} from './slice-ui';
2929
import type { PlaygroundDispatch, PlaygroundReduxState } from './store';
30-
import { selectSiteBySlug } from './slice-sites';
30+
import {
31+
isAutosavedSite,
32+
selectSiteBySlug,
33+
updateSiteMetadata,
34+
} from './slice-sites';
3135
// @ts-ignore
3236
import { corsProxyUrl } from 'virtual:cors-proxy-url';
3337
import { modalSlugs } from './slice-ui';
@@ -161,6 +165,21 @@ export function bootSiteClient(
161165
? 'install-from-existing-files-if-needed'
162166
: 'download-and-install';
163167

168+
const shouldSyncNewOpfsSiteInBackground =
169+
site.metadata.storage === 'opfs' &&
170+
!!mountDescriptor &&
171+
!isWordPressInstalled;
172+
const syncOperation = isAutosavedSite(site) ? 'autosave' : 'save';
173+
const mounts =
174+
mountDescriptor && !shouldSyncNewOpfsSiteInBackground
175+
? [
176+
{
177+
...mountDescriptor,
178+
initialSyncDirection: 'opfs-to-memfs' as const,
179+
},
180+
]
181+
: [];
182+
164183
let playground: PlaygroundClient | undefined = undefined;
165184
try {
166185
const phpExtensions = phpExtensionQueryArgsToExtensionsArray(
@@ -187,14 +206,7 @@ export function bootSiteClient(
187206
},
188207
// Log Blueprint events
189208
onBlueprintValidated: logBlueprintEvents,
190-
mounts: mountDescriptor
191-
? [
192-
{
193-
...mountDescriptor,
194-
initialSyncDirection: 'opfs-to-memfs',
195-
},
196-
]
197-
: [],
209+
mounts,
198210
wordpressInstallMode,
199211
corsProxy: corsProxyUrl,
200212
gitAdditionalHeadersCallback: createGitAuthHeaders(),
@@ -290,19 +302,137 @@ export function bootSiteClient(
290302
if (signal.aborted || !playground) {
291303
return;
292304
}
305+
const connectedPlayground = playground as PlaygroundClient;
293306

294307
setupPostMessageRelay(iframe, document.location.origin);
295308

296309
dispatch(
297310
addClientInfo({
298311
siteSlug: site.slug,
299312
url: '/',
300-
client: playground,
313+
client: connectedPlayground,
301314
opfsMountDescriptor: mountDescriptor,
315+
opfsSync: shouldSyncNewOpfsSiteInBackground
316+
? {
317+
status: 'syncing',
318+
operation: syncOperation,
319+
}
320+
: undefined,
302321
})
303322
);
323+
if (site.metadata.storage !== 'none') {
324+
try {
325+
await dispatch(
326+
updateSiteMetadata({
327+
slug: site.slug,
328+
changes: {
329+
whenLastUsed: Date.now(),
330+
},
331+
})
332+
);
333+
} catch (error) {
334+
logger.error('Error updating Playground last-used time', error);
335+
}
336+
}
337+
338+
if (
339+
site.metadata.initialOpfsSyncPending &&
340+
!shouldSyncNewOpfsSiteInBackground
341+
) {
342+
try {
343+
await dispatch(
344+
updateSiteMetadata({
345+
slug: site.slug,
346+
changes: {
347+
initialOpfsSyncPending: false,
348+
},
349+
})
350+
);
351+
} catch (error) {
352+
logger.error(
353+
'Error updating Playground initial sync state',
354+
error
355+
);
356+
}
357+
}
358+
359+
if (shouldSyncNewOpfsSiteInBackground && mountDescriptor) {
360+
// Progress callbacks cross worker and iframe boundaries asynchronously.
361+
// A final progress message may arrive after mountOpfs() resolves and
362+
// must not resurrect the completed sync state in Redux.
363+
let opfsMountSettled = false;
364+
void connectedPlayground
365+
.mountOpfs(
366+
{
367+
...mountDescriptor,
368+
initialSyncDirection: 'memfs-to-opfs',
369+
},
370+
(progress: SyncProgress) => {
371+
if (opfsMountSettled) {
372+
return;
373+
}
374+
dispatch(
375+
updateClientInfo({
376+
siteSlug: site.slug,
377+
changes: {
378+
opfsSync: {
379+
status: 'syncing',
380+
progress,
381+
operation: syncOperation,
382+
},
383+
},
384+
})
385+
);
386+
}
387+
)
388+
.then(async () => {
389+
try {
390+
await dispatch(
391+
updateSiteMetadata({
392+
slug: site.slug,
393+
changes: {
394+
initialOpfsSyncPending: false,
395+
},
396+
})
397+
);
398+
} catch (error) {
399+
logger.error(
400+
'Error updating Playground initial sync state',
401+
error
402+
);
403+
}
404+
await new Promise((resolve) => setTimeout(resolve, 100));
405+
opfsMountSettled = true;
406+
dispatch(
407+
updateClientInfo({
408+
siteSlug: site.slug,
409+
changes: {
410+
opfsSync: undefined,
411+
},
412+
})
413+
);
414+
})
415+
.catch((error: unknown) => {
416+
opfsMountSettled = true;
417+
logger.error(
418+
'Error syncing saved Playground to OPFS',
419+
error
420+
);
421+
dispatch(
422+
updateClientInfo({
423+
siteSlug: site.slug,
424+
changes: {
425+
opfsSync: {
426+
status: 'error',
427+
operation: syncOperation,
428+
},
429+
},
430+
})
431+
);
432+
});
433+
}
304434

305-
(playground as PlaygroundClient).onNavigation((url) => {
435+
connectedPlayground.onNavigation((url) => {
306436
dispatch(
307437
updateClientInfo({
308438
siteSlug: site.slug,

packages/playground/website/src/lib/state/redux/persist-temporary-site.ts

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type store from './store';
1616
import { selectClientBySiteSlug, updateClientInfo } from './slice-clients';
1717
import {
1818
selectSiteBySlug,
19+
type SitePersistence,
1920
updateSite,
2021
updateSiteMetadata,
2122
} from './slice-sites';
@@ -30,6 +31,10 @@ export function persistTemporarySite(
3031
localFsHandle?: FileSystemDirectoryHandle;
3132
siteName?: string;
3233
skipRenameModal?: boolean;
34+
persistence?: SitePersistence;
35+
updateUrl?: boolean;
36+
keepOriginalUrlParams?: boolean;
37+
keepRunningClient?: boolean;
3338
} = {}
3439
) {
3540
return async (
@@ -156,13 +161,18 @@ export function persistTemporarySite(
156161
} else {
157162
throw new Error(`Unsupported device type: ${storageType}`);
158163
}
164+
const syncOperation =
165+
options.persistence === 'autosave' ? 'autosave' : 'save';
159166

160167
dispatch(
161168
updateClientInfo({
162169
siteSlug,
163170
changes: {
164171
opfsMountDescriptor: mountDescriptor,
165-
opfsSync: { status: 'syncing' },
172+
opfsSync: {
173+
status: 'syncing',
174+
operation: syncOperation,
175+
},
166176
},
167177
})
168178
);
@@ -180,6 +190,7 @@ export function persistTemporarySite(
180190
opfsSync: {
181191
status: 'syncing',
182192
progress,
193+
operation: syncOperation,
183194
},
184195
},
185196
})
@@ -203,37 +214,52 @@ export function persistTemporarySite(
203214
changes: {
204215
opfsSync: {
205216
status: 'error',
217+
operation: syncOperation,
206218
},
207219
},
208220
})
209221
);
210222
throw error;
211223
}
212224

213-
await dispatch(
214-
updateSite({
215-
slug: siteSlug,
216-
changes: {
217-
originalUrlParams: undefined,
218-
},
219-
})
220-
);
225+
if (!options.keepOriginalUrlParams) {
226+
await dispatch(
227+
updateSite({
228+
slug: siteSlug,
229+
changes: {
230+
originalUrlParams: undefined,
231+
},
232+
})
233+
);
234+
}
221235

236+
const persistedAt = Date.now();
237+
const runtimeConfiguration = {
238+
...siteInfo.metadata.runtimeConfiguration,
239+
constants: await getPlaygroundDefinedPHPConstants(playground),
240+
};
222241
await dispatch(
223242
updateSiteMetadata({
224243
slug: siteSlug,
225244
changes: {
226245
storage: storageType,
227-
// Reset the created date. Mental model: From the perspective of
228-
// the storage backend, the site was just created.
229-
whenCreated: Date.now(),
246+
persistence: options.persistence ?? 'explicit',
247+
/**
248+
* The viewport key includes whenCreated to distinguish
249+
* delete/recreate cycles for the same slug. The delayed
250+
* autosave path already has a live Playground iframe and
251+
* must not change that key, or React will remount it after
252+
* the OPFS sync completes.
253+
*/
254+
...(options.keepRunningClient
255+
? {}
256+
: { whenCreated: persistedAt }),
257+
whenLastUsed: persistedAt,
230258
// Make sure to store the constants we'll want to re-apply
231259
// on the next page load.
232-
runtimeConfiguration: {
233-
...siteInfo.metadata.runtimeConfiguration,
234-
constants:
235-
await getPlaygroundDefinedPHPConstants(playground),
236-
},
260+
...(options.keepRunningClient
261+
? {}
262+
: { runtimeConfiguration }),
237263
// If we persisted a blueprint bundle, point to it so we can
238264
// load the full bundle (not just the declaration) on next load.
239265
...(bundleWasPersisted
@@ -247,6 +273,15 @@ export function persistTemporarySite(
247273
},
248274
})
249275
);
276+
if (options.keepRunningClient) {
277+
const updatedSite = selectSiteBySlug(getState(), siteSlug);
278+
if (updatedSite?.metadata.storage !== 'none') {
279+
await opfsSiteStorage?.update(updatedSite.slug, {
280+
...updatedSite.metadata,
281+
runtimeConfiguration,
282+
});
283+
}
284+
}
250285
/**
251286
* @TODO: Fix OPFS site storage write timeout that happens alongside 2000
252287
* "Cannot read properties of undefined (reading 'apply')" errors here:
@@ -258,7 +293,9 @@ export function persistTemporarySite(
258293
const updatedState = getState();
259294
const updatedSite = selectSiteBySlug(updatedState, siteSlug);
260295
const persistentSiteUrl = PlaygroundRoute.site(updatedSite!);
261-
redirectTo(persistentSiteUrl);
296+
if (options.updateUrl !== false) {
297+
redirectTo(persistentSiteUrl);
298+
}
262299
if (!options.skipRenameModal) {
263300
dispatch(setActiveModal('rename-site'));
264301
}

0 commit comments

Comments
 (0)