diff --git a/crates/oxc_linter/src/rules/react/purity.rs b/crates/oxc_linter/src/rules/react/purity.rs index 74cd6ec290..63acd4cb9f 100644 --- a/crates/oxc_linter/src/rules/react/purity.rs +++ b/crates/oxc_linter/src/rules/react/purity.rs @@ -16,6 +16,10 @@ declare_react_compiler_lint!( /// not call known-impure functions such as `Math.random()`, `Date.now()`, /// or `performance.now()` during render. /// + /// Async function components are exempt, including impure calls in nested + /// callbacks. An async component is a Server Component: it renders once per + /// request and does not re-render. Async hooks are still checked. + /// upstream = "purity", /// /// ### Why is this bad? @@ -40,6 +44,10 @@ declare_react_compiler_lint!( /// const [rand, setRand] = useState(0); /// return ; /// } + /// + /// async function Posts({ posts }) { + /// return ; + /// } /// ``` Purity, react, diff --git a/crates/oxc_react_compiler/fixtures/purity-async-server-component.js b/crates/oxc_react_compiler/fixtures/purity-async-server-component.js new file mode 100644 index 0000000000..a5e2c9b7ad --- /dev/null +++ b/crates/oxc_react_compiler/fixtures/purity-async-server-component.js @@ -0,0 +1,79 @@ +// @validateNoImpureFunctionsInRender @outputMode:"lint" @compilationMode:"infer" @panicThreshold:"none" +// Async function components are Server Components: they render once per request and never +// re-render, so impure calls are not reported in them or in their nested callbacks. +export default async function Posts() { + const posts = await getPosts(); + return posts.map((p) =>

{Date.now() - p.createdAt < 86_400_000 ? "new" : ""}

); +} + +async function AsyncDeclaration() { + const date = Date.now(); + const now = new Date(); + const rand = Math.random(); + return ; +} + +const AsyncArrow = async () => { + return

{Date.now()}

; +}; + +const AsyncFunctionExpression = async function () { + return

{new Date().getTime()}

; +}; + +async function AsyncMapCallback({ posts }) { + return ( + + ); +} + +async function AsyncHelperCallback({ posts }) { + const label = (post) => (Date.now() - post.createdAt < 86400000 ? "new" : ""); + return ( + + ); +} + +async function AsyncNestedComponent() { + function Badge() { + return {Date.now()}; + } + return ; +} + +// Non-async components are still reported, including after an async component. +function ClientAfterServer() { + return

{Date.now()}

; +} + +const ClientArrow = () => { + return

{Math.random()}

; +}; + +// Nested callbacks of a non-async component still run on every render. +function ClientMapCallback({ posts }) { + return ( + + ); +} + +// Hooks stay checked, including async hooks. +function useNow() { + return {Date.now()}; +} + +async function useAsyncNow() { + return {new Date().getTime()}; +} diff --git a/crates/oxc_react_compiler/src/react_compiler/entrypoint/pipeline.rs b/crates/oxc_react_compiler/src/react_compiler/entrypoint/pipeline.rs index 55a9c5bcd9..2344167db0 100644 --- a/crates/oxc_react_compiler/src/react_compiler/entrypoint/pipeline.rs +++ b/crates/oxc_react_compiler/src/react_compiler/entrypoint/pipeline.rs @@ -170,6 +170,16 @@ pub fn compile_fn<'a, const EMIT: bool>( optimize_props_method_calls(&mut hir, &env); + // Async function components are Server Components: they render once per + // request and never re-render, so `Date.now()` / `Math.random()` are not a + // purity hazard. Nested functions are analysed with this same config, which + // covers callbacks that run during that render. Async hooks are not + // components and stay checked. The upstream React Compiler does not + // special-case this (facebook/react#34834). + if hir.is_async && env.fn_type == ReactFunctionType::Component { + env.config.validate_no_impure_functions_in_render = false; + } + analyse_functions(&mut hir, &mut env, &mut |_inner_func, _inner_env| {})?; if env.has_invariant_errors() { diff --git a/crates/oxc_react_compiler/tests/snapshots/purity-async-server-component.snap b/crates/oxc_react_compiler/tests/snapshots/purity-async-server-component.snap new file mode 100644 index 0000000000..810988ceaf --- /dev/null +++ b/crates/oxc_react_compiler/tests/snapshots/purity-async-server-component.snap @@ -0,0 +1,68 @@ +--- +source: crates/oxc_react_compiler/tests/snapshot.rs +input_file: crates/oxc_react_compiler/fixtures/purity-async-server-component.js +--- +Diagnostics: + +OxcDiagnostic { inner: OxcDiagnosticInner { message: "Cannot call impure function during render", labels: [LabeledSpan { label: Some("Cannot call impure function"), span: Span { start: 1460, end: 1470 }, primary: true }], help: Some("`Date.now` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component re-renders"), note: Some("React Compiler skipped optimizing this component or hook"), severity: Error, code: OxcCode { scope: Some("react-compiler"), number: Some("Purity") }, url: Some("https://react.dev/reference/eslint-plugin-react-hooks/lints/purity") } } +OxcDiagnostic { inner: OxcDiagnosticInner { message: "Cannot call impure function during render", labels: [LabeledSpan { label: Some("Cannot call impure function"), span: Span { start: 1521, end: 1534 }, primary: true }], help: Some("`Math.random` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component re-renders"), note: Some("React Compiler skipped optimizing this component or hook"), severity: Error, code: OxcCode { scope: Some("react-compiler"), number: Some("Purity") }, url: Some("https://react.dev/reference/eslint-plugin-react-hooks/lints/purity") } } +OxcDiagnostic { inner: OxcDiagnosticInner { message: "Cannot call impure function during render", labels: [LabeledSpan { label: Some("Cannot call impure function"), span: Span { start: 1727, end: 1737 }, primary: true }], help: Some("`Date.now` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component re-renders"), note: Some("React Compiler skipped optimizing this component or hook"), severity: Error, code: OxcCode { scope: Some("react-compiler"), number: Some("Purity") }, url: Some("https://react.dev/reference/eslint-plugin-react-hooks/lints/purity") } } +OxcDiagnostic { inner: OxcDiagnosticInner { message: "Cannot call impure function during render", labels: [LabeledSpan { label: Some("Cannot call impure function"), span: Span { start: 1854, end: 1864 }, primary: true }], help: Some("`Date.now` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component re-renders"), note: Some("React Compiler skipped optimizing this component or hook"), severity: Error, code: OxcCode { scope: Some("react-compiler"), number: Some("Purity") }, url: Some("https://react.dev/reference/eslint-plugin-react-hooks/lints/purity") } } +OxcDiagnostic { inner: OxcDiagnosticInner { message: "Cannot call impure function during render", labels: [LabeledSpan { label: Some("Cannot call impure function"), span: Span { start: 1924, end: 1934 }, primary: true }], help: Some("`Date` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component re-renders"), note: Some("React Compiler skipped optimizing this component or hook"), severity: Error, code: OxcCode { scope: Some("react-compiler"), number: Some("Purity") }, url: Some("https://react.dev/reference/eslint-plugin-react-hooks/lints/purity") } } + +// @validateNoImpureFunctionsInRender @outputMode:"lint" @compilationMode:"infer" @panicThreshold:"none" +// Async function components are Server Components: they render once per request and never +// re-render, so impure calls are not reported in them or in their nested callbacks. +export default async function Posts() { + const posts = await getPosts(); + return posts.map((p) =>

{Date.now() - p.createdAt < 864e5 ? "new" : ""}

); +} +async function AsyncDeclaration() { + const date = Date.now(); + const now = new Date(); + const rand = Math.random(); + return ; +} +const AsyncArrow = async () => { + return

{Date.now()}

; +}; +const AsyncFunctionExpression = async function() { + return

{new Date().getTime()}

; +}; +async function AsyncMapCallback({ posts }) { + return
    + {posts.map((p) =>
  • {Date.now() - p.createdAt}
  • )} +
; +} +async function AsyncHelperCallback({ posts }) { + const label = (post) => Date.now() - post.createdAt < 864e5 ? "new" : ""; + return
    + {posts.map((p) =>
  • {label(p)}
  • )} +
; +} +async function AsyncNestedComponent() { + function Badge() { + return {Date.now()}; + } + return ; +} +// Non-async components are still reported, including after an async component. +function ClientAfterServer() { + return

{Date.now()}

; +} +const ClientArrow = () => { + return

{Math.random()}

; +}; +// Nested callbacks of a non-async component still run on every render. +function ClientMapCallback({ posts }) { + return
    + {posts.map((p) =>
  • {Date.now()}
  • )} +
; +} +// Hooks stay checked, including async hooks. +function useNow() { + return {Date.now()}; +} +async function useAsyncNow() { + return {new Date().getTime()}; +}