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
5 changes: 5 additions & 0 deletions .changeset/good-kiwis-wonder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@biomejs/biome": patch

Added the nursery rule [`noBeforeInteractiveScriptOutsideDocument`](https://biomejs.dev/linter/rules/no-before-interactive-script-outside-document/) to the Next.js domain.
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.

309 changes: 167 additions & 142 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.

1 change: 1 addition & 0 deletions 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
///
/// ```jsx,expect_diagnostic
/// // pages/index.jsx
/// import Script from 'next/script'
///
/// export default function Index() {
/// return (
/// <div>
/// <Script
/// src="https://example.com/script.js"
/// strategy="beforeInteractive"
/// ></Script>
/// </div>
/// )
/// }
/// ```
///
/// ### Valid
///
/// ```jsx,ignore
/// // pages/_document.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>
/// )
/// }
/// ```
///
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" && a.is_dir()));
// should not run in app dir
if is_in_app_dir {
return None;
}
Comment on lines +85 to +92

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.

⚠️ Potential issue | 🟡 Minor

Potential false negative for files under unrelated app directories.

The ancestor check matches any directory named app in the path, not just Next.js's App Router directory. A path like /home/user/app/my-project/pages/index.jsx would incorrectly skip the rule.

Consider checking that app is a sibling to pages or at a known project root level, though this edge case may be acceptable for practical use.

@taga3s taga3s Dec 25, 2025

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.

It is hard for me to cover these edge cases just by file path.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We can always straighten those out later


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."
}))
}
}
Comment thread
dyc3 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/* should not generate diagnostics */
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,22 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
expression: layout.jsx
---
# Input
```jsx
/* should not generate diagnostics */
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,19 @@
/* should not generate diagnostics */
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,27 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
expression: _document.jsx
---
# Input
```jsx
/* should not generate diagnostics */
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,13 @@
/* should generate diagnostics */
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,45 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
expression: index.jsx
---
# Input
```jsx
/* should generate diagnostics */
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:7:7 lint/nursery/noBeforeInteractiveScriptOutsideDocument ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

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

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

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