diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index ace2312e0..0c0422b0b 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -299,6 +299,7 @@ mod nextjs { pub mod next_script_for_ga; pub mod no_assign_module_variable; pub mod no_async_client_component; + pub mod no_before_interactive_script_outside_document; pub mod no_css_tags; pub mod no_document_import_in_page; pub mod no_head_element; @@ -584,4 +585,5 @@ oxc_macros::declare_all_lint_rules! { nextjs::no_typos, nextjs::no_document_import_in_page, nextjs::no_unwanted_polyfillio, + nextjs::no_before_interactive_script_outside_document, } diff --git a/crates/oxc_linter/src/rules/nextjs/no_before_interactive_script_outside_document.rs b/crates/oxc_linter/src/rules/nextjs/no_before_interactive_script_outside_document.rs new file mode 100644 index 000000000..04c8c95fb --- /dev/null +++ b/crates/oxc_linter/src/rules/nextjs/no_before_interactive_script_outside_document.rs @@ -0,0 +1,389 @@ +use oxc_ast::{ + ast::{JSXAttributeItem, JSXAttributeName, JSXAttributeValue, JSXElementName, JSXIdentifier}, + AstKind, +}; +use oxc_diagnostics::{ + miette::{self, Diagnostic}, + thiserror::Error, +}; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; + +use crate::{ + context::LintContext, + rule::Rule, + utils::{get_next_script_import_local_name, is_document_page, is_in_app_dir}, + AstNode, +}; + +#[derive(Debug, Error, Diagnostic)] +#[error("eslint-plugin-next(no-before-interactive-script-outside-document): next/script's `beforeInteractive` strategy should not be used outside of `pages/_document.js`")] +#[diagnostic( + severity(warning), + help("See https://nextjs.org/docs/messages/no-before-interactive-script-outside-document") +)] +struct NoBeforeInteractiveScriptOutsideDocumentDiagnostic(#[label] pub Span); + +#[derive(Debug, Default, Clone)] +pub struct NoBeforeInteractiveScriptOutsideDocument; + +declare_oxc_lint!( + /// ### What it does + /// Prevent usage of `next/script`'s `beforeInteractive` strategy outside of `pages/_document.js`. + /// + /// ### Why is this bad? + /// + /// + /// ### Example + /// ```javascript + /// ``` + NoBeforeInteractiveScriptOutsideDocument, + correctness +); + +impl Rule for NoBeforeInteractiveScriptOutsideDocument { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + if let AstKind::JSXOpeningElement(jsx_el) = node.kind() { + let Some(file_path) = ctx.file_path().to_str() else { return }; + if is_in_app_dir(file_path) { + return; + } + let JSXElementName::Identifier(JSXIdentifier { name: tag_name, .. }) = &jsx_el.name + else { + return; + }; + if jsx_el.attributes.len() == 0 { + return; + } + + let Some(JSXAttributeItem::Attribute(strategy)) = + 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() == "strategy" + ) + ) + }) + else { + return; + }; + + if let Some(JSXAttributeValue::StringLiteral(strategy_value)) = &strategy.value { + if strategy_value.value.as_str() == "beforeInteractive" { + if is_document_page(file_path) { + return; + } + let next_script_import_local_name = get_next_script_import_local_name(ctx); + if !matches!(next_script_import_local_name, Some(import) if tag_name.as_str() == import.as_str()) + { + return; + } + ctx.diagnostic(NoBeforeInteractiveScriptOutsideDocumentDiagnostic( + strategy.span, + )); + } + } + } + } +} + +#[test] +fn test() { + use crate::tester::Tester; + use std::path::PathBuf; + + let pass = vec![ + ( + r#"import Document, { Html, Main, NextScript } from 'next/document' + import Script from 'next/script' + + class MyDocument extends Document { + render() { + return ( + + + + + +
+ + + + + ) + } + } + + export default MyDocument + "#, + None, + None, + Some(PathBuf::from("pages/_document.js")), + ), + ( + r#"import Document, { Html, Main, NextScript } from 'next/document' + import ScriptComponent from 'next/script' + + class MyDocument extends Document { + render() { + return ( + + + + + +
+ + + + + ) + } + } + + export default MyDocument + "#, + None, + None, + Some(PathBuf::from("pages/_document.tsx")), + ), + ( + r#"import Document, { Html, Main, NextScript } from 'next/document' + import ScriptComponent from 'next/script' + + class MyDocument extends Document { + render() { + return ( + + + + + +
+ + + + + ) + } + } + + export default MyDocument + "#, + None, + None, + Some(PathBuf::from("pages/_document.tsx")), + ), + ( + r#"import Script from "next/script"; + + export default function Index() { + return ( + + {children} + + ); + } + "#, + None, + None, + Some(PathBuf::from("pages/index.js")), + ), + ( + r#" import Head from "next/head"; + import Script from "next/script"; + + export default function Index() { + return ( + + ); + } + "#, + None, + None, + Some(PathBuf::from("components/outside-known-dirs.js")), + ), + ( + r#" import Script from "next/script"; + + export default function Index() { + return ( + + {children} + + ╰──── + help: See https://nextjs.org/docs/messages/no-before-interactive-script-outside-document + + ⚠ eslint-plugin-next(no-before-interactive-script-outside-document): next/script's `beforeInteractive` strategy should not be used outside of `pages/_document.js` + ╭─[no_before_interactive_script_outside_document.tsx:8:1] + 8 │ src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptBeforeInteractive" + 9 │ strategy="beforeInteractive" + · ──────────────────────────── + 10 │ > + ╰──── + help: See https://nextjs.org/docs/messages/no-before-interactive-script-outside-document + + ⚠ eslint-plugin-next(no-before-interactive-script-outside-document): next/script's `beforeInteractive` strategy should not be used outside of `pages/_document.js` + ╭─[no_before_interactive_script_outside_document.tsx:8:1] + 8 │ src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptBeforeInteractive" + 9 │ strategy='beforeInteractive' + · ──────────────────────────── + 10 │ /> + ╰──── + help: See https://nextjs.org/docs/messages/no-before-interactive-script-outside-document + + ⚠ eslint-plugin-next(no-before-interactive-script-outside-document): next/script's `beforeInteractive` strategy should not be used outside of `pages/_document.js` + ╭─[no_before_interactive_script_outside_document.tsx:8:1] + 8 │ src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptBeforeInteractive" + 9 │ strategy='beforeInteractive' + · ──────────────────────────── + 10 │ /> + ╰──── + help: See https://nextjs.org/docs/messages/no-before-interactive-script-outside-document + + ⚠ eslint-plugin-next(no-before-interactive-script-outside-document): next/script's `beforeInteractive` strategy should not be used outside of `pages/_document.js` + ╭─[no_before_interactive_script_outside_document.tsx:8:1] + 8 │ src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptBeforeInteractive" + 9 │ strategy='beforeInteractive' + · ──────────────────────────── + 10 │ /> + ╰──── + help: See https://nextjs.org/docs/messages/no-before-interactive-script-outside-document + + ⚠ eslint-plugin-next(no-before-interactive-script-outside-document): next/script's `beforeInteractive` strategy should not be used outside of `pages/_document.js` + ╭─[no_before_interactive_script_outside_document.tsx:8:1] + 8 │ src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=scriptBeforeInteractive" + 9 │ strategy='beforeInteractive' + · ──────────────────────────── + 10 │ /> + ╰──── + help: See https://nextjs.org/docs/messages/no-before-interactive-script-outside-document + + diff --git a/crates/oxc_linter/src/utils/mod.rs b/crates/oxc_linter/src/utils/mod.rs index 117b892b1..bfbd1e9ec 100644 --- a/crates/oxc_linter/src/utils/mod.rs +++ b/crates/oxc_linter/src/utils/mod.rs @@ -1,7 +1,8 @@ mod jest; +mod nextjs; mod node; mod react; mod react_perf; mod unicorn; -pub use self::{jest::*, node::*, react::*, react_perf::*, unicorn::*}; +pub use self::{jest::*, nextjs::*, node::*, react::*, react_perf::*, unicorn::*}; diff --git a/crates/oxc_linter/src/utils/nextjs.rs b/crates/oxc_linter/src/utils/nextjs.rs new file mode 100644 index 000000000..e3e26ccee --- /dev/null +++ b/crates/oxc_linter/src/utils/nextjs.rs @@ -0,0 +1,22 @@ +use oxc_span::Atom; + +use crate::LintContext; + +pub fn is_in_app_dir(file_path: &str) -> bool { + file_path.contains("app/") || file_path.contains("app\\") +} + +pub fn is_document_page(file_path: &str) -> bool { + let Some(page) = file_path.split("pages").last() else { return false }; + page.starts_with("/_document") || page.starts_with("\\_document") +} + +pub fn get_next_script_import_local_name<'a>(ctx: &'a LintContext) -> Option<&'a Atom> { + 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 + } + }) +}