Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions .changeset/good-kiwis-wonder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@biomejs/biome": minor
---

Added ['noBeforeInteractiveScriptOutsideDocument'](https://biomejs.dev/linter/rules/no-before-interactive-script-outside-document/) to Next.js.
This rule prevents usage of `next/script`'s `beforeInteractive` strategy outside of `pages/_document.js`.
12 changes: 12 additions & 0 deletions crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

293 changes: 159 additions & 134 deletions crates/biome_configuration/src/analyzer/linter/rules.rs

Large diffs are not rendered by default.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion crates/biome_diagnostics_categories/src/categories.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion crates/biome_js_analyze/src/lint/nursery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
use biome_analyze::declare_lint_group;
pub mod no_ambiguous_anchor_text;
pub mod no_before_interactive_script_outside_document;
pub mod no_continue;
pub mod no_deprecated_imports;
pub mod no_duplicated_spread_props;
Expand Down Expand Up @@ -55,4 +56,4 @@ pub mod use_spread;
pub mod use_vue_consistent_define_props_declaration;
pub mod use_vue_define_macros_order;
pub mod use_vue_multi_word_component_names;
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_ambiguous_anchor_text :: NoAmbiguousAnchorText , self :: no_continue :: NoContinue , self :: no_deprecated_imports :: NoDeprecatedImports , self :: no_duplicated_spread_props :: NoDuplicatedSpreadProps , self :: no_empty_source :: NoEmptySource , self :: no_equals_to_null :: NoEqualsToNull , self :: no_floating_promises :: NoFloatingPromises , self :: no_for_in :: NoForIn , self :: no_import_cycles :: NoImportCycles , self :: no_increment_decrement :: NoIncrementDecrement , self :: no_jsx_literals :: NoJsxLiterals , self :: no_leaked_render :: NoLeakedRender , self :: no_misused_promises :: NoMisusedPromises , self :: no_multi_assign :: NoMultiAssign , self :: no_multi_str :: NoMultiStr , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_parameters_only_used_in_recursion :: NoParametersOnlyUsedInRecursion , self :: no_proto :: NoProto , self :: no_react_forward_ref :: NoReactForwardRef , self :: no_return_assign :: NoReturnAssign , self :: no_script_url :: NoScriptUrl , self :: no_shadow :: NoShadow , self :: no_sync_scripts :: NoSyncScripts , self :: no_ternary :: NoTernary , self :: no_undeclared_env_vars :: NoUndeclaredEnvVars , self :: no_unknown_attribute :: NoUnknownAttribute , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_unused_expressions :: NoUnusedExpressions , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_duplicate_keys :: NoVueDuplicateKeys , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: no_vue_setup_props_reactivity_loss :: NoVueSetupPropsReactivityLoss , self :: use_array_sort_compare :: UseArraySortCompare , self :: use_await_thenable :: UseAwaitThenable , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_destructuring :: UseDestructuring , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_find :: UseFind , self :: use_max_params :: UseMaxParams , self :: use_qwik_method_usage :: UseQwikMethodUsage , self :: use_qwik_valid_lexical_scope :: UseQwikValidLexicalScope , self :: use_regexp_exec :: UseRegexpExec , self :: use_sorted_classes :: UseSortedClasses , self :: use_spread :: UseSpread , self :: use_vue_consistent_define_props_declaration :: UseVueConsistentDefinePropsDeclaration , self :: use_vue_define_macros_order :: UseVueDefineMacrosOrder , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } }
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_ambiguous_anchor_text :: NoAmbiguousAnchorText , self :: no_before_interactive_script_outside_document :: NoBeforeInteractiveScriptOutsideDocument , self :: no_continue :: NoContinue , self :: no_deprecated_imports :: NoDeprecatedImports , self :: no_duplicated_spread_props :: NoDuplicatedSpreadProps , self :: no_empty_source :: NoEmptySource , self :: no_equals_to_null :: NoEqualsToNull , self :: no_floating_promises :: NoFloatingPromises , self :: no_for_in :: NoForIn , self :: no_import_cycles :: NoImportCycles , self :: no_increment_decrement :: NoIncrementDecrement , self :: no_jsx_literals :: NoJsxLiterals , self :: no_leaked_render :: NoLeakedRender , self :: no_misused_promises :: NoMisusedPromises , self :: no_multi_assign :: NoMultiAssign , self :: no_multi_str :: NoMultiStr , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_parameters_only_used_in_recursion :: NoParametersOnlyUsedInRecursion , self :: no_proto :: NoProto , self :: no_react_forward_ref :: NoReactForwardRef , self :: no_return_assign :: NoReturnAssign , self :: no_script_url :: NoScriptUrl , self :: no_shadow :: NoShadow , self :: no_sync_scripts :: NoSyncScripts , self :: no_ternary :: NoTernary , self :: no_undeclared_env_vars :: NoUndeclaredEnvVars , self :: no_unknown_attribute :: NoUnknownAttribute , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_unused_expressions :: NoUnusedExpressions , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_duplicate_keys :: NoVueDuplicateKeys , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: no_vue_setup_props_reactivity_loss :: NoVueSetupPropsReactivityLoss , self :: use_array_sort_compare :: UseArraySortCompare , self :: use_await_thenable :: UseAwaitThenable , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_destructuring :: UseDestructuring , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_find :: UseFind , self :: use_max_params :: UseMaxParams , self :: use_qwik_method_usage :: UseQwikMethodUsage , self :: use_qwik_valid_lexical_scope :: UseQwikValidLexicalScope , self :: use_regexp_exec :: UseRegexpExec , self :: use_sorted_classes :: UseSortedClasses , self :: use_spread :: UseSpread , self :: use_vue_consistent_define_props_declaration :: UseVueConsistentDefinePropsDeclaration , self :: use_vue_define_macros_order :: UseVueDefineMacrosOrder , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } }
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
use biome_analyze::{
Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule,
};
use biome_console::markup;
use biome_diagnostics::Severity;
use biome_js_syntax::jsx_ext::AnyJsxElement;
use biome_rowan::{AstNode, TextRange};
use biome_rule_options::no_before_interactive_script_outside_document::NoBeforeInteractiveScriptOutsideDocumentOptions;

use crate::{
nextjs::{NextUtility, is_next_import},
services::semantic::Semantic,
};

declare_lint_rule! {
/// Prevent usage of `next/script`'s `beforeInteractive` strategy outside of `pages/_document.js` in a Next.js project.
///
/// Next.js provides a `<Script>` component from `next/script` to optimize the loading of third-party scripts. Using the `beforeInteractive`
/// strategy allows scripts to be preloaded before any first-party code. `beforeInteractive` scripts must be placed in `pages/_document.js`.
///
/// This rule checks for any usage of the `beforeInteractive` scripts outside of these files.
///
/// ## Examples
///
/// ### Invalid
///
/// ```js,expect_diagnostic
/// // pages/index.js
/// import Script from 'next/script'
///
/// export default function Index() {
/// return (
/// <div>
/// <Script
/// src="https://example.com/script.js"
/// strategy="beforeInteractive"
/// ></Script>
/// </div>
/// )
/// }
/// ```
///
/// ### Valid
///
/// ```js
/// // pages/_document.js
/// import { Html, Head, Main, NextScript } from 'next/document'
/// import Script from 'next/script'
///
/// export default function Document() {
/// return (
/// <Html>
/// <Head />
/// <body>
/// <Main />
/// <NextScript />
/// <Script
/// src="https://example.com/script.js"
/// strategy="beforeInteractive"
/// ></Script>
/// </body>
/// </Html>
/// )
/// }
/// ```
///
pub NoBeforeInteractiveScriptOutsideDocument {
version: "next",
name: "noBeforeInteractiveScriptOutsideDocument",
language: "jsx",
sources: &[RuleSource::EslintNext("no-before-interactive-script-outside-document").same()],
recommended: false,
severity: Severity::Warning,
domains: &[RuleDomain::Next],
}
}

impl Rule for NoBeforeInteractiveScriptOutsideDocument {
type Query = Semantic<AnyJsxElement>;
type State = TextRange;
type Signals = Option<Self::State>;
type Options = NoBeforeInteractiveScriptOutsideDocumentOptions;

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let is_in_app_dir = ctx
.file_path()
.ancestors()
.any(|a| a.file_name().is_some_and(|f| f == "app"));
// should not run in app dir
if is_in_app_dir {
return None;
}

let jsx_element = ctx.query();
let element_name = jsx_element.name().ok()?.name_value_token().ok()?;
if element_name.text_trimmed() != "Script" {
return None;
}

let semantic_model = ctx.model();
let reference = jsx_element.name().ok()?;
let reference = reference.as_jsx_reference_identifier()?;
let binding = semantic_model.binding(reference)?;
if !is_next_import(&binding, NextUtility::Script) {
return None;
}

let strategy_attribute = jsx_element.find_attribute_by_name("strategy")?;
let strategy_attribute_value = strategy_attribute.as_static_value()?;
let strategy_attribute_value = strategy_attribute_value.text();
if strategy_attribute_value != "beforeInteractive" {
return None;
}

let path = ctx.file_path();

let file_name = path.file_stem()?;

// pages/_document.(js|ts|jsx|tsx)
let is_in_pages_dir = path.parent()?.file_name().is_some_and(|f| f == "pages");
if is_in_pages_dir && file_name == "_document" {
return None;
}

Some(jsx_element.syntax().text_range_with_trivia())
}

fn diagnostic(_: &RuleContext<Self>, range: &Self::State) -> Option<RuleDiagnostic> {
Some(RuleDiagnostic::new(
rule_category!(),
range,
markup! {
"Don't use "<Emphasis>"next/script"</Emphasis>" component with the `"<Emphasis>"beforeInteractive"</Emphasis>"` strategy outside of "<Emphasis>"pages/_document.js"</Emphasis>"."
},
).note(markup! {
"See the "<Hyperlink href="https://nextjs.org/docs/messages/no-before-interactive-script-outside-document">"Next.js docs"</Hyperlink>" for more details."
}))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Script from 'next/script'

export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
<Script
src="https://example.com/script.js"
strategy="beforeInteractive"
/>
</html>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
expression: layout.jsx
---
# Input
```jsx
import Script from 'next/script'

export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
<Script
src="https://example.com/script.js"
strategy="beforeInteractive"
/>
</html>
)
}

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Html, Head, Main, NextScript } from 'next/document'
import Script from 'next/script'

export default function Document() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
<Script
src="https://example.com/script.js"
strategy="beforeInteractive"
></Script>
</body>
</Html>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
expression: _document.jsx
---
# Input
```jsx
import { Html, Head, Main, NextScript } from 'next/document'
import Script from 'next/script'

export default function Document() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
<Script
src="https://example.com/script.js"
strategy="beforeInteractive"
></Script>
</body>
</Html>
)
}

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Script from 'next/script'

export default function Index() {
return (
<div>
<Script
src="https://example.com/script.js"
strategy="beforeInteractive"
></Script>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
expression: index.jsx
---
# Input
```jsx
import Script from 'next/script'

export default function Index() {
return (
<div>
<Script
src="https://example.com/script.js"
strategy="beforeInteractive"
></Script>
</div>
)
}

```

# Diagnostics
```
index.jsx:6:7 lint/nursery/noBeforeInteractiveScriptOutsideDocument ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

! Don't use next/script component with the `beforeInteractive` strategy outside of pages/_document.js.

4 │ return (
5 │ <div>
> 6 │ <Script
│ ^^^^^^^
> 7 │ src="https://example.com/script.js"
> 8 │ strategy="beforeInteractive"
> 9 │ ></Script>
│ ^
10 │ </div>
11 │ )

i See the Next.js docs for more details.

i This rule belongs to the nursery group, which means it is not yet stable and may change in the future. Visit https://biomejs.dev/linter/#nursery for more information.


```
1 change: 1 addition & 0 deletions crates/biome_rule_options/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub mod no_autofocus;
pub mod no_await_in_loops;
pub mod no_banned_types;
pub mod no_barrel_file;
pub mod no_before_interactive_script_outside_document;
pub mod no_biome_first_exception;
pub mod no_bitwise_operators;
pub mod no_blank_target;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
use biome_deserialize_macros::{Deserializable, Merge};
use serde::{Deserialize, Serialize};
#[derive(Default, Clone, Debug, Deserialize, Deserializable, Merge, Eq, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
pub struct NoBeforeInteractiveScriptOutsideDocumentOptions {}
Loading
Loading