From b8dbc03553dc91fc71d2e914a4bce8ef7e7b44ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 7 Nov 2025 00:16:12 +0100 Subject: [PATCH 01/32] Improve Blueprint-related error reporting --- .../blueprints/src/lib/v1/compile.ts | 89 +++++++- .../blueprints/src/lib/v1/resources.ts | 11 +- .../components/playground-viewport/index.tsx | 196 ++++++++++++++++++ .../src/lib/state/redux/boot-site-client.ts | 16 ++ .../src/lib/state/redux/slice-sites.ts | 47 ++++- .../website/src/lib/state/redux/slice-ui.ts | 5 +- 6 files changed, 352 insertions(+), 12 deletions(-) diff --git a/packages/playground/blueprints/src/lib/v1/compile.ts b/packages/playground/blueprints/src/lib/v1/compile.ts index b2b3b7bb89..ace3064fc9 100644 --- a/packages/playground/blueprints/src/lib/v1/compile.ts +++ b/packages/playground/blueprints/src/lib/v1/compile.ts @@ -297,10 +297,93 @@ function compileBlueprintJson( const { valid, errors } = validateBlueprint(blueprint); if (!valid) { + // Format all validation errors with context + const errorMessages = errors! + .map((err, index) => { + const path = err.instancePath || '/'; + let message = err.message || 'validation failed'; + + // For "additional properties" errors, highlight the actual problematic key + let highlightedSnippet = ''; + if (message.includes('must NOT have additional properties')) { + // Extract the property name from the error params + const additionalProperty = (err.params as any) + ?.additionalProperty; + if (additionalProperty) { + message = `has unexpected property "${additionalProperty}"`; + + // Try to show the offending key highlighted + try { + const pathParts = path.split('/').filter(Boolean); + let currentValue: any = blueprint; + for (const part of pathParts) { + if ( + currentValue && + typeof currentValue === 'object' + ) { + currentValue = currentValue[part]; + } + } + + if ( + currentValue && + typeof currentValue === 'object' + ) { + const offendingValue = + currentValue[additionalProperty]; + const valueStr = JSON.stringify(offendingValue); + highlightedSnippet = `\n "${additionalProperty}": ${valueStr}\n ${'^'.repeat( + additionalProperty.length + 2 + )} This property is not recognized`; + } + } catch { + // If we can't extract context, that's okay + } + } + } else { + // For other errors, try to extract the offending value + try { + const pathParts = path.split('/').filter(Boolean); + let currentValue: any = blueprint; + for (const part of pathParts) { + if ( + currentValue && + typeof currentValue === 'object' + ) { + currentValue = currentValue[part]; + } + } + if (currentValue !== undefined) { + const valueStr = JSON.stringify( + currentValue, + null, + 2 + ); + // Limit snippet length + const snippet = + valueStr.length > 200 + ? valueStr.substring(0, 200) + '...' + : valueStr; + highlightedSnippet = `\n Value: ${snippet}`; + } + } catch { + // If we can't extract context, that's okay + } + } + + return `${ + index + 1 + }. At path "${path}": ${message}${highlightedSnippet}`; + }) + .join('\n\n'); + const e = new Error( - `Invalid blueprint: ${errors![0].message} at ${ - errors![0].instancePath - }` + `Invalid Blueprint: The Blueprint does not conform to the schema.\n\n` + + `Found ${ + errors!.length + } validation error(s):\n\n${errorMessages}\n\n` + + `Please review your Blueprint and fix these issues. ` + + `Learn more about the Blueprint format: https://wordpress.github.io/wordpress-playground/blueprints/data-format` ); // Attach Ajv output to the thrown object for easier debugging (e as any).errors = errors; diff --git a/packages/playground/blueprints/src/lib/v1/resources.ts b/packages/playground/blueprints/src/lib/v1/resources.ts index 675ca531f5..f25896b698 100644 --- a/packages/playground/blueprints/src/lib/v1/resources.ts +++ b/packages/playground/blueprints/src/lib/v1/resources.ts @@ -193,7 +193,16 @@ export abstract class Resource { case 'bundled': if (!streamBundledFile) { throw new Error( - 'Filesystem is required for blueprint resources' + 'Blueprint resource of type "bundled" requires a filesystem.\n\n' + + 'This Blueprint refers to files that should be bundled with it (like images, plugins, or themes), ' + + 'but the filesystem needed to access these files is not available. This usually happens when:\n\n' + + "1. You're trying to load a Blueprint as a standalone JSON file that was meant to be part of a bundle\n" + + '2. The Blueprint was not packaged correctly as a blueprint.zip file\n\n' + + 'To fix this:\n' + + "• If you're loading from a URL, make sure all referenced files are accessible relative to the Blueprint file\n" + + "• If you're using a blueprint.zip file, ensure it contains all the files referenced in the Blueprint\n" + + '• Check that the "resource": "bundled" references in your Blueprint match actual files in your bundle\n\n' + + 'Learn more about Blueprint resources: https://wordpress.github.io/wordpress-playground/blueprints/data-format#resources' ); } resource = new BundledResource( diff --git a/packages/playground/website/src/components/playground-viewport/index.tsx b/packages/playground/website/src/components/playground-viewport/index.tsx index 8d3eabca10..26f602c340 100644 --- a/packages/playground/website/src/components/playground-viewport/index.tsx +++ b/packages/playground/website/src/components/playground-viewport/index.tsx @@ -319,6 +319,202 @@ function SiteErrorMessage({ ); } + if (error === 'blueprint-fetch-failed') { + const errorDetails = (window as any).__playgroundBlueprintError; + const errorMessage = + errorDetails instanceof Error + ? errorDetails.message + : String(errorDetails || 'Unknown error'); + + return ( + <> +

Failed to load Blueprint

+

+ The Blueprint could not be downloaded or loaded. This + usually happens when: +

+ +
+ + Error details + +
+						{errorMessage}
+					
+
+

+ + Learn more about troubleshooting Blueprints + +

+ + + ); + } + + if (error === 'blueprint-filesystem-required') { + const errorDetails = (window as any).__playgroundBlueprintError; + const errorMessage = + errorDetails instanceof Error + ? errorDetails.message + : String(errorDetails || 'Unknown error'); + + return ( + <> +

Blueprint Resource Error

+

+ This Blueprint refers to files that should be bundled with + it (like images, plugins, or themes), but the filesystem + needed to access these files is not available. +

+

+ Common causes: +

+ +
+ + Error details + +
+						{errorMessage}
+					
+
+

+ + Learn more about Blueprint resources + +

+ + + ); + } + + if (error === 'blueprint-validation-failed') { + const errorDetails = (window as any).__playgroundBlueprintError; + const errorMessage = + errorDetails instanceof Error + ? errorDetails.message + : String(errorDetails || 'Unknown error'); + + return ( + <> +

Invalid Blueprint

+

+ The Blueprint does not conform to the required schema. + Please review the validation errors below and fix your + Blueprint. +

+
+ + Validation errors + +
+						{errorMessage}
+					
+
+

+ + Learn more about the Blueprint format + +

+ + + ); + } + return ( <>

Something went wrong

diff --git a/packages/playground/website/src/lib/state/redux/boot-site-client.ts b/packages/playground/website/src/lib/state/redux/boot-site-client.ts index 7f5e87e613..1804f584b6 100644 --- a/packages/playground/website/src/lib/state/redux/boot-site-client.ts +++ b/packages/playground/website/src/lib/state/redux/boot-site-client.ts @@ -193,11 +193,27 @@ export function bootSiteClient( } } catch (e) { logger.error(e); + + // Store the error details for display + (window as any).__playgroundBlueprintError = e; + if ( (e as any).name === 'ArtifactExpiredError' || (e as any).originalErrorClassName === 'ArtifactExpiredError' ) { dispatch(setActiveSiteError('github-artifact-expired')); + } else if ( + e instanceof Error && + e.message.includes( + 'Blueprint resource of type "bundled" requires a filesystem' + ) + ) { + dispatch(setActiveSiteError('blueprint-filesystem-required')); + } else if ( + e instanceof Error && + e.message.startsWith('Invalid Blueprint:') + ) { + dispatch(setActiveSiteError('blueprint-validation-failed')); } else { dispatch(setActiveSiteError('site-boot-failed')); dispatch(setActiveModal(modalSlugs.ERROR_REPORT)); diff --git a/packages/playground/website/src/lib/state/redux/slice-sites.ts b/packages/playground/website/src/lib/state/redux/slice-sites.ts index b776f5d137..747fcc488e 100644 --- a/packages/playground/website/src/lib/state/redux/slice-sites.ts +++ b/packages/playground/website/src/lib/state/redux/slice-sites.ts @@ -20,6 +20,7 @@ import { applyQueryOverrides, } from '../url/resolve-blueprint-from-url'; import { logger } from '@php-wasm/logger'; +import { setActiveSiteError } from './slice-ui'; /** * The Site model used to represent a site within Playground. @@ -286,15 +287,47 @@ export function setTemporarySiteSpec( ); } catch (e) { logger.error( - 'Error resolving blueprint, fallink back to a blank blueprint.', + 'Error resolving blueprint: Blueprint could not be downloaded or loaded.', e ); - // TODO: This is a hack – we are just abusing a URL-oriented - // function to create a completely blank Blueprint. Let's fix this by - // making default creation first-class. - resolvedBlueprint = await resolveBlueprintFromURL( - new URL('https://w.org') - ); + + // Store the error details for the error modal + (window as any).__playgroundBlueprintError = e; + + // Show error to the user - create a minimal site to display the error + const errorSite: SiteInfo = { + slug: deriveSlugFromSiteName(siteName), + originalUrlParams: newSiteUrlParams, + metadata: { + name: siteName, + id: crypto.randomUUID(), + whenCreated: Date.now(), + storage: 'none' as const, + originalBlueprint: {}, + originalBlueprintSource: { + type: 'url', + url: playgroundUrlWithQueryApiArgs.toString(), + }, + runtimeConfiguration: { + phpVersion: '8.0', + wpVersion: 'latest', + intl: false, + networking: true, + extraLibraries: [], + constants: {}, + }, + }, + }; + + dispatch(sitesSlice.actions.addSite(errorSite)); + dispatch(sitesSlice.actions.setFirstTemporarySiteCreated()); + + // Set the error state for this site + setTimeout(() => { + dispatch(setActiveSiteError('blueprint-fetch-failed')); + }, 0); + + return errorSite; } const reflection = await BlueprintReflection.create( diff --git a/packages/playground/website/src/lib/state/redux/slice-ui.ts b/packages/playground/website/src/lib/state/redux/slice-ui.ts index 0e4e23419a..3154cf0682 100644 --- a/packages/playground/website/src/lib/state/redux/slice-ui.ts +++ b/packages/playground/website/src/lib/state/redux/slice-ui.ts @@ -8,7 +8,10 @@ export type SiteError = | 'directory-handle-unknown-error' // @TODO: Improve name? | 'site-boot-failed' - | 'github-artifact-expired'; + | 'github-artifact-expired' + | 'blueprint-fetch-failed' + | 'blueprint-filesystem-required' + | 'blueprint-validation-failed'; export type SiteManagerSection = 'sidebar' | 'site-details' | 'blueprints'; export interface UIState { From b147b57f764366c8d84223a90aeb17810c29446c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 18 Nov 2025 13:11:49 +0100 Subject: [PATCH 02/32] Lint --- .vscode/settings.json | 1 + packages/playground/website/src/lib/state/redux/slice-sites.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 7da412235d..0bbbcf0cfe 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "typescript.tsdk": "node_modules/typescript/lib", + "typescript.experimental.useTsgo": true, "eslint.useFlatConfig": false, "eslint.workingDirectories": [ { "pattern": "./packages/*/" } diff --git a/packages/playground/website/src/lib/state/redux/slice-sites.ts b/packages/playground/website/src/lib/state/redux/slice-sites.ts index 747fcc488e..ea719a32cd 100644 --- a/packages/playground/website/src/lib/state/redux/slice-sites.ts +++ b/packages/playground/website/src/lib/state/redux/slice-sites.ts @@ -305,7 +305,8 @@ export function setTemporarySiteSpec( storage: 'none' as const, originalBlueprint: {}, originalBlueprintSource: { - type: 'url', + // @TODO: Should this say remote-url? + type: 'remote-url', url: playgroundUrlWithQueryApiArgs.toString(), }, runtimeConfiguration: { From 6d445a3ce26318d55eb579f6b0b755382b5c82b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 18 Nov 2025 13:57:21 +0100 Subject: [PATCH 03/32] Display clear Blueprint syntax errors --- .../components/playground-viewport/index.tsx | 13 +- .../src/lib/state/redux/boot-site-client.ts | 52 +++++-- .../src/lib/state/redux/slice-sites.ts | 139 ++++++++++-------- .../website/src/lib/state/redux/slice-ui.ts | 11 +- .../website/src/lib/state/redux/store.ts | 5 + 5 files changed, 142 insertions(+), 78 deletions(-) diff --git a/packages/playground/website/src/components/playground-viewport/index.tsx b/packages/playground/website/src/components/playground-viewport/index.tsx index 26f602c340..6b262acd9b 100644 --- a/packages/playground/website/src/components/playground-viewport/index.tsx +++ b/packages/playground/website/src/components/playground-viewport/index.tsx @@ -4,6 +4,7 @@ import css from './style.module.css'; import BrowserChrome from '../browser-chrome'; import { selectActiveSiteError, + selectActiveSiteErrorDetails, useActiveSite, useAppDispatch, useAppSelector, @@ -200,12 +201,17 @@ export const JustViewport = function JustViewport({ }, [siteSlug, iframeRef, runtimeConfigString]); const error = useAppSelector(selectActiveSiteError); + const errorDetails = useAppSelector(selectActiveSiteErrorDetails); if (error) { return (
- +
); @@ -224,9 +230,11 @@ export const JustViewport = function JustViewport({ function SiteErrorMessage({ error, siteSlug, + errorDetails, }: { error: SiteError; siteSlug: string; + errorDetails?: unknown; }) { const dispatch = useAppDispatch(); if ( @@ -320,7 +328,6 @@ function SiteErrorMessage({ } if (error === 'blueprint-fetch-failed') { - const errorDetails = (window as any).__playgroundBlueprintError; const errorMessage = errorDetails instanceof Error ? errorDetails.message @@ -387,7 +394,6 @@ function SiteErrorMessage({ } if (error === 'blueprint-filesystem-required') { - const errorDetails = (window as any).__playgroundBlueprintError; const errorMessage = errorDetails instanceof Error ? errorDetails.message @@ -457,7 +463,6 @@ function SiteErrorMessage({ } if (error === 'blueprint-validation-failed') { - const errorDetails = (window as any).__playgroundBlueprintError; const errorMessage = errorDetails instanceof Error ? errorDetails.message diff --git a/packages/playground/website/src/lib/state/redux/boot-site-client.ts b/packages/playground/website/src/lib/state/redux/boot-site-client.ts index 1804f584b6..93b6582612 100644 --- a/packages/playground/website/src/lib/state/redux/boot-site-client.ts +++ b/packages/playground/website/src/lib/state/redux/boot-site-client.ts @@ -57,9 +57,10 @@ export function bootSiteClient( } catch (e) { logger.error(e); dispatch( - setActiveSiteError( - 'directory-handle-not-found-in-indexeddb' - ) + setActiveSiteError({ + error: 'directory-handle-not-found-in-indexeddb', + details: e, + }) ); return; } @@ -82,13 +83,19 @@ export function bootSiteClient( logger.error(e); if (e instanceof DOMException && e.name === 'NotFoundError') { dispatch( - setActiveSiteError( - 'directory-handle-not-found-in-indexeddb' - ) + setActiveSiteError({ + error: 'directory-handle-not-found-in-indexeddb', + details: e, + }) ); return; } - dispatch(setActiveSiteError('directory-handle-unknown-error')); + dispatch( + setActiveSiteError({ + error: 'directory-handle-unknown-error', + details: e, + }) + ); return; } } @@ -194,28 +201,45 @@ export function bootSiteClient( } catch (e) { logger.error(e); - // Store the error details for display - (window as any).__playgroundBlueprintError = e; - if ( (e as any).name === 'ArtifactExpiredError' || (e as any).originalErrorClassName === 'ArtifactExpiredError' ) { - dispatch(setActiveSiteError('github-artifact-expired')); + dispatch( + setActiveSiteError({ + error: 'github-artifact-expired', + details: e, + }) + ); } else if ( e instanceof Error && e.message.includes( 'Blueprint resource of type "bundled" requires a filesystem' ) ) { - dispatch(setActiveSiteError('blueprint-filesystem-required')); + dispatch( + setActiveSiteError({ + error: 'blueprint-filesystem-required', + details: e, + }) + ); } else if ( e instanceof Error && e.message.startsWith('Invalid Blueprint:') ) { - dispatch(setActiveSiteError('blueprint-validation-failed')); + dispatch( + setActiveSiteError({ + error: 'blueprint-validation-failed', + details: e, + }) + ); } else { - dispatch(setActiveSiteError('site-boot-failed')); + dispatch( + setActiveSiteError({ + error: 'site-boot-failed', + details: e, + }) + ); dispatch(setActiveModal(modalSlugs.ERROR_REPORT)); } return; diff --git a/packages/playground/website/src/lib/state/redux/slice-sites.ts b/packages/playground/website/src/lib/state/redux/slice-sites.ts index ea719a32cd..fcdded360b 100644 --- a/packages/playground/website/src/lib/state/redux/slice-sites.ts +++ b/packages/playground/website/src/lib/state/redux/slice-sites.ts @@ -20,7 +20,7 @@ import { applyQueryOverrides, } from '../url/resolve-blueprint-from-url'; import { logger } from '@php-wasm/logger'; -import { setActiveSiteError } from './slice-ui'; +import { setActiveSiteError, type SiteError } from './slice-ui'; /** * The Site model used to represent a site within Playground. @@ -247,6 +247,7 @@ export function setTemporarySiteSpec( dispatch: PlaygroundDispatch, getState: () => PlaygroundReduxState ) => { + const siteSlug = deriveSlugFromSiteName(siteName); const newSiteUrlParams = { searchParams: parseSearchParams( playgroundUrlWithQueryApiArgs.searchParams @@ -254,6 +255,50 @@ export function setTemporarySiteSpec( hash: playgroundUrlWithQueryApiArgs.hash, }; + const showTemporarySiteError = (params: { + error: SiteError; + details: unknown; + }) => { + const errorSite: SiteInfo = { + slug: siteSlug, + originalUrlParams: newSiteUrlParams, + metadata: { + name: siteName, + id: crypto.randomUUID(), + whenCreated: Date.now(), + storage: 'none' as const, + originalBlueprint: {}, + originalBlueprintSource: { + // @TODO: Should this say remote-url? + type: 'remote-url', + url: playgroundUrlWithQueryApiArgs.toString(), + }, + runtimeConfiguration: { + phpVersion: '8.0', + wpVersion: 'latest', + intl: false, + networking: true, + extraLibraries: [], + constants: {}, + }, + }, + }; + + dispatch(sitesSlice.actions.addSite(errorSite)); + dispatch(sitesSlice.actions.setFirstTemporarySiteCreated()); + + setTimeout(() => { + dispatch( + setActiveSiteError({ + error: params.error, + details: params.details, + }) + ); + }, 0); + + return errorSite; + }; + const currentTemporarySite = selectTemporarySite(getState()); if (currentTemporarySite) { // If the current temporary site is the same as the site we're setting, @@ -291,75 +336,53 @@ export function setTemporarySiteSpec( e ); - // Store the error details for the error modal - (window as any).__playgroundBlueprintError = e; + return showTemporarySiteError({ + error: 'blueprint-fetch-failed', + details: e, + }); + } - // Show error to the user - create a minimal site to display the error - const errorSite: SiteInfo = { - slug: deriveSlugFromSiteName(siteName), + try { + const reflection = await BlueprintReflection.create( + resolvedBlueprint.blueprint + ); + if (reflection.getVersion() === 1) { + resolvedBlueprint.blueprint = await applyQueryOverrides( + resolvedBlueprint.blueprint, + playgroundUrlWithQueryApiArgs.searchParams + ); + } + + // Compute the runtime configuration based on the resolved Blueprint: + const newSiteInfo: SiteInfo = { + slug: siteSlug, originalUrlParams: newSiteUrlParams, metadata: { name: siteName, id: crypto.randomUUID(), whenCreated: Date.now(), storage: 'none' as const, - originalBlueprint: {}, - originalBlueprintSource: { - // @TODO: Should this say remote-url? - type: 'remote-url', - url: playgroundUrlWithQueryApiArgs.toString(), - }, - runtimeConfiguration: { - phpVersion: '8.0', - wpVersion: 'latest', - intl: false, - networking: true, - extraLibraries: [], - constants: {}, - }, + originalBlueprint: resolvedBlueprint.blueprint, + originalBlueprintSource: resolvedBlueprint.source!, + runtimeConfiguration: await resolveRuntimeConfiguration( + resolvedBlueprint.blueprint + )!, }, }; - - dispatch(sitesSlice.actions.addSite(errorSite)); + dispatch(sitesSlice.actions.addSite(newSiteInfo)); dispatch(sitesSlice.actions.setFirstTemporarySiteCreated()); - - // Set the error state for this site - setTimeout(() => { - dispatch(setActiveSiteError('blueprint-fetch-failed')); - }, 0); - - return errorSite; - } - - const reflection = await BlueprintReflection.create( - resolvedBlueprint.blueprint - ); - if (reflection.getVersion() === 1) { - resolvedBlueprint.blueprint = await applyQueryOverrides( - resolvedBlueprint.blueprint, - playgroundUrlWithQueryApiArgs.searchParams + return newSiteInfo; + } catch (e) { + logger.error( + 'Error preparing the Blueprint after it was downloaded.', + e ); + const errorType = + e instanceof Error && e.message.startsWith('Invalid Blueprint:') + ? 'blueprint-validation-failed' + : 'site-boot-failed'; + return showTemporarySiteError({ error: errorType, details: e }); } - - // Compute the runtime configuration based on the resolved Blueprint: - const newSiteInfo: SiteInfo = { - slug: deriveSlugFromSiteName(siteName), - originalUrlParams: newSiteUrlParams, - metadata: { - name: siteName, - id: crypto.randomUUID(), - whenCreated: Date.now(), - storage: 'none' as const, - originalBlueprint: resolvedBlueprint.blueprint, - originalBlueprintSource: resolvedBlueprint.source!, - runtimeConfiguration: await resolveRuntimeConfiguration( - resolvedBlueprint.blueprint - )!, - }, - }; - dispatch(sitesSlice.actions.addSite(newSiteInfo)); - dispatch(sitesSlice.actions.setFirstTemporarySiteCreated()); - return newSiteInfo; }; } diff --git a/packages/playground/website/src/lib/state/redux/slice-ui.ts b/packages/playground/website/src/lib/state/redux/slice-ui.ts index 3154cf0682..9350a661c1 100644 --- a/packages/playground/website/src/lib/state/redux/slice-ui.ts +++ b/packages/playground/website/src/lib/state/redux/slice-ui.ts @@ -18,6 +18,7 @@ export interface UIState { activeSite?: { slug: string; error?: SiteError; + errorDetails?: unknown; }; activeModal: string | null; offline: boolean; @@ -69,12 +70,18 @@ const uiSlice = createSlice({ state.activeSite = action.payload ? { slug: action.payload, + error: undefined, + errorDetails: undefined, } : undefined; }, - setActiveSiteError: (state, action: PayloadAction) => { + setActiveSiteError: ( + state, + action: PayloadAction<{ error: SiteError; details?: unknown }> + ) => { if (state.activeSite) { - state.activeSite.error = action.payload; + state.activeSite.error = action.payload.error; + state.activeSite.errorDetails = action.payload.details; } }, setActiveModal: (state, action: PayloadAction) => { diff --git a/packages/playground/website/src/lib/state/redux/store.ts b/packages/playground/website/src/lib/state/redux/store.ts index 3ba04ed195..69c7605925 100644 --- a/packages/playground/website/src/lib/state/redux/store.ts +++ b/packages/playground/website/src/lib/state/redux/store.ts @@ -89,6 +89,11 @@ export const selectActiveSiteError = ( ): SiteError | undefined => state.ui.activeSite?.slug ? state.ui.activeSite.error : undefined; +export const selectActiveSiteErrorDetails = ( + state: PlaygroundReduxState +): unknown => + state.ui.activeSite?.slug ? state.ui.activeSite.errorDetails : undefined; + export const useActiveSite = () => useAppSelector(selectActiveSite); export const setActiveSite = (slug: string | undefined) => { From 20579572e8245e3d1a2ba28ebe4dd04a6ff4e4f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 18 Nov 2025 15:02:21 +0100 Subject: [PATCH 04/32] Exclude error object from redux store immutability --- .../components/playground-viewport/index.tsx | 32 ++++---- .../website/src/lib/state/redux/slice-ui.ts | 82 +++++++++++++++++-- .../website/src/lib/state/redux/store.ts | 4 +- 3 files changed, 93 insertions(+), 25 deletions(-) diff --git a/packages/playground/website/src/components/playground-viewport/index.tsx b/packages/playground/website/src/components/playground-viewport/index.tsx index 6b262acd9b..a150cf12b6 100644 --- a/packages/playground/website/src/components/playground-viewport/index.tsx +++ b/packages/playground/website/src/components/playground-viewport/index.tsx @@ -11,7 +11,10 @@ import { } from '../../lib/state/redux/store'; import { removeClientInfo } from '../../lib/state/redux/slice-clients'; import { bootSiteClient } from '../../lib/state/redux/boot-site-client'; -import type { SiteError } from '../../lib/state/redux/slice-ui'; +import type { + SiteError, + SerializedSiteErrorDetails, +} from '../../lib/state/redux/slice-ui'; import { Button, Spinner } from '@wordpress/components'; import { removeSite, @@ -234,7 +237,7 @@ function SiteErrorMessage({ }: { error: SiteError; siteSlug: string; - errorDetails?: unknown; + errorDetails?: SerializedSiteErrorDetails; }) { const dispatch = useAppDispatch(); if ( @@ -328,10 +331,7 @@ function SiteErrorMessage({ } if (error === 'blueprint-fetch-failed') { - const errorMessage = - errorDetails instanceof Error - ? errorDetails.message - : String(errorDetails || 'Unknown error'); + const errorMessage = getRenderableErrorMessage(errorDetails); return ( <> @@ -394,10 +394,7 @@ function SiteErrorMessage({ } if (error === 'blueprint-filesystem-required') { - const errorMessage = - errorDetails instanceof Error - ? errorDetails.message - : String(errorDetails || 'Unknown error'); + const errorMessage = getRenderableErrorMessage(errorDetails); return ( <> @@ -463,10 +460,7 @@ function SiteErrorMessage({ } if (error === 'blueprint-validation-failed') { - const errorMessage = - errorDetails instanceof Error - ? errorDetails.message - : String(errorDetails || 'Unknown error'); + const errorMessage = getRenderableErrorMessage(errorDetails); return ( <> @@ -536,3 +530,13 @@ function SiteErrorMessage({ ); } + +function getRenderableErrorMessage(details?: SerializedSiteErrorDetails) { + if (!details) { + return 'Unknown error'; + } + if (typeof details === 'string') { + return details; + } + return details.message || details.stack || details.name || 'Unknown error'; +} diff --git a/packages/playground/website/src/lib/state/redux/slice-ui.ts b/packages/playground/website/src/lib/state/redux/slice-ui.ts index 9350a661c1..209eb57490 100644 --- a/packages/playground/website/src/lib/state/redux/slice-ui.ts +++ b/packages/playground/website/src/lib/state/redux/slice-ui.ts @@ -14,11 +14,64 @@ export type SiteError = | 'blueprint-validation-failed'; export type SiteManagerSection = 'sidebar' | 'site-details' | 'blueprints'; + +export type SerializedSiteErrorDetails = + | string + | { + message?: string; + name?: string; + stack?: string; + }; + +const serializeSiteErrorDetails = ( + details?: unknown +): SerializedSiteErrorDetails | undefined => { + if (details instanceof Error) { + return { + message: details.message, + name: details.name, + stack: details.stack, + }; + } + if (typeof details === 'string') { + return details; + } + if (details === undefined || details === null) { + return undefined; + } + if (typeof details === 'object') { + const maybeMessage = + 'message' in details && typeof (details as any).message === 'string' + ? (details as any).message + : undefined; + const maybeName = + 'name' in details && typeof (details as any).name === 'string' + ? (details as any).name + : undefined; + const maybeStack = + 'stack' in details && typeof (details as any).stack === 'string' + ? (details as any).stack + : undefined; + if (maybeMessage || maybeName || maybeStack) { + return { + message: maybeMessage, + name: maybeName, + stack: maybeStack, + }; + } + } + try { + return JSON.stringify(details, null, 2); + } catch { + return String(details); + } +}; + export interface UIState { activeSite?: { slug: string; error?: SiteError; - errorDetails?: unknown; + errorDetails?: SerializedSiteErrorDetails; }; activeModal: string | null; offline: boolean; @@ -75,14 +128,25 @@ const uiSlice = createSlice({ } : undefined; }, - setActiveSiteError: ( - state, - action: PayloadAction<{ error: SiteError; details?: unknown }> - ) => { - if (state.activeSite) { - state.activeSite.error = action.payload.error; - state.activeSite.errorDetails = action.payload.details; - } + setActiveSiteError: { + reducer: ( + state, + action: PayloadAction<{ + error: SiteError; + details?: SerializedSiteErrorDetails; + }> + ) => { + if (state.activeSite) { + state.activeSite.error = action.payload.error; + state.activeSite.errorDetails = action.payload.details; + } + }, + prepare: (payload: { error: SiteError; details?: unknown }) => ({ + payload: { + error: payload.error, + details: serializeSiteErrorDetails(payload.details), + }, + }), }, setActiveModal: (state, action: PayloadAction) => { const url = new URL(window.location.href); diff --git a/packages/playground/website/src/lib/state/redux/store.ts b/packages/playground/website/src/lib/state/redux/store.ts index 69c7605925..13443926ec 100644 --- a/packages/playground/website/src/lib/state/redux/store.ts +++ b/packages/playground/website/src/lib/state/redux/store.ts @@ -1,5 +1,5 @@ import { configureStore, createSelector } from '@reduxjs/toolkit'; -import type { SiteError } from './slice-ui'; +import type { SiteError, SerializedSiteErrorDetails } from './slice-ui'; import uiReducer, { __internal_uiSlice, listenToOnlineOfflineEventsMiddleware, @@ -91,7 +91,7 @@ export const selectActiveSiteError = ( export const selectActiveSiteErrorDetails = ( state: PlaygroundReduxState -): unknown => +): SerializedSiteErrorDetails | undefined => state.ui.activeSite?.slug ? state.ui.activeSite.errorDetails : undefined; export const useActiveSite = () => useAppSelector(selectActiveSite); From aa42612d4c7d8abad2b5641ebd5dd87a1e424c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 18 Nov 2025 18:07:13 +0100 Subject: [PATCH 05/32] Adjust CSS of the error modal --- .../website/src/components/layout/index.tsx | 17 +- .../components/playground-viewport/index.tsx | 651 +++++++++--------- .../playground-viewport/style.module.css | 95 ++- .../website/src/lib/state/redux/slice-ui.ts | 7 + 4 files changed, 444 insertions(+), 326 deletions(-) diff --git a/packages/playground/website/src/components/layout/index.tsx b/packages/playground/website/src/components/layout/index.tsx index 9523bd0946..f6e9222773 100644 --- a/packages/playground/website/src/components/layout/index.tsx +++ b/packages/playground/website/src/components/layout/index.tsx @@ -31,22 +31,11 @@ import { PreviewPRModal } from '../../github/preview-pr'; import { MissingSiteModal } from '../missing-site-modal'; import { RenameSiteModal } from '../rename-site-modal'; import { SaveSiteModal } from '../save-site-modal'; +import { modalSlugs } from './modal-slugs'; -acquireOAuthTokenIfNeeded(); +export { modalSlugs } from './modal-slugs'; -export const modalSlugs = { - LOG: 'log', - ERROR_REPORT: 'error-report', - START_ERROR: 'start-error', - IMPORT_FORM: 'import-form', - GITHUB_IMPORT: 'github-import', - GITHUB_EXPORT: 'github-export', - PREVIEW_PR_WP: 'preview-pr-wordpress', - PREVIEW_PR_GUTENBERG: 'preview-pr-gutenberg', - MISSING_SITE_PROMPT: 'missing-site-prompt', - RENAME_SITE: 'rename-site', - SAVE_SITE: 'save-site', -}; +acquireOAuthTokenIfNeeded(); const displayMode = getDisplayModeFromQuery(); function getDisplayModeFromQuery(): DisplayMode { diff --git a/packages/playground/website/src/components/playground-viewport/index.tsx b/packages/playground/website/src/components/playground-viewport/index.tsx index a150cf12b6..9e2f05224c 100644 --- a/packages/playground/website/src/components/playground-viewport/index.tsx +++ b/packages/playground/website/src/components/playground-viewport/index.tsx @@ -5,15 +5,18 @@ import BrowserChrome from '../browser-chrome'; import { selectActiveSiteError, selectActiveSiteErrorDetails, + setActiveSite, 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 type { - SiteError, - SerializedSiteErrorDetails, +import { + setActiveModal, + clearActiveSiteError, + type SiteError, + type SerializedSiteErrorDetails, } from '../../lib/state/redux/slice-ui'; import { Button, Spinner } from '@wordpress/components'; import { @@ -21,7 +24,11 @@ import { selectSiteBySlug, selectSitesLoaded, selectTemporarySites, + setTemporarySiteSpec, } from '../../lib/state/redux/slice-sites'; +import type { SiteInfo } from '../../lib/state/redux/slice-sites'; +import { modalSlugs } from '../layout/modal-slugs'; +import { Modal } from '../modal'; import classNames from 'classnames'; export const supportedDisplayModes = [ @@ -205,338 +212,368 @@ export const JustViewport = function JustViewport({ const error = useAppSelector(selectActiveSiteError); const errorDetails = useAppSelector(selectActiveSiteErrorDetails); - - if (error) { - return ( -
-
- -
-
- ); - } + const activeSiteSlug = useAppSelector((state) => state.ui.activeSite?.slug); + const showOverlay = error && activeSiteSlug === siteSlug; return ( -