Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
3 changes: 3 additions & 0 deletions src/app/lib/logger.const.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ const logCodes = {
// Build manifest errors
BUILD_MANIFEST_CSS_READ_ERROR: 'build_manifest_css_read_error',
DYNAMIC_IMPORT_CSS_READ_ERROR: 'dynamic_import_css_read_error',

// AMP/Lite CSS vendor-prefix optimisation
AMP_LITE_CSS_AUTOPREFIXER_ERROR: 'amp_lite_css_autoprefixer_error',
};

module.exports = logCodes;
7 changes: 7 additions & 0 deletions ws-nextjs-app/integration/common/inlinedCss.amp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,12 @@ export default () => {
// This confirms CSS module extraction is working, not just global SCSS
expect(css).toMatch(/\.[A-Za-z]+_[a-zA-Z]+__[A-Za-z0-9]+/);
});

it('should strip unnecessary vendor prefixes left in by Emotion', () => {
const css = getAmpCustomStyle()?.textContent ?? '';

expect(css).not.toMatch(/-webkit-box-(align|pack)\s*:/);
expect(css).not.toMatch(/-ms-flex-(align|pack|direction|wrap)\s*:/);
});
});
};
7 changes: 7 additions & 0 deletions ws-nextjs-app/integration/common/inlinedCss.lite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,12 @@ export default () => {
// This confirms CSS module extraction is working, not just global SCSS
expect(css).toMatch(/\.[A-Za-z]+_[a-zA-Z]+__[A-Za-z0-9]+/);
});

it('should strip unnecessary vendor prefixes left in by Emotion', () => {
const css = getInlinedCss();

expect(css).not.toMatch(/-webkit-box-(align|pack)\s*:/);
expect(css).not.toMatch(/-ms-flex-(align|pack|direction|wrap)\s*:/);
});
});
};
15 changes: 15 additions & 0 deletions ws-nextjs-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@
"test:e2e": "yarn stop && yarn build && run-p --race start cypress -- --e2e",
"test:e2e:interactive": "yarn stop && yarn build && run-p --race start cypress:interactive -- --e2e"
},
"browserslist": [
"chrome >= 53",
"firefox >= 52",
"edge >= 100",
"safari >= 12.1",
"opera >= 40",
"op_mob >= 80",
"op_mini all",
"Android >= 7",
"and_chr >= 53",
"and_ff >= 52",
"ios_saf >= 12.2"
],
"devDependencies": {
"@cypress/webpack-preprocessor": "7.1.1",
"@jest/environment": "30.2.0",
Expand All @@ -63,7 +76,9 @@
"ts-node": "10.9.2"
},
"dependencies": {
"autoprefixer": "10.5.4",
"next": "16.2.11",
"postcss": "8.5.22",
"sharp": "0.35.3",
"temporal-polyfill": "0.3.0"
}
Expand Down
50 changes: 6 additions & 44 deletions ws-nextjs-app/pages/_document.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import CanonicalToLiteRedirect from '#utilities/CanonicalToLiteRedirect';
import addOperaMiniClassScript from '#app/lib/utilities/addOperaMiniClassScript';
import handleServerLogging from '#utilities/handleServerLogging';
import getAmpLiteCss from '#utilities/getAmpLiteCss';
import optimiseCssPrefixes from '#utilities/optimiseCssPrefixes';
import ComponentTracking from '../renderers/ComponentTracking';
import ReverbTemplate from '../renderers/ReverbTemplate';
import litePageTransforms from '../renderers/litePageTransforms';
Expand All @@ -46,47 +47,6 @@ type DocProps = {
title: ReactElement;
};

const stripVendorPrefixes = (css: string): string => {
let output = css;
let previous = '';

while (output !== previous) {
previous = output;

output = output
// Remove vendor-prefixed property declarations
.replace(
/(^|[;{])\s*-(webkit|moz|ms|o)-[\w-]+\s*:\s*[^;{}]+;?/gm,
'$1',
)
// Remove declarations with vendor-prefixed values
.replace(
/(^|[;{])\s*[\w-]+\s*:\s*-(webkit|moz|ms|o)-[^;{}]+;?/gm,
'$1',
)
// Remove vendor-prefixed pseudo-element/class rules only when simple
.replace(
/(^|})\s*[^{}]*::?-(webkit|moz|ms|o)-[\w-]+[^{}]*\{[^{}]*\}/gm,
'$1',
)
// Remove empty rules
.replace(/[^{}]+\{\s*\}/g, '')
// Cleanup
.replace(/;{2,}/g, ';')
.replace(/\{\s*;/g, '{')
.replace(/;\s*\}/g, '}');
}

return output.trim();
};

const optimiseAmpCss = (css: string): string =>
stripVendorPrefixes(css)
.replace(/;\}/g, '}')
.replace(/\s{2,}/g, ' ')
.trim();


