diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index a0ffe90b63cb1..ace2312e0385f 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -308,6 +308,7 @@ mod nextjs { pub mod no_sync_scripts; pub mod no_title_in_document_head; pub mod no_typos; + pub mod no_unwanted_polyfillio; } oxc_macros::declare_all_lint_rules! { @@ -582,4 +583,5 @@ oxc_macros::declare_all_lint_rules! { nextjs::no_title_in_document_head, nextjs::no_typos, nextjs::no_document_import_in_page, + nextjs::no_unwanted_polyfillio, } diff --git a/crates/oxc_linter/src/rules/nextjs/no_unwanted_polyfillio.rs b/crates/oxc_linter/src/rules/nextjs/no_unwanted_polyfillio.rs new file mode 100644 index 0000000000000..cdff0ddc3b4c4 --- /dev/null +++ b/crates/oxc_linter/src/rules/nextjs/no_unwanted_polyfillio.rs @@ -0,0 +1,275 @@ +use oxc_ast::{ + ast::{JSXAttributeItem, JSXAttributeName, JSXAttributeValue, JSXElementName, JSXIdentifier}, + AstKind, +}; +use oxc_diagnostics::{ + miette::{self, Diagnostic}, + thiserror::{self, Error}, +}; +use oxc_macros::declare_oxc_lint; +use oxc_semantic::AstNode; +use oxc_span::Span; +use phf::{phf_set, Set}; + +use crate::{context::LintContext, rule::Rule}; + +#[derive(Debug, Error, Diagnostic)] +#[error("eslint-plugin-next(no-unwanted-polyfillio): No duplicate polyfills from Polyfill.io are allowed. {0} already shipped with Next.js.")] +#[diagnostic( + severity(warning), + help("See https://nextjs.org/docs/messages/no-unwanted-polyfillio") +)] +struct NoUnwantedPolyfillioDiagnostic(String, #[label] pub Span); + +#[derive(Debug, Default, Clone)] +pub struct NoUnwantedPolyfillio; + +declare_oxc_lint!( + /// ### What it does + /// Prevent duplicate polyfills from Polyfill.io. + /// + /// ### Why is this bad? + /// You are using polyfills from Polyfill.io and including polyfills already shipped with Next.js. This unnecessarily increases page weight which can affect loading performance. + /// + /// ### Example + /// ```javascript + /// + /// + /// + /// ``` + NoUnwantedPolyfillio, + correctness +); + +// Keep in sync with next.js polyfills file : https://github.com/vercel/next.js/blob/master/packages/next-polyfill-nomodule/src/index.js +const NEXT_POLYFILLED_FEATURES: Set<&'static str> = phf_set! { + "Array.prototype.@@iterator", + "Array.prototype.at", + "Array.prototype.copyWithin", + "Array.prototype.fill", + "Array.prototype.find", + "Array.prototype.findIndex", + "Array.prototype.flatMap", + "Array.prototype.flat", + "Array.from", + "Array.prototype.includes", + "Array.of", + "Function.prototype.name", + "fetch", + "Map", + "Number.EPSILON", + "Number.Epsilon", + "Number.isFinite", + "Number.isNaN", + "Number.isInteger", + "Number.isSafeInteger", + "Number.MAX_SAFE_INTEGER", + "Number.MIN_SAFE_INTEGER", + "Number.parseFloat", + "Number.parseInt", + "Object.assign", + "Object.entries", + "Object.fromEntries", + "Object.getOwnPropertyDescriptor", + "Object.getOwnPropertyDescriptors", + "Object.is", + "Object.keys", + "Object.values", + "Reflect", + "Set", + "Symbol", + "Symbol.asyncIterator", + "String.prototype.codePointAt", + "String.prototype.endsWith", + "String.fromCodePoint", + "String.prototype.includes", + "String.prototype.@@iterator", + "String.prototype.padEnd", + "String.prototype.padStart", + "String.prototype.repeat", + "String.raw", + "String.prototype.startsWith", + "String.prototype.trimEnd", + "String.prototype.trimStart", + "URL", + "URL.prototype.toJSON", + "URLSearchParams", + "WeakMap", + "WeakSet", + "Promise", + "Promise.prototype.finally", + "es2015", // Should be covered by babel-preset-env instead. + "es2016", // contains polyfilled 'Array.prototype.includes', 'String.prototype.padEnd' and 'String.prototype.padStart' + "es2017", // contains polyfilled 'Object.entries', 'Object.getOwnPropertyDescriptors', 'Object.values', 'String.prototype.padEnd' and 'String.prototype.padStart' + "es2018", // contains polyfilled 'Promise.prototype.finally' and ''Symbol.asyncIterator' + "es2019", // Contains polyfilled 'Object.fromEntries' and polyfilled 'Array.prototype.flat', 'Array.prototype.flatMap', 'String.prototype.trimEnd' and 'String.prototype.trimStart' + "es5", // Should be covered by babel-preset-env instead. + "es6", // Should be covered by babel-preset-env instead. + "es7", // contains polyfilled 'Array.prototype.includes', 'String.prototype.padEnd' and 'String.prototype.padStart' +}; + +impl Rule for NoUnwantedPolyfillio { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + if let AstKind::JSXOpeningElement(jsx_el) = node.kind() { + let JSXElementName::Identifier(JSXIdentifier { name: tag_name, .. }) = &jsx_el.name + else { + return; + }; + + if tag_name.as_str() != "script" { + let next_script_import_local_name = + ctx.semantic().module_record().import_entries.iter().find_map(|entry| { + if entry.module_request.name().as_str() == "next/script" { + Some(entry.local_name.name()) + } else { + None + } + }); + if !matches!(next_script_import_local_name, Some(import) if tag_name.as_str() == import.as_str()) + { + return; + } + } + + if jsx_el.attributes.len() == 0 { + return; + } + + let Some(JSXAttributeItem::Attribute(src)) = jsx_el.attributes.iter().find(|attr| { + matches!( + attr, + JSXAttributeItem::Attribute(jsx_attr) + if matches!( + &jsx_attr.name, + JSXAttributeName::Identifier(id) if id.name.as_str() == "src" + ) + ) + }) else { + return; + }; + + if let Some(JSXAttributeValue::StringLiteral(src_value)) = &src.value { + if src_value.value.as_str().starts_with("https://cdn.polyfill.io/v2/") + || src_value.value.as_str().starts_with("https://polyfill.io/v3/") + { + let Ok(url) = url::Url::parse(src_value.value.as_str()) else { + return; + }; + + let Some((_, features_value)) = + url.query_pairs().find(|(key, _)| key == "features") + else { + return; + }; + let unwanted_features: Vec<&str> = features_value + .split(',') + .filter(|feature| NEXT_POLYFILLED_FEATURES.contains(feature)) + .collect(); + if !unwanted_features.is_empty() { + ctx.diagnostic(NoUnwantedPolyfillioDiagnostic( + format!( + "{} {}", + unwanted_features.join(", "), + if unwanted_features.len() > 1 { "are" } else { "is" } + ), + src.span, + )); + } + } + } + } + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + r"import {Head} from 'next/document'; + + export class Blah extends Head { + render() { + return ( +
+

Hello title

+ +
+ ); + } + }", + r"import {Head} from 'next/document'; + + export class Blah extends Head { + render() { + return ( +
+

Hello title

+ +
+ ); + } + }", + r" + import Script from 'next/script'; + + export function MyApp({ Component, pageProps }) { + return ( +
+ + +
+ ); + } + }", + r" + export class Blah { + render() { + return ( +
+

Hello title

+ +
+ ); + } + }", + r"import NextScript from 'next/script'; + + export function MyApp({ Component, pageProps }) { + return ( +
+ + +
+ ); + }", + r"import {Head} from 'next/document'; + + export class ES2019Features extends Head { + render() { + return ( +
+

Hello title

+ +
+ ); + } + }", + ]; + + Tester::new(NoUnwantedPolyfillio::NAME, pass, fail).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/no_unwanted_polyfillio.snap b/crates/oxc_linter/src/snapshots/no_unwanted_polyfillio.snap new file mode 100644 index 0000000000000..01dfbec3d445b --- /dev/null +++ b/crates/oxc_linter/src/snapshots/no_unwanted_polyfillio.snap @@ -0,0 +1,41 @@ +--- +source: crates/oxc_linter/src/tester.rs +expression: no_unwanted_polyfillio +--- + ⚠ eslint-plugin-next(no-unwanted-polyfillio): No duplicate polyfills from Polyfill.io are allowed. WeakSet, Promise, Promise.prototype.finally, es2015, es5, es6 are already shipped with Next.js. + ╭─[no_unwanted_polyfillio.tsx:7:1] + 7 │

Hello title

+ 8 │ + · ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + 9 │ + ╰──── + help: See https://nextjs.org/docs/messages/no-unwanted-polyfillio + + ⚠ eslint-plugin-next(no-unwanted-polyfillio): No duplicate polyfills from Polyfill.io are allowed. Array.prototype.copyWithin is already shipped with Next.js. + ╭─[no_unwanted_polyfillio.tsx:6:1] + 6 │

Hello title

+ 7 │ + · ──────────────────────────────────────────────────────────────────────────────── + 8 │ + ╰──── + help: See https://nextjs.org/docs/messages/no-unwanted-polyfillio + + ⚠ eslint-plugin-next(no-unwanted-polyfillio): No duplicate polyfills from Polyfill.io are allowed. Array.prototype.copyWithin is already shipped with Next.js. + ╭─[no_unwanted_polyfillio.tsx:6:1] + 6 │ + 7 │ + · ──────────────────────────────────────────────────────────────────────────────── + 8 │ + ╰──── + help: See https://nextjs.org/docs/messages/no-unwanted-polyfillio + + ⚠ eslint-plugin-next(no-unwanted-polyfillio): No duplicate polyfills from Polyfill.io are allowed. Object.fromEntries is already shipped with Next.js. + ╭─[no_unwanted_polyfillio.tsx:7:1] + 7 │

Hello title

+ 8 │ + · ──────────────────────────────────────────────────────────────────────── + 9 │ + ╰──── + help: See https://nextjs.org/docs/messages/no-unwanted-polyfillio + +