Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
796f66e
Save Playgrounds by default
adamziel May 17, 2026
fc0efb3
Fix storage git internals imports
adamziel May 17, 2026
153ff51
Fix saved-by-default CI coverage
adamziel May 17, 2026
c1a4efc
Fix sites API saved-default expectation
adamziel May 17, 2026
17796f9
Use temp storage for unrelated UI specs
adamziel May 17, 2026
4551c4b
Preserve version params for saved sites
adamziel May 17, 2026
6f12383
Assert preserved query params by value
adamziel May 17, 2026
5341de3
Make saved-default UI specs deterministic
adamziel May 17, 2026
41b4f33
Align sites API default storage expectation
adamziel May 17, 2026
760c894
Use temporary storage in query API specs
adamziel May 18, 2026
9e3bb6c
Restore temporary overlay start and background saved sync
adamziel May 18, 2026
88865dc
Capture writes during background OPFS sync
adamziel May 18, 2026
02d94ad
Distinguish autosaved Playground recovery copies
adamziel May 18, 2026
e0a7517
Stabilize autosaved Playground persistence test
adamziel May 18, 2026
009a4b6
Clarify autosaved Playground retention UX
adamziel May 18, 2026
1861daa
Improve autosave promotion affordances
adamziel May 19, 2026
ed331af
Merge remote-tracking branch 'origin/trunk' into codex/default-saved-…
adamziel May 19, 2026
986122b
Fix autosave completion and saved playgrounds overlay
adamziel May 19, 2026
12c31e0
Refine saved playgrounds overlay sections
adamziel May 19, 2026
f8c2f4c
Settle autosave status after root site creation
adamziel May 19, 2026
8692677
Reorder saved playgrounds overlay sections
adamziel May 19, 2026
38bf19d
Move unsaved playground action into creation buttons
adamziel May 19, 2026
d176750
Use plus icon for new Playground action
adamziel May 19, 2026
635a756
Remove unsaved overlay action
adamziel May 19, 2026
4dd3797
Move save status actions into popovers
adamziel May 20, 2026
3f610eb
Clarify autosaves section
adamziel May 20, 2026
c7c1931
Open GitHub import modal before creating site
adamziel May 20, 2026
69fbb0a
Prompt to restore matching autosaves
adamziel May 21, 2026
51ffbf5
Show recent autosave restore as nudge
adamziel May 21, 2026
bfe15b8
Delay autosaving restore nudge fresh sites
adamziel May 21, 2026
eef16cc
Trim saved playground UI extras
adamziel May 21, 2026
ae20fe2
Remove changelog formatting noise
adamziel May 21, 2026
c51d30c
Simplify active site API E2E expectation
adamziel May 21, 2026
edcc8ed
Align OPFS switching test with autosave URLs
adamziel May 21, 2026
c8ef1a2
Update autosave restore nudge copy
adamziel May 21, 2026
e640a52
Polish autosave status popover
adamziel May 21, 2026
d1882fe
Keep fresh Playground running after dismissing restore
adamziel May 21, 2026
a485138
Fix autosave status consistency
adamziel May 21, 2026
9d52dce
Surface autosave progress when available
adamziel May 21, 2026
535ffe2
Use determinate autosave progress indicator
adamziel May 21, 2026
863eb46
Avoid pre-site unsaved status flicker
adamziel May 21, 2026
13343ff
Make Playground overlay selection immediate
adamziel May 21, 2026
b73ab8d
Avoid autosaving flicker when opening autosaves
adamziel May 21, 2026
e17bc56
Polish autosave status copy
adamziel May 21, 2026
41275b3
Track autosave sync operation
adamziel May 21, 2026
249e820
Bold autosaved status label
adamziel May 21, 2026
1daf6ea
Hide save CTA when storage is unavailable
adamziel May 21, 2026
d968a5d
Account for unavailable storage targets in temp test
adamziel May 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ You can go ahead and try it out. The Playground will automatically install the t
| `multisite` | `no` | Enables the WordPress multisite mode. Accepts `yes` or `no`. |
| `import-site` | | Imports site files and database from a ZIP file specified by a URL. |
| `import-wxr` | | Imports site content from a WXR file specified by a URL. It uses the WordPress Importer plugin, so the default admin user must be logged in. |
| `site-slug` | | Selects which site to load from browser storage. If the specified site does not exist, the user will be prompted to save a new site with the specified slug. |
| `site-slug` | | Selects which site to load from browser storage. If the specified site does not exist, Playground creates a new browser-saved site with the specified slug unless temporary storage is requested. |
| `storage` | | Controls whether the Playground is saved by default. Use `storage=temp` to create an unsaved temporary Playground that is reset when the page is refreshed or closed. |
| `language` | `en_US` | Sets the locale for the WordPress instance. This must be used in combination with `networking=yes` otherwise WordPress won't be able to download translations. |
| `core-pr` | | Installs a specific https://github.com/WordPress/wordpress-develop core PR. Accepts the PR number. For example, `core-pr=6883`. |
| `gutenberg-pr` | | Installs a specific https://github.com/WordPress/gutenberg PR. Accepts the PR number. For example, `gutenberg-pr=65337`. |
Expand Down
148 changes: 147 additions & 1 deletion packages/php-wasm/web/src/lib/directory-handle-mount.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { describe, expect, it, vi } from 'vitest';
import { __private__dont__use, type PHP } from '@php-wasm/universal';
import { Semaphore } from '@php-wasm/util';
import { logger } from '@php-wasm/logger';
import { journalFSEventsToOpfs } from './directory-handle-mount';
import {
copyMemfsToOpfs,
createDirectoryHandleMountHandler,
journalFSEventsToOpfs,
} from './directory-handle-mount';

class MemoryFileHandle {
kind = 'file' as const;
Expand Down Expand Up @@ -355,9 +359,151 @@ describe('journalFSEventsToOpfs', () => {
});
});

describe('createDirectoryHandleMountHandler', () => {
it('flushes changes made while the initial MEMFS to OPFS sync is still running', async () => {
let changedDuringInitialSync = false;
let mount: { flush(): Promise<void> } | undefined;
const { FS, files, php } = createFakePhp();
const opfsRoot = new MemoryDirectoryHandle('root', () => {
if (changedDuringInitialSync) {
return;
}
changedDuringInitialSync = true;
files.set('/wordpress/database.sqlite', encode('changed'));
FS.write({ path: '/wordpress/database.sqlite' });
});
files.set('/wordpress/database.sqlite', encode('initial'));

const mountHandler = createDirectoryHandleMountHandler(
opfsRoot as unknown as FileSystemDirectoryHandle,
{
initialSync: {
direction: 'memfs-to-opfs',
},
onMount: (createdMount) => {
mount = createdMount;
},
}
);

await mountHandler(php, FS as any, '/wordpress');
await mount!.flush();

expect(decode(opfsRoot.files.get('database.sqlite')!.bytes)).toBe(
'changed'
);
});

it('does not block the initial MEMFS to OPFS sync on the final flush', async () => {
let mount: { flush(): Promise<void> } | undefined;
let changedDuringInitialSync = false;
const { FS, files, php } = createFakePhp();
const opfsRoot = new MemoryDirectoryHandle('root', () => {
if (changedDuringInitialSync) {
return;
}
changedDuringInitialSync = true;
FS.write({ path: '/wordpress/database.sqlite' });
});
files.set('/wordpress/database.sqlite', encode('initial'));
const releaseSemaphore = await php.semaphore.acquire();

const mountHandler = createDirectoryHandleMountHandler(
opfsRoot as unknown as FileSystemDirectoryHandle,
{
initialSync: {
direction: 'memfs-to-opfs',
},
onMount: (createdMount) => {
mount = createdMount;
},
}
);

await mountHandler(php, FS as any, '/wordpress');
releaseSemaphore();
await mount!.flush();

expect(decode(opfsRoot.files.get('database.sqlite')!.bytes)).toBe(
'initial'
);
});

it('reports a flushing phase after the initial MEMFS to OPFS copy', async () => {
const progressEvents: Array<{
files: number;
total: number;
phase?: 'copying' | 'flushing';
}> = [];
const { FS, files, php } = createFakePhp();
const opfsRoot = new MemoryDirectoryHandle('root');
files.set('/wordpress/database.sqlite', encode('initial'));

const mountHandler = createDirectoryHandleMountHandler(
opfsRoot as unknown as FileSystemDirectoryHandle,
{
initialSync: {
direction: 'memfs-to-opfs',
onProgress: (progress) => {
progressEvents.push(progress);
},
},
}
);

await mountHandler(php, FS as any, '/wordpress');

expect(progressEvents[0]).toEqual({
files: 0,
total: 1,
phase: 'copying',
});
expect(progressEvents).toContainEqual({
files: 1,
total: 1,
phase: 'flushing',
});
});

it('does not emit stale copy progress after the final progress event', async () => {
vi.useFakeTimers();

try {
const progressEvents: Array<{ files: number; total: number }> = [];
const { FS, files } = createFakePhp();
const opfsRoot = new MemoryDirectoryHandle('root');
FS.readdir.mockReturnValue(['.', '..', 'first.txt', 'second.txt']);
files.set('/wordpress/first.txt', encode('first'));
files.set('/wordpress/second.txt', encode('second'));

await copyMemfsToOpfs(
FS as any,
opfsRoot as unknown as FileSystemDirectoryHandle,
'/wordpress',
(progress) => {
progressEvents.push(progress);
}
);

const progressEventCount = progressEvents.length;
await vi.advanceTimersByTimeAsync(1000);

expect(progressEvents).toHaveLength(progressEventCount);
expect(progressEvents.at(-1)).toEqual({
files: 2,
total: 2,
});
} finally {
vi.useRealTimers();
}
});
});

function createFakePhp() {
const files = new Map<string, Uint8Array>();
const FS = {
mkdirTree: vi.fn(),
readdir: vi.fn(() => ['.', '..', 'database.sqlite']),
write: vi.fn(),
truncate: vi.fn(),
unlink: vi.fn(),
Expand Down
78 changes: 65 additions & 13 deletions packages/php-wasm/web/src/lib/directory-handle-mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,12 @@ export type SyncProgress = {
files: number;
/** The number of all files that need to be synced. */
total: number;
/** The current stage of the initial sync. */
phase?: 'copying' | 'flushing';
};
export type SyncProgressCallback = (progress: SyncProgress) => void;
export type SyncProgressCallback = (
progress: SyncProgress
) => void | Promise<void>;

interface JournalFSEventsToOpfsOptions {
maxFlushPasses?: number;
Expand All @@ -74,17 +78,40 @@ export function createDirectoryHandleMountHandler(
}
FSHelpers.mkdir(FS, vfsMountPoint);
await copyOpfsToMemfs(FS, handle, vfsMountPoint);
const mount = journalFSEventsToOpfs(php, handle, vfsMountPoint);
options.onMount?.(mount);
return mount.unmount;
} else {
await copyMemfsToOpfs(
FS,
handle,
vfsMountPoint,
options.initialSync.onProgress
);
const mount = journalFSEventsToOpfs(php, handle, vfsMountPoint);
options.onMount?.(mount);
let lastProgress: SyncProgress | undefined;
try {
await copyMemfsToOpfs(
FS,
handle,
vfsMountPoint,
async (progress) => {
lastProgress = {
...progress,
phase: 'copying',
};
await options.initialSync.onProgress?.(lastProgress);
}
);
await options.initialSync.onProgress?.({
files: lastProgress?.total ?? 0,
total: lastProgress?.total ?? 0,
phase: 'flushing',
});
void mount.flush().catch((error) => {
logger.error(error);
});
} catch (error) {
await mount.unmount();
throw error;
}
return mount.unmount;
}
const mount = journalFSEventsToOpfs(php, handle, vfsMountPoint);
options.onMount?.(mount);
return mount.unmount;
};
}

Expand Down Expand Up @@ -195,6 +222,10 @@ export async function copyMemfsToOpfs(
// so we report progress. Throttle the progress callback to avoid flooding
// the main thread with excessive updates.
let numFilesCompleted = 0;
await onProgress?.({
files: numFilesCompleted,
total: filesToCreate.length,
});
const throttledProgressCallback = onProgress && throttle(onProgress, 100);

// Limit max concurrent writes because Safari may otherwise encounter
Expand Down Expand Up @@ -240,6 +271,11 @@ export async function copyMemfsToOpfs(
// to a conflict with writes from the earlier attempt.
await Promise.allSettled(concurrentWrites);
}
throttledProgressCallback?.cancel();
await onProgress?.({
files: filesToCreate.length,
total: filesToCreate.length,
});
}

function isMemfsDir(FS: Emscripten.RootFS, path: string) {
Expand Down Expand Up @@ -532,15 +568,21 @@ async function resolveParent(
return handle as any;
}

type CancelableThrottledFunction<T extends (...args: any[]) => any> = T & {
cancel(): void;
};

function throttle<T extends (...args: any[]) => any>(
fn: T,
debounceMs: number
): T {
): CancelableThrottledFunction<T> {
let lastCallTime = 0;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let pendingArgs: Parameters<T> | undefined;

return function throttledCallback(...args: Parameters<T>) {
const throttledCallback = function throttledCallback(
...args: Parameters<T>
) {
pendingArgs = args;

const timeSinceLastCall = Date.now() - lastCallTime;
Expand All @@ -552,5 +594,15 @@ function throttle<T extends (...args: any[]) => any>(
fn(...pendingArgs!);
}, delay);
}
} as T;
} as CancelableThrottledFunction<T>;

throttledCallback.cancel = () => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
timeoutId = undefined;
pendingArgs = undefined;
};

return throttledCallback;
}
4 changes: 3 additions & 1 deletion packages/playground/storage/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
"type": "module",
"types": "index.d.ts",
"dependencies": {
"pako": "^1.0.10"
"crc-32": "^1.2.0",
"pako": "^1.0.10",
"sha.js": "^2.4.12"
}
}
Loading
Loading