Skip to content

Commit 8a4e01c

Browse files
dilirityLiamSarsfieldhaqadn
authored
Boost: Improve critical css generation process (#41946)
* Add watch command * Check http code before proceeding with C.CSS generation We need to do this, otherwise the library will generate css for the 404 page. * Update css generation to catch invalid URL errors and surface them * Update recommendations to be split by type as well as provider key WIP * Add changelogs * Refactor recommendations to allow dismissing unique provider errors * Update URL validation * Update helper description * Add proper troubleshooting steps for invalid URLs * Update status code validation * Fix tests * Throw an error on invalid url Co-authored-by: Liam Sarsfield <[email protected]> * Remove redundant fragment Co-authored-by: Liam Sarsfield <[email protected]> * Improve type safety and readability of error grouping * Update step Co-authored-by: Adnan Haque <[email protected]> * Add an additional step * Update http status check to check for a 404 instead * Revert slashes to see if opaque redirects work * Simplify 404 check --------- Co-authored-by: Liam Sarsfield <[email protected]> Co-authored-by: Adnan Haque <[email protected]>
1 parent 426cd02 commit 8a4e01c

18 files changed

+297
-60
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: patch
2+
Type: fixed
3+
4+
Critical CSS: Prevent invalid URLs from breaking the whole process.

projects/js-packages/critical-css-gen/composer.json

+4
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
],
2020
"test-js": [
2121
"pnpm exec playwright install --with-deps chromium && pnpm run test"
22+
],
23+
"watch": [
24+
"Composer\\Config::disableProcessTimeout",
25+
"pnpm run watch"
2226
]
2327
},
2428
"repositories": [

projects/js-packages/critical-css-gen/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
"clean": "rm -rf build/",
2121
"clean:test": "rm -rf tests/build/",
2222
"test": "pnpm build && pnpm build:test && NODE_ENV=test NODE_PATH=./node_modules jest --forceExit --config=tests/config/jest.config.js",
23-
"test-coverage": "pnpm run test --coverage"
23+
"test-coverage": "pnpm run test --coverage",
24+
"watch": "tsc --watch"
2425
},
2526
"main": "./build/browser.js",
2627
"devDependencies": {

projects/js-packages/critical-css-gen/src/browser-interface-iframe.ts

+26-5
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
UrlVerifyError,
88
UnknownError,
99
XFrameDenyError,
10+
InvalidURLError,
1011
UrlError,
1112
} from './errors.js';
1213
import { Viewport, NullableViewport } from './types.js';
@@ -96,13 +97,12 @@ export class BrowserInterfaceIframe extends BrowserInterface {
9697
return method( { innerWindow: this.iframe.contentWindow, args } );
9798
}
9899

99-
addGetParameters( rawUrl: string ): string {
100-
const urlObject = new URL( rawUrl );
100+
addGetParameters( url: URL ): string {
101101
for ( const key of Object.keys( this.requestGetParameters ) ) {
102-
urlObject.searchParams.append( key, this.requestGetParameters[ key ] );
102+
url.searchParams.append( key, this.requestGetParameters[ key ] );
103103
}
104104

105-
return urlObject.toString();
105+
return url.toString();
106106
}
107107

108108
async diagnoseUrlError( url: string ): Promise< UrlError | null > {
@@ -131,6 +131,12 @@ export class BrowserInterfaceIframe extends BrowserInterface {
131131
}
132132
}
133133

134+
async is404Page( url: string ): Promise< boolean > {
135+
const response = await this.fetch( url, { redirect: 'manual' }, 'html' );
136+
137+
return response.status === 404;
138+
}
139+
134140
sameOrigin( url: string ): boolean {
135141
return new URL( url ).origin === window.location.origin;
136142
}
@@ -140,7 +146,16 @@ export class BrowserInterfaceIframe extends BrowserInterface {
140146
return;
141147
}
142148

143-
const fullUrl = this.addGetParameters( rawUrl );
149+
// Make sure URL is valid.
150+
let url;
151+
try {
152+
url = new URL( rawUrl );
153+
} catch ( err ) {
154+
this.trackUrlError( rawUrl, err );
155+
throw new InvalidURLError( { url: rawUrl } );
156+
}
157+
158+
const fullUrl = this.addGetParameters( url );
144159

145160
return new Promise( ( resolve, rawReject ) => {
146161
// Track all URL errors.
@@ -167,6 +182,12 @@ export class BrowserInterfaceIframe extends BrowserInterface {
167182
this.iframe.onload = null;
168183
clearTimeout( timeoutId );
169184

185+
// Check HTTP status code first.
186+
const is404 = await this.is404Page( fullUrl );
187+
if ( is404 ) {
188+
throw new HttpError( { url, code: 404 } );
189+
}
190+
170191
// Verify the inner document is readable.
171192
if ( ! this.iframe.contentDocument || ! this.iframe.contentWindow ) {
172193
throw (

projects/js-packages/critical-css-gen/src/errors.ts

+9
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,12 @@ export class XFrameDenyError extends UrlError {
140140
);
141141
}
142142
}
143+
144+
/**
145+
* InvalidURLError - Indicates that a requested URL is invalid.
146+
*/
147+
export class InvalidURLError extends UrlError {
148+
constructor( { url }: { url: string } ) {
149+
super( 'InvalidURLError', { url }, `Invalid URL: ${ url }` );
150+
}
151+
}

projects/plugins/boost/app/assets/src/js/features/critical-css/error-description/error-description.tsx

+9-3
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,18 @@ import getCriticalCssErrorSetInterpolateVars from '$lib/utils/get-critical-css-e
1313
* Remove GET parameters that are used to cache-bust from display URLs, as they add visible noise
1414
* to the error output with no real benefit to users understanding which URLs are problematic.
1515
*
16+
* If the URL is invalid, return the original URL.
17+
*
1618
* @param url The URL to strip cache parameters from.
1719
*/
1820
export function stripCacheParams( url: string ): string {
19-
const urlObj = new URL( url );
20-
urlObj.searchParams.delete( 'donotcachepage' );
21-
return urlObj.toString();
21+
try {
22+
const urlObj = new URL( url );
23+
urlObj.searchParams.delete( 'donotcachepage' );
24+
return urlObj.toString();
25+
} catch ( err ) {
26+
return url;
27+
}
2228
}
2329

2430
const CriticalCssErrorDescription: React.FC< CriticalCssErrorDescriptionTypes > = ( {

projects/plugins/boost/app/assets/src/js/features/critical-css/lib/critical-css-errors.ts

+53
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
Critical_CSS_Error_Type,
88
Provider,
99
} from './stores/critical-css-state-types';
10+
import { ProviderRecommendation } from './stores/recommendation-types';
1011

1112
/**
1213
* Specification for a set of errors that can appear as a part of a recommendation.
@@ -140,3 +141,55 @@ export function groupKey( error: CriticalCssErrorDetails ) {
140141

141142
return error.type;
142143
}
144+
145+
type RecommendationsResult = {
146+
activeRecommendations: ProviderRecommendation[];
147+
dismissedRecommendations: ProviderRecommendation[];
148+
};
149+
150+
type ErrorsByType = Record< Critical_CSS_Error_Type, CriticalCssErrorDetails[] >;
151+
152+
export function groupRecommendationsByStatus(
153+
providersWithIssues: Provider[]
154+
): RecommendationsResult {
155+
const activeRecommendations: ProviderRecommendation[] = [];
156+
const dismissedRecommendations: ProviderRecommendation[] = [];
157+
158+
providersWithIssues.forEach( provider => {
159+
const providerErrors = provider.errors || [];
160+
// Group errors by type first
161+
const errorsByType = providerErrors.reduce( ( acc, error ) => {
162+
if ( ! acc[ error.type ] ) {
163+
acc[ error.type ] = [];
164+
}
165+
acc[ error.type ].push( error );
166+
return acc;
167+
}, {} as ErrorsByType );
168+
169+
const errorTypeGroups = Object.entries( errorsByType ) as [
170+
Critical_CSS_Error_Type,
171+
CriticalCssErrorDetails[],
172+
][];
173+
174+
// For each error type group, check if it's dismissed
175+
errorTypeGroups.forEach( ( [ errorType, errors ] ) => {
176+
if ( provider.dismissed_errors?.includes( errorType ) ) {
177+
dismissedRecommendations.push( {
178+
key: provider.key,
179+
label: provider.label,
180+
errorType: errorType,
181+
errors: errors,
182+
} );
183+
} else {
184+
activeRecommendations.push( {
185+
key: provider.key,
186+
label: provider.label,
187+
errorType: errorType,
188+
errors: errors,
189+
} );
190+
}
191+
} );
192+
} );
193+
194+
return { activeRecommendations, dismissedRecommendations };
195+
}

projects/plugins/boost/app/assets/src/js/features/critical-css/lib/describe-critical-css-recommendations.ts

+40
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,46 @@ const errorTypeSpecs: { [ type: string ]: ErrorTypeSpec } = {
464464
],
465465
} ),
466466
},
467+
468+
InvalidURLError: {
469+
describeSet: set =>
470+
_n(
471+
'Jetpack Boost found an invalid URL:',
472+
'Jetpack Boost found invalid URLs:',
473+
urlCount( set ),
474+
'jetpack-boost'
475+
),
476+
suggestion: _set => ( {
477+
paragraph: __(
478+
'Jetpack Boost needs valid URLs in order to generate Critical CSS. It seems that one or more of the URLs you provided are invalid.',
479+
'jetpack-boost'
480+
),
481+
list: [
482+
__( 'It is okay to ignore URLs that are not meant to be shown publicly.', 'jetpack-boost' ),
483+
__(
484+
'If they are meant to be shown publicly, check if you have any plugins that modify your permalinks or URL structure.',
485+
'jetpack-boost'
486+
),
487+
__(
488+
'Verify your WordPress permalink settings are configured correctly in Settings → Permalinks.',
489+
'jetpack-boost'
490+
),
491+
__(
492+
'If you use custom post types, ensure they are properly registered and their permalinks are working.',
493+
'jetpack-boost'
494+
),
495+
__(
496+
'Try visiting the problematic URLs directly in your browser to confirm they work.',
497+
'jetpack-boost'
498+
),
499+
__( '<retry>Try again</retry> to generate the Critical CSS.', 'jetpack-boost' ),
500+
],
501+
closingParagraph: __(
502+
'If the issue persists, you may want to check your theme and plugins for any custom code that modifies URLs or permalinks.',
503+
'jetpack-boost'
504+
),
505+
} ),
506+
},
467507
};
468508

469509
function getErrorSpec( type: string ): ErrorTypeSpec {

projects/plugins/boost/app/assets/src/js/features/critical-css/lib/stores/critical-css-state-types.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const CriticalCssErrorType = z.enum( [
1212
'UrlVerifyError',
1313
'EmptyCSSError',
1414
'XFrameDenyError',
15+
'InvalidURLError',
1516
] );
1617

1718
export const CriticalCssErrorDetailsSchema = z.object( {
@@ -37,8 +38,8 @@ export const ProviderSchema = z.object( {
3738
.catch( 'validation-error' ),
3839
// Error details
3940
errors: z.array( CriticalCssErrorDetailsSchema ).optional(),
40-
// If this an error, has it been dismissed?
41-
error_status: z.enum( [ 'active', 'dismissed' ] ).optional(),
41+
// List of error types that have been dismissed for this provider
42+
dismissed_errors: z.array( CriticalCssErrorType ).optional(),
4243
} );
4344

4445
export const CriticalCssStateSchema = z

projects/plugins/boost/app/assets/src/js/features/critical-css/lib/stores/critical-css-state.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,8 @@ export function useSetProviderErrorDismissedAction() {
141141
'set-provider-errors-dismissed',
142142
z.array(
143143
z.object( {
144-
key: z.string(),
144+
provider: z.string(),
145+
error_type: z.string(),
145146
dismissed: z.boolean(),
146147
} )
147148
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Critical_CSS_Error_Type, CriticalCssErrorDetails } from './critical-css-state-types';
2+
3+
export type DismissedItem = {
4+
provider: string;
5+
dismissed: boolean;
6+
errorType: Critical_CSS_Error_Type;
7+
};
8+
9+
export type RecommendationProps = {
10+
recommendation: ProviderRecommendation;
11+
setDismissed: ( dismissedItems: DismissedItem[] ) => void;
12+
};
13+
14+
export type ProviderRecommendation = {
15+
key: string;
16+
label: string;
17+
errors: CriticalCssErrorDetails[];
18+
errorType: Critical_CSS_Error_Type;
19+
};

projects/plugins/boost/app/assets/src/js/features/critical-css/more-list/more-list.tsx

+17-3
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,28 @@ const MoreList: React.FC< MoreListTypes > = ( { entries = [], showLimit = 2 } )
1313
const listItems = expanded ? entries : entries.slice( 0, showLimit );
1414
const showExpandButton = ! expanded && entries.length > showLimit;
1515

16+
const isValidUrl = ( url: string ) => {
17+
try {
18+
const urlObj = new URL( url );
19+
// Check that it's a web URL (http or https protocol)
20+
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
21+
} catch ( err ) {
22+
return false;
23+
}
24+
};
25+
1626
return (
1727
<>
1828
<ul className={ styles[ 'more-list' ] }>
1929
{ listItems.map( ( { href, label }, index ) => (
2030
<li key={ index }>
21-
<a href={ href } target="_blank" rel="noreferrer">
22-
{ label }
23-
</a>
31+
{ isValidUrl( href ) ? (
32+
<a href={ href } target="_blank" rel="noreferrer">
33+
{ label }
34+
</a>
35+
) : (
36+
<code>{ label }</code>
37+
) }
2438
</li>
2539
) ) }
2640

projects/plugins/boost/app/assets/src/js/lib/utils/get-support-link-critical-css.ts

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ function getPrettyError( error: string ) {
1010
UrlVerifyError: 'url-verify-error',
1111
EmptyCSSError: 'empty-css-error',
1212
XFrameDenyError: 'x-frame-deny-error',
13+
InvalidURLError: 'invalid-url-error',
1314
};
1415

1516
return errorMap[ error as keyof typeof errorMap ] || error;

0 commit comments

Comments
 (0)