export default class AppDocument extends Document<DocProps> {
static async getInitialProps(ctx: DocumentContext) {
const url = ctx.asPath || '';
Expand Down Expand Up @@ -169,7 +129,8 @@ export default class AppDocument extends Document<DocProps> {

switch (true) {
case isAmp && pageType === 'article': {
const ampCss = optimiseAmpCss(css + getAmpLiteCss(getNextData()));
const ampLiteCss = getAmpLiteCss(getNextData());
const combinedCss = optimiseCssPrefixes(css + ampLiteCss);
return (
<AmpRenderer
bodyContent={<Main />}
Expand All @@ -178,13 +139,14 @@ export default class AppDocument extends Document<DocProps> {
helmetScriptTags={helmetScriptTags}
htmlAttrs={htmlAttrs}
ids={ids}
styles={ampCss}
styles={combinedCss}
title={title}
/>
);
}
case isLite: {
const liteCss = css + getAmpLiteCss(getNextData());
const ampLiteCss = getAmpLiteCss(getNextData());
const liteCss = optimiseCssPrefixes(css + ampLiteCss);
return (
<LiteRenderer
bodyContent={<Main />}
Expand Down
70 changes: 70 additions & 0 deletions ws-nextjs-app/utilities/optimiseCssPrefixes/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import loggerMock from '#testHelpers/loggerMock';
import optimiseCssPrefixes from '.';

describe('optimiseCssPrefixes', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('removes vendor prefixes not needed for the declared target browsers', () => {
// Old 2009/2012-draft flexbox spec syntax — not needed for any browser in
// this project's browserslist config.
const css =
'.foo{display:-webkit-box;-webkit-box-align:center;align-items:center;}';

const result = optimiseCssPrefixes(css);

expect(result).not.toContain('-webkit-box-align');
expect(result).toContain('align-items:center');
});

it('keeps vendor-prefixed properties that Autoprefixer does not track', () => {
// None of these appear in Autoprefixer's data set, so they're never
// touched regardless of the target browsers. They're real properties
// this codebase relies on (momentum scrolling, text truncation), so
// this guards against a future dependency bump changing that.
const css =
'.foo{-webkit-overflow-scrolling:touch;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden;}';

const result = optimiseCssPrefixes(css);

expect(result).toContain('-webkit-overflow-scrolling:touch');
expect(result).toContain('-webkit-line-clamp:4');
expect(result).toContain('-webkit-box-orient:vertical');
});

it('adds a vendor prefix that is genuinely required for a declared target browser', () => {
// and_chr/chrome need -webkit-clip-path in some of the versions this
// project declares support for.
const css = '.foo{clip-path:circle(50%);}';

const result = optimiseCssPrefixes(css);

expect(result).toContain('-webkit-clip-path:circle(50%)');
expect(result).toContain('clip-path:circle(50%)');
});

it('returns the CSS unchanged when there is nothing to optimise', () => {
const css = '.foo{color:red;}';

expect(optimiseCssPrefixes(css)).toBe(css);
});

it('returns an empty string for empty input', () => {
expect(optimiseCssPrefixes('')).toBe('');
});

it('logs an error and falls back to the original CSS when PostCSS fails to parse it', () => {
// Missing closing brace — genuinely invalid CSS that PostCSS's parser
// will throw a CssSyntaxError on.
const invalidCss = '.foo{color:red';

const result = optimiseCssPrefixes(invalidCss);

expect(result).toBe(invalidCss);
expect(loggerMock.error).toHaveBeenCalledWith(
'amp_lite_css_autoprefixer_error',
expect.objectContaining({ message: expect.any(String) }),
);
});
});
37 changes: 37 additions & 0 deletions ws-nextjs-app/utilities/optimiseCssPrefixes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import postcss from 'postcss';
import autoprefixer from 'autoprefixer';
import nodeLogger from '#lib/logger.node';
import logCodes from '#app/lib/logger.const';
import { browserslist as targetBrowsers } from '../../package.json';

/**
* Public API for this module: optimiseCssPrefixes (default export)
*
* Removes vendor-prefixed CSS that isn't needed for the project's real target
* browsers (see `browserslist` in package.json), using Autoprefixer's own
* caniuse-backed compatibility data rather than a hand-rolled pattern match.
* This correctly keeps properties with no standard equivalent (e.g.
* `-webkit-overflow-scrolling`) regardless of target, since Autoprefixer only
* removes prefixes it knows are safe to remove for the given browser list.
*/

const logger = nodeLogger(__filename);

const autoprefixerPlugin = autoprefixer({
overrideBrowserslist: targetBrowsers,
remove: true,
Comment thread
HarveyPeachey marked this conversation as resolved.
});

const optimiseCssPrefixes = (css: string): string => {
try {
return postcss([autoprefixerPlugin]).process(css, { from: undefined }).css;
} catch (e) {
logger.error(logCodes.AMP_LITE_CSS_AUTOPREFIXER_ERROR, {
message: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
});
return css;
}
};
Comment on lines +18 to +35

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot informed me that cascade is only needed where multi-line css is passed in, the css is already minified so it will have no effect.

Given this logic runs in a lambda and different pages will include different css I think the benefit of the cache would be limited. I'll look again at this for further optimisation


export default optimiseCssPrefixes;
Loading
Loading