Skip to content

Improve maintainability of vendor prefix stripping - #14253

Merged
andrewscfc merged 14 commits into
latestfrom
vendor-prefix-optimize
Jul 29, 2026
Merged

Improve maintainability of vendor prefix stripping#14253
andrewscfc merged 14 commits into
latestfrom
vendor-prefix-optimize

Conversation

@andrewscfc

@andrewscfc andrewscfc commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

PR Description generated by AI, verified by me

Summary

Replaces the regex-based vendor-prefix stripping in AMP/Lite CSS (introduced in #14229 to fix the AMP 75KB CSS limit) with a safer, standards-driven approach using Autoprefixer and a proper browserslist config.

The previous approach used a hand-written regex to blindly strip any -webkit-/-moz-/-ms-/-o- prefixed CSS. This risked breaking properties Autoprefixer doesn't manage at all — e.g. -webkit-overflow-scrolling (no standard alternative exists for iOS momentum scrolling), and -webkit-line-clamp/-webkit-box-orient (multi-line text truncation — a standard line-clamp property now exists in the CSS Overflow Module Level 4 spec, but isn't yet supported by any browser in our target list) — since a regex can't distinguish "safe duplicate" from "the only way to get this behaviour in the browsers we support."

Autoprefixer solves this correctly: it uses real caniuse compatibility data against our declared target browsers to decide what's safe to remove, and leaves untouched anything it doesn't recognise as a trackable prefix. This required first adding a proper browserslist config to the project, which didn't previously exist (Next.js was falling back to a very broad legacy default, including IE11 support the project doesn't actually need).

Code changes

  • Added a browserslist config to package.json, based on the project's legacy babel.config.main.js target list, with:
    • ie >= 11 dropped (IE11 has no support for CSS custom properties, which our CSS Modules theming relies on entirely — so it was already incompatible regardless of this config)
    • op_mini >= 18 corrected to op_mini all (Opera Mini's data-saving proxy isn't versioned in caniuse-lite the way other browsers are)
    • op_mob >= 80 added (previously missing entirely; targets the modern Chromium-based Opera Mobile only, excluding pre-80 Presto-engine versions which Browserslist itself marks as dead)
    • firefox/and_ff raised to >= 52 and safari/ios_saf raised to >= 12.1/>= 12.2 — these are the actual minimum versions in the legacy target list once the incorrect entries above are fixed, and keeping them precise avoids Autoprefixer being overly conservative
  • Added autoprefixer and postcss as direct dependencies of the ws-nextjs-app workspace
  • Replaced stripVendorPrefixes/optimiseAmpCss (regex-based) in _document.page.tsx with optimiseCssPrefixes, extracted into its own module (optimiseCssPrefixes), which runs the combined CSS through postcss([autoprefixer({ overrideBrowserslist, remove: true })])
  • Applied the new optimisation consistently to both the AMP and Lite render branches (previously only AMP was optimised)
  • Added AMP_LITE_CSS_AUTOPREFIXER_ERROR log code — if Autoprefixer throws for any reason, the error is logged and the unprocessed CSS is used as a fallback rather than breaking the render
  • Added unit tests for optimiseCssPrefixes covering: removing unnecessary legacy prefixes, preserving prefixes Autoprefixer doesn't track (that this codebase depends on), adding a genuinely-required prefix, no-op/empty-input behaviour, and the PostCSS-failure fallback/logging path
  • Added integration test coverage (inlinedCss.amp.ts/inlinedCss.lite.ts) asserting unnecessary vendor prefixes left in by Emotion no longer appear in real rendered AMP/Lite page output

Testing

  1. yarn workspace simorgh-nextjs test utilities/optimiseCssPrefixes — unit tests for the new module
  2. yarn test:integration (from the repo root) — builds, starts the server, and runs all Canonical/AMP/Lite integration suites end-to-end automatically; confirms vendor prefixes are stripped from real rendered output, while genuinely-needed prefixes are preserved.
    • Alternatively, to run just the AMP or Lite suites in isolation: yarn workspace simorgh-nextjs build:local && yarn workspace simorgh-nextjs start (in one terminal), then yarn workspace simorgh-nextjs test:integration:amp / test:integration:lite (in another) — these scoped scripts assume a server is already running on port 7081 and won't build/start one for you.
  3. Run npx browserslist from ws-nextjs-app to confirm the config resolves without warnings

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR replaces the runtime regex-based stripping of vendor-prefixed CSS (used to reduce inline AMP/Lite CSS size) with a PostCSS + Autoprefixer-based approach driven by an explicit browserslist configuration for the ws-nextjs-app workspace.

Changes:

  • Adds a browserslist target list to ws-nextjs-app/package.json and uses it to drive prefix removal decisions.
  • Replaces the regex vendor-prefix stripping in ws-nextjs-app/pages/_document.page.tsx with postcss([autoprefixer({ remove: true })]), with error logging + fallback to unprocessed CSS.
  • Adds autoprefixer and postcss dependencies (and updates lockfile), and introduces a dedicated log code for Autoprefixer failures.

Reviewed changes

Copilot reviewed 3 out of 13 changed files in this pull request and generated 1 comment.

File Description
yarn.lock Adds lock entries for autoprefixer, postcss, and their dependency graph.
ws-nextjs-app/pages/_document.page.tsx Switches AMP/Lite inline CSS “optimisation” from regex stripping to Autoprefixer/PostCSS with logging + fallback; applies to both AMP and Lite branches.
ws-nextjs-app/package.json Introduces workspace browserslist config and adds runtime deps needed to run PostCSS/Autoprefixer server-side.
src/app/lib/logger.const.js Adds AMP_LITE_CSS_AUTOPREFIXER_ERROR log code for runtime failures.
Comments suppressed due to low confidence (1)

ws-nextjs-app/pages/_document.page.tsx:70

  • The previous AMP optimisation also performed small, safe minifications (e.g. removing ;} and collapsing repeated whitespace). optimiseCssPrefixes no longer does this, which can inflate the inline AMP/Lite <style> and make it easier to hit AMP’s 75KB CSS limit again.
const optimiseCssPrefixes = (css: string): string => {
  try {
    return postcss([autoprefixerPlugin]).process(css, { from: undefined }).css;
  } catch (e) {

Comment thread ws-nextjs-app/pages/_document.page.tsx Outdated
Comment on lines +62 to +65
const autoprefixerPlugin = autoprefixer({
overrideBrowserslist: targetBrowsers,
remove: true,
});

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.

This change would stop it adding any genuinely needed prefixes that emotion may miss, I'd rather leave this unchanged

- Introduced a new launch configuration for profiling Next.js production builds.
- Enhanced logging with AMP/Lite CSS size metrics.
- Implemented `pruneUnusedCssCustomProperties` to remove unused CSS custom properties.
- Added tests for `pruneUnusedCssCustomProperties` to ensure correct functionality.
[copilot]
@andrewscfc
andrewscfc force-pushed the vendor-prefix-optimize branch from 03092be to cf0b4fd Compare July 24, 2026 08:34
@andrewscfc
andrewscfc requested a review from Copilot July 24, 2026 09:29
@andrewscfc
andrewscfc marked this pull request as ready for review July 24, 2026 09:29
@andrewscfc
andrewscfc requested a review from a team as a code owner July 24, 2026 09:29
@andrewscfc andrewscfc changed the title Vendor prefix optimize Improve maintainability of vendor prefix stripping Jul 24, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 7 out of 17 changed files in this pull request and generated 1 comment.

Comment on lines +18 to +35
const logger = nodeLogger(__filename);

const autoprefixerPlugin = autoprefixer({
overrideBrowserslist: targetBrowsers,
remove: true,
});

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;
}
};

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

@andrewscfc

Copy link
Copy Markdown
Contributor Author

Here's a size comparison before and after this PR, you'll see the size has gone up a bit but it was stripping to much according to the explanation below:

AI:

Compared AMP inlined CSS size before/after this PR, on /pidgin/articles/crrrkxz2k0ko.amp:

Before (regex-based optimiseAmpCss, on latest) After (this PR, Autoprefixer-based)
44,453 B 46,892 B (+2,439 B, +5.5%)

The size increase is expected and is the cost of fixing a real functional regression: the old regex approach blindly stripped every vendor-prefixed declaration, including properties with no safe removal path (-webkit-overflow-scrolling for iOS momentum scrolling, -webkit-line-clamp/-webkit-box-orient for multi-line text truncation) — these were silently missing from the old output entirely. This PR's output is larger specifically because it correctly restores them, while still removing the genuinely unnecessary legacy 2009/2012 flexbox prefixes the regex also caught.

@HarveyPeachey HarveyPeachey left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Page Avg transform time P95 transform time
AMP /news/articles/c0g992jmmkko.amp 16.37 ms 29.70 ms
Lite /gahuza/articles/cey23zx8wx8o.lite 15.85 ms 32.92 ms

Did a quick isolation of the autoprefixer visiting local pages using AI, won't be fully accurate to production, but will give us a rough idea for how much processing time this might add

The script it produced and ran:

Code
for i in $(seq 1 40); do curl -fsS "http://127.0.0.1:7081/news/articles/c0g992jmmkko.amp" >/dev/null && break; sleep 2; done && node - <<'NODE'
const { performance } = require('node:perf_hooks');
const autoprefixer = require('autoprefixer');
const postcss = require('postcss');
const pkg = require('./package.json');

const urls = {
  amp: 'http://127.0.0.1:7081/news/articles/c0g992jmmkko.amp',
  lite: 'http://127.0.0.1:7081/gahuza/articles/cey23zx8wx8o.lite',
};

const extractAmpCss = html => {
  const m = html.match(/<style[^>]*amp-custom[^>]*>([\s\S]*?)<\/style>/i);
  return m ? m[1] : '';
};

const extractLiteCss = html => {
  const matches = [...html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)];
  return matches.map(m => m[1]).join('');
};

const processor = postcss([
  autoprefixer({ overrideBrowserslist: pkg.browserslist }),
]);

const optimise = css => processor.process(css, { from: undefined }).css;

const pct = (a,b) => (a === 0 ? 0 : ((b-a)/a)*100);

(async () => {
  const results = [];
  for (const [kind, url] of Object.entries(urls)) {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`${kind} fetch failed: ${res.status}`);
    const html = await res.text();
    const rawCss = kind === 'amp' ? extractAmpCss(html) : extractLiteCss(html);
    if (!rawCss) throw new Error(`${kind} css extraction failed`);

    const beforeBytes = Buffer.byteLength(rawCss, 'utf8');
    const afterCss = optimise(rawCss);
    const afterBytes = Buffer.byteLength(afterCss, 'utf8');

    const iterations = 50;
    const timings = [];
    for (let i = 0; i < iterations; i += 1) {
      const t0 = performance.now();
      optimise(rawCss);
      timings.push(performance.now() - t0);
    }
    timings.sort((a,b) => a-b);
    const avgMs = timings.reduce((a,b)=>a+b,0) / iterations;
    const p95Ms = timings[Math.floor(iterations * 0.95) - 1];

    results.push({
      kind,
      beforeBytes,
      afterBytes,
      bytesDelta: afterBytes - beforeBytes,
      pctDelta: pct(beforeBytes, afterBytes),
      avgMs,
      p95Ms,
    });
  }

  const totalBefore = results.reduce((s, r) => s + r.beforeBytes, 0);
  const totalAfter = results.reduce((s, r) => s + r.afterBytes, 0);

  console.log(JSON.stringify({
    results,
    total: {
      beforeBytes: totalBefore,
      afterBytes: totalAfter,
      bytesDelta: totalAfter - totalBefore,
      pctDelta: pct(totalBefore, totalAfter),
    },
  }, null, 2));
})();

Comment thread ws-nextjs-app/utilities/optimiseCssPrefixes/index.ts
@andrewscfc

Copy link
Copy Markdown
Contributor Author

Page Avg transform time P95 transform time
AMP /news/articles/c0g992jmmkko.amp 16.37 ms 29.70 ms
Lite /gahuza/articles/cey23zx8wx8o.lite 15.85 ms 32.92 ms

Did a quick isolation of the autoprefixer visiting local pages using AI, won't be fully accurate to production, but will give us a rough idea for how much processing time this might add

The script it produced and ran:
Code

This looks good compared to the 100ms-ish the old solution seemed to add to response times

@paruchurisilpa

Copy link
Copy Markdown
Contributor

Checked the steps mentioned on the PR under testing and they all look good. All tests pass. Conflicts needs resolving.

@andrewscfc
andrewscfc merged commit 7963ca1 into latest Jul 29, 2026
17 checks passed
@andrewscfc
andrewscfc deleted the vendor-prefix-optimize branch July 29, 2026 08:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants