Skip to content

Commit a3a1ad2

Browse files
taga3sautofix-ci[bot]Netail
authored
feat(biome_js_analyze): port noBeforeInteractiveScriptOutsideDocument from Next.js (#8580)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Maikel van Dort <[email protected]>
1 parent 2fce3df commit a3a1ad2

17 files changed

Lines changed: 520 additions & 143 deletions

File tree

.changeset/good-kiwis-wonder.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@biomejs/biome": patch
3+
4+
Added the nursery rule [`noBeforeInteractiveScriptOutsideDocument`](https://biomejs.dev/linter/rules/no-before-interactive-script-outside-document/) to the Next.js domain.
5+
This rule prevents usage of `next/script`'s `beforeInteractive` strategy outside of `pages/_document.js`.

crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/analyzer/linter/rules.rs

Lines changed: 167 additions & 142 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/generated/domain_selector.rs

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_diagnostics_categories/src/categories.rs

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_js_analyze/src/lint/nursery.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
55
use biome_analyze::declare_lint_group;
66
pub mod no_ambiguous_anchor_text;
7+
pub mod no_before_interactive_script_outside_document;
78
pub mod no_continue;
89
pub mod no_deprecated_imports;
910
pub mod no_duplicated_spread_props;
@@ -55,4 +56,4 @@ pub mod use_spread;
5556
pub mod use_vue_consistent_define_props_declaration;
5657
pub mod use_vue_define_macros_order;
5758
pub mod use_vue_multi_word_component_names;
58-
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 ,] } }
59+
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 ,] } }
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
use biome_analyze::{
2+
Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule,
3+
};
4+
use biome_console::markup;
5+
use biome_diagnostics::Severity;
6+
use biome_js_syntax::jsx_ext::AnyJsxElement;
7+
use biome_rowan::{AstNode, TextRange};
8+
use biome_rule_options::no_before_interactive_script_outside_document::NoBeforeInteractiveScriptOutsideDocumentOptions;
9+
10+
use crate::{
11+
nextjs::{NextUtility, is_next_import},
12+
services::semantic::Semantic,
13+
};
14+
15+
declare_lint_rule! {
16+
/// Prevent usage of `next/script`'s `beforeInteractive` strategy outside of `pages/_document.js` in a Next.js project.
17+
///
18+
/// Next.js provides a `<Script>` component from `next/script` to optimize the loading of third-party scripts. Using the `beforeInteractive`
19+
/// strategy allows scripts to be preloaded before any first-party code. `beforeInteractive` scripts must be placed in `pages/_document.js`.
20+
///
21+
/// This rule checks for any usage of the `beforeInteractive` scripts outside of these files.
22+
///
23+
/// ## Examples
24+
///
25+
/// ### Invalid
26+
///
27+
/// ```jsx,expect_diagnostic
28+
/// // pages/index.jsx
29+
/// import Script from 'next/script'
30+
///
31+
/// export default function Index() {
32+
/// return (
33+
/// <div>
34+
/// <Script
35+
/// src="https://example.com/script.js"
36+
/// strategy="beforeInteractive"
37+
/// ></Script>
38+
/// </div>
39+
/// )
40+
/// }
41+
/// ```
42+
///
43+
/// ### Valid
44+
///
45+
/// ```jsx,ignore
46+
/// // pages/_document.jsx
47+
/// import { Html, Head, Main, NextScript } from 'next/document'
48+
/// import Script from 'next/script'
49+
///
50+
/// export default function Document() {
51+
/// return (
52+
/// <Html>
53+
/// <Head />
54+
/// <body>
55+
/// <Main />
56+
/// <NextScript />
57+
/// <Script
58+
/// src="https://example.com/script.js"
59+
/// strategy="beforeInteractive"
60+
/// ></Script>
61+
/// </body>
62+
/// </Html>
63+
/// )
64+
/// }
65+
/// ```
66+
///
67+
pub NoBeforeInteractiveScriptOutsideDocument {
68+
version: "next",
69+
name: "noBeforeInteractiveScriptOutsideDocument",
70+
language: "jsx",
71+
sources: &[RuleSource::EslintNext("no-before-interactive-script-outside-document").same()],
72+
recommended: false,
73+
severity: Severity::Warning,
74+
domains: &[RuleDomain::Next],
75+
}
76+
}
77+
78+
impl Rule for NoBeforeInteractiveScriptOutsideDocument {
79+
type Query = Semantic<AnyJsxElement>;
80+
type State = TextRange;
81+
type Signals = Option<Self::State>;
82+
type Options = NoBeforeInteractiveScriptOutsideDocumentOptions;
83+
84+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
85+
let is_in_app_dir = ctx
86+
.file_path()
87+
.ancestors()
88+
.any(|a| a.file_name().is_some_and(|f| f == "app" && a.is_dir()));
89+
// should not run in app dir
90+
if is_in_app_dir {
91+
return None;
92+
}
93+
94+
let jsx_element = ctx.query();
95+
let element_name = jsx_element.name().ok()?.name_value_token().ok()?;
96+
if element_name.text_trimmed() != "Script" {
97+
return None;
98+
}
99+
100+
let semantic_model = ctx.model();
101+
let reference = jsx_element.name().ok()?;
102+
let reference = reference.as_jsx_reference_identifier()?;
103+
let binding = semantic_model.binding(reference)?;
104+
if !is_next_import(&binding, NextUtility::Script) {
105+
return None;
106+
}
107+
108+
let strategy_attribute = jsx_element.find_attribute_by_name("strategy")?;
109+
let strategy_attribute_value = strategy_attribute.as_static_value()?;
110+
let strategy_attribute_value = strategy_attribute_value.text();
111+
if strategy_attribute_value != "beforeInteractive" {
112+
return None;
113+
}
114+
115+
let path = ctx.file_path();
116+
117+
let file_name = path.file_stem()?;
118+
119+
// pages/_document.(js|ts|jsx|tsx)
120+
let is_in_pages_dir = path.parent()?.file_name().is_some_and(|f| f == "pages");
121+
if is_in_pages_dir && file_name == "_document" {
122+
return None;
123+
}
124+
125+
Some(jsx_element.syntax().text_range_with_trivia())
126+
}
127+
128+
fn diagnostic(_: &RuleContext<Self>, range: &Self::State) -> Option<RuleDiagnostic> {
129+
Some(RuleDiagnostic::new(
130+
rule_category!(),
131+
range,
132+
markup! {
133+
"Don't use "<Emphasis>"next/script"</Emphasis>" component with the `"<Emphasis>"beforeInteractive"</Emphasis>"` strategy outside of "<Emphasis>"pages/_document.js"</Emphasis>"."
134+
},
135+
).note(markup! {
136+
"See the "<Hyperlink href="https://nextjs.org/docs/messages/no-before-interactive-script-outside-document">"Next.js docs"</Hyperlink>" for more details."
137+
}))
138+
}
139+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/* should not generate diagnostics */
2+
import Script from 'next/script'
3+
4+
export default function RootLayout({ children }) {
5+
return (
6+
<html lang="en">
7+
<body>{children}</body>
8+
<Script
9+
src="https://example.com/script.js"
10+
strategy="beforeInteractive"
11+
/>
12+
</html>
13+
)
14+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
source: crates/biome_js_analyze/tests/spec_tests.rs
3+
expression: layout.jsx
4+
---
5+
# Input
6+
```jsx
7+
/* should not generate diagnostics */
8+
import Script from 'next/script'
9+
10+
export default function RootLayout({ children }) {
11+
return (
12+
<html lang="en">
13+
<body>{children}</body>
14+
<Script
15+
src="https://example.com/script.js"
16+
strategy="beforeInteractive"
17+
/>
18+
</html>
19+
)
20+
}
21+
22+
```
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/* should not generate diagnostics */
2+
import { Html, Head, Main, NextScript } from 'next/document'
3+
import Script from 'next/script'
4+
5+
export default function Document() {
6+
return (
7+
<Html>
8+
<Head />
9+
<body>
10+
<Main />
11+
<NextScript />
12+
<Script
13+
src="https://example.com/script.js"
14+
strategy="beforeInteractive"
15+
></Script>
16+
</body>
17+
</Html>
18+
)
19+
}

0 commit comments

Comments
 (0)