Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(eslint-plugin): add no-fast-state rule #2724

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Enable the rules that you would like to use.

```json
"rules": {
"@react-three/no-clone-in-frame-loop": "error"
"@react-three/no-clone-in-loop": "error"
}
```

Expand All @@ -55,6 +55,7 @@ Enable the rules that you would like to use.
| Rule | Description | ✅ | 🔧 | 💡 |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | --- | --- | --- |
| <a href="./docs/rules/no-clone-in-loop.md">no-clone-in-loop</a> | Disallow cloning vectors in the frame loop which can cause performance problems. | ✅ | | |
| <a href="./docs/rules/no-fast-state.md">no-fast-state</a> | Disallow setting state too quickly which can cause performance problems. | ✅ | | |
| <a href="./docs/rules/no-new-in-loop.md">no-new-in-loop</a> | Disallow instantiating new objects in the frame loop which can cause performance problems. | ✅ | | |

<!-- END_CODEGEN -->
Expand Down
2 changes: 1 addition & 1 deletion packages/eslint-plugin/docs/rules/no-clone-in-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function Direction({ targetPosition }) {
}
```

This creates a vector once outside of the frame loop inside a `useMemo` to be reused each frame.
This creates a vector outside of the frame loop using `useMemo` to be reused each frame.

```js
function Direction({ targetPosition }) {
Expand Down
99 changes: 99 additions & 0 deletions packages/eslint-plugin/docs/rules/no-fast-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
Setting state too quickly in the frame loop,
events,
and timers,
can cause performance problems from React scheduling too much work.
Instead mutate your scene objects.

#### ❌ Incorrect

This sets state to increase `x` by 0.1 every 16ms causing React to schedule too much work.

```js
function MoveRight() {
const [x, setX] = useState(0)

useEffect(() => {
const interval = setInterval(() => setX((x) => x + 0.1), 16)
return () => clearInterval(interval)
}, [])

return <mesh position-x={x} />
}
```

This sets state to increase `x` by 0.1 each frame causing React to schedule too much work.

```js
function MoveRight() {
const [x, setX] = useState(0)

useFrame(() => {
setX((x) => x + 0.1)
})

return <mesh position-x={x} />
}
```

This sets state to update `x` every time the pointer moves over the scene object causing React to schedule too much work.

```js
function MatchPointer() {
const [x, setX] = useState(0)

return <mesh onPointerMove={(e) => setX((x) => e.point.x)} />
}
```

#### ✅ Correct

This mutates the scene object to increase `x` by approximately 0.1 every frame.

> **Note** – instead of increasing `x` by a fixed amount in the frame loop multiply by `delta`, the time since last frame, enabling movement to be independent of <abbr title="Frames per second">FPS</abbr> so regardless if your app runs at 10 or 120 FPS your scene will move the same relative amount over time.

```js
function MoveRight() {
const ref = useRef()

useFrame((_, delta) => {
ref.current.position.x += 63 * delta // ~1.008 when 60fps
})

return <mesh ref={ref} />
}
```

This mutates the scene object to set `x` every time the pointer moves over the scene object.

```js
function MatchPointer() {
const ref = useRef()

return (
<mesh
onPointerMove={(e) => {
ref.current.position.x = e.point.x
}}
/>
)
}
```

This sets state in the frame loop but only when a condition changes.

```js
function MatchPointer() {
const ref = useRef()
const [outside, setOutside] = useState(false)

useFrame((_, delta) => {
if (ref.current.position.x > 200 && !outside) {
setOutside(true)
} else if (ref.current.position.x <= 200 && outside) {
setOutside(false)
}
})

return <mesh ref={ref} />
}
```
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export default {
plugins: ['@react-three'],
rules: {
'@react-three/no-clone-in-loop': 'error',
'@react-three/no-fast-state': 'error',
'@react-three/no-new-in-loop': 'error',
},
}
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/recommended.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export default {
plugins: ['@react-three'],
rules: {
'@react-three/no-clone-in-loop': 'error',
'@react-three/no-fast-state': 'error',
'@react-three/no-new-in-loop': 'error',
},
}
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
// @command yarn codegen:eslint

import noCloneInLoop from './no-clone-in-loop'
import noFastState from './no-fast-state'
import noNewInLoop from './no-new-in-loop'

export default {
'no-clone-in-loop': noCloneInLoop,
'no-fast-state': noFastState,
'no-new-in-loop': noNewInLoop,
}
4 changes: 2 additions & 2 deletions packages/eslint-plugin/src/rules/no-clone-in-loop.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Rule } from 'eslint'
import * as ESTree from 'estree'
import type { NewExpression } from 'estree'
import { gitHubUrl } from '../lib/url'

const rule: Rule.RuleModule = {
Expand All @@ -17,7 +17,7 @@ const rule: Rule.RuleModule = {
create(ctx) {
return {
['CallExpression[callee.name=useFrame] CallExpression MemberExpression Identifier[name=clone]'](
node: ESTree.NewExpression,
node: NewExpression,
) {
ctx.report({
messageId: 'noClone',
Expand Down
45 changes: 45 additions & 0 deletions packages/eslint-plugin/src/rules/no-fast-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Rule } from 'eslint'
import type { Identifier } from 'estree'
import { gitHubUrl } from '../lib/url'

function isSetState(node: Identifier & Rule.NodeParentExtension, context: Rule.RuleContext) {
const scope = context.getScope()

for (const ref of scope.references) {
if (ref.identifier === node && ref.resolved?.defs[0].node.init.callee.name === 'useState') {
return true
}
}

return false
}

const rule: Rule.RuleModule = {
meta: {
messages: {
noFastEventSet: '',
noUnconditionalSet: '',
},
docs: {
url: gitHubUrl('no-fast-state'),
recommended: true,
description: 'Disallow setting state too quickly which can cause performance problems.',
},
},
create(ctx) {
return {
['CallExpression[callee.name=useFrame] CallExpression > Identifier'](
node: Identifier & Rule.NodeParentExtension,
) {
if (isSetState(node, ctx)) {
ctx.report({
messageId: 'noUnconditionalSet',
node: node.parent,
})
}
},
}
},
}

export default rule
4 changes: 2 additions & 2 deletions packages/eslint-plugin/src/rules/no-new-in-loop.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Rule } from 'eslint'
import * as ESTree from 'estree'
import type { NewExpression } from 'estree'
import { gitHubUrl } from '../lib/url'

const rule: Rule.RuleModule = {
Expand All @@ -16,7 +16,7 @@ const rule: Rule.RuleModule = {
},
create(ctx) {
return {
['CallExpression[callee.name=useFrame] NewExpression'](node: ESTree.NewExpression) {
['CallExpression[callee.name=useFrame] NewExpression'](node: NewExpression) {
ctx.report({
messageId: 'noNew',
node: node,
Expand Down
76 changes: 76 additions & 0 deletions packages/eslint-plugin/tests/rules/no-fast-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { RuleTester } from 'eslint'
import rule from '../../src/rules/no-fast-state'

const tester = new RuleTester({
parserOptions: { ecmaVersion: 2015 },
})

tester.run('no-fast-state', rule, {
valid: [
`
useFrame((_, delta) => {
ref.current.position.x += 63 * delta // ~1.008 when 60fps
})
`,
`
function MatchPointer() {
const ref = useRef()

return (
<mesh
onPointerMove={(e) => {
ref.current.position.x = e.point.x
}}
/>
)
}
`,
`
function MatchPointer() {
const ref = useRef()
const [outside, setOutside] = useState(false)

useFrame((_, delta) => {
if (ref.current.position.x > 200 && !outside) {
setOutside(true)
} else if (ref.current.position.x <= 200 && outside) {
setOutside(false)
}
})

return <mesh ref={ref} />
}
`,
],
invalid: [
{
code: `
useEffect(() => {
const interval = setInterval(() => setX((x) => x + 0.1), 16)
return () => clearInterval(interval)
}, [])
`,
errors: [{ messageId: 'noUnconditionalSet' }],
},
{
code: `
const [x, setX] = useState(0)

useFrame(() => {
setX((x) => x + 0.1)
})
`,
errors: [{ messageId: 'noUnconditionalSet' }],
},
{
code: `
function MatchPointer() {
const [x, setX] = useState(0)

return <mesh onPointerMove={(e) => setX((x) => e.point.x)} />
}
`,
errors: [{ messageId: 'noFastEventSet' }],
},
],
})