Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
129 changes: 129 additions & 0 deletions configs/eslint-config/docs/check-parent-suspense.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# @suspensive/check-parent-suspense

Ensures that components using Suspense-related APIs are wrapped in a Suspense boundary.

## Rule Details

This rule checks that components using Suspense-related hooks and components are properly wrapped in a `<Suspense>` component boundary within the same component scope.

### Suspense-related APIs checked:

**Hooks:**

- `useSuspenseQuery`
- `useSuspenseInfiniteQuery`
- `useSuspenseQueries`

**Components:**

- `SuspenseQuery`
- `SuspenseInfiniteQuery`
- `SuspenseQueries`

**Lazy components:**

- Components created with `lazy()` function

## Examples

### ❌ Incorrect

```tsx
// Hook without Suspense wrapper
function MyComponent() {
const data = useSuspenseQuery(queryOptions)
return <div>{data}</div>
}

// Component without Suspense wrapper
function MyComponent() {
return <SuspenseQuery>{(data) => <div>{data}</div>}</SuspenseQuery>
}

// Lazy component without Suspense wrapper
const LazyComponent = lazy(() => import('./Component'))

function MyApp() {
return <LazyComponent />
}
```

### ✅ Correct

```tsx
// Hook with Suspense wrapper
function MyComponent() {
return (
<Suspense fallback={<div>Loading...</div>}>
<div>
{(() => {
const data = useSuspenseQuery(queryOptions)
return <div>{data}</div>
})()}
</div>
</Suspense>
)
}

// Component with Suspense wrapper
function MyComponent() {
return (
<Suspense fallback={<div>Loading...</div>}>
<SuspenseQuery>{(data) => <div>{data}</div>}</SuspenseQuery>
</Suspense>
)
}

// Lazy component with Suspense wrapper
const LazyComponent = lazy(() => import('./Component'))

function MyApp() {
return (
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
)
}
```

## Options

The rule accepts an options object with the following properties:

- `suspenseHooks` (array): List of hook names to check. Default: `['useSuspenseQuery', 'useSuspenseInfiniteQuery', 'useSuspenseQueries']`
- `suspenseComponents` (array): List of component names to check. Default: `['SuspenseQuery', 'SuspenseInfiniteQuery', 'SuspenseQueries']`
- `suspenseWrappers` (array): List of valid Suspense wrapper component names. Default: `['Suspense']`

### Example configuration

```json
{
"rules": {
"@suspensive/check-parent-suspense": [
"error",
{
"suspenseHooks": ["useSuspenseQuery", "useCustomSuspenseHook"],
"suspenseComponents": ["SuspenseQuery", "CustomSuspenseComponent"],
"suspenseWrappers": ["Suspense", "CustomSuspense"]
}
]
}
}
```

## When Not To Use It

This rule enforces Suspense boundaries within the same component scope. In some patterns, components using Suspense APIs may be rendered within Suspense boundaries defined in parent components. If you're using such patterns and want to allow them, you can disable the rule for specific lines:

```tsx
const MyComponent = () => {
// eslint-disable-next-line @suspensive/check-parent-suspense
const data = useSuspenseQuery(queryOptions)
return <div>{data}</div>
}
```

## Related Rules

- React's built-in Suspense documentation: https://react.dev/reference/react/Suspense
- @tanstack/react-query Suspense documentation
5 changes: 5 additions & 0 deletions configs/eslint-config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import cspellConfigs from '@cspell/eslint-plugin/configs'
import vitest from '@vitest/eslint-plugin'
import jestDom from 'eslint-plugin-jest-dom'
import * as mdx from 'eslint-plugin-mdx'
import suspensivePlugin from './plugin.js'

const ignores = ['**/.next/**', '**/build/**', '**/coverage/**', '**/dist/**']

Expand Down Expand Up @@ -123,11 +124,15 @@ export const suspensiveReactTypeScriptConfig = tseslint.config(
JSX: true,
},
},
plugins: {
'@suspensive': suspensivePlugin,
},
rules: {
'react-hooks/react-compiler': 'warn',
'@eslint-react/no-use-context': 'off',
'@eslint-react/no-forward-ref': 'off',
'@eslint-react/no-context-provider': 'off',
'@suspensive/check-parent-suspense': 'error',
},
settings: {
react: {
Expand Down
4 changes: 4 additions & 0 deletions configs/eslint-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,9 @@
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react-hooks": "6.0.0-rc.1",
"typescript-eslint": "^8.31.0"
},
"devDependencies": {
"@babel/eslint-parser": "^7.28.4",
"@babel/preset-react": "^7.27.1"
}
}
13 changes: 13 additions & 0 deletions configs/eslint-config/plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import rules from './rules/index.js'

export default {
rules,
configs: {
recommended: {
plugins: ['@suspensive'],
rules: {
'@suspensive/check-parent-suspense': 'error',
},
},
},
}
184 changes: 184 additions & 0 deletions configs/eslint-config/rules/check-parent-suspense.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
* @fileoverview Rule to check if components using Suspense-related APIs are wrapped in a Suspense boundary
* @author Suspensive Team
*/

/** @type {import('eslint').Rule.RuleModule} */
export default {
meta: {
type: 'problem',
docs: {
description: 'ensure components using Suspense-related APIs are wrapped in a Suspense boundary',
category: 'Possible Errors',
recommended: true,
},
fixable: null,
schema: [
{
type: 'object',
properties: {
suspenseHooks: {
type: 'array',
items: { type: 'string' },
default: ['useSuspenseQuery', 'useSuspenseInfiniteQuery', 'useSuspenseQueries'],
},
suspenseComponents: {
type: 'array',
items: { type: 'string' },
default: ['SuspenseQuery', 'SuspenseInfiniteQuery', 'SuspenseQueries'],
},
suspenseWrappers: {
type: 'array',
items: { type: 'string' },
default: ['Suspense'],
},
},
additionalProperties: false,
},
],
messages: {
missingSuspenseWrapper: 'Component using "{{name}}" must be wrapped in a Suspense boundary.',
missingLazySuspenseWrapper: 'Lazy component "{{name}}" must be wrapped in a Suspense boundary.',
},
},

create(context) {
const options = context.options[0] || {}
const suspenseHooks = new Set(
options.suspenseHooks || ['useSuspenseQuery', 'useSuspenseInfiniteQuery', 'useSuspenseQueries']
)
const suspenseComponents = new Set(
options.suspenseComponents || ['SuspenseQuery', 'SuspenseInfiniteQuery', 'SuspenseQueries']
)
const suspenseWrappers = new Set(options.suspenseWrappers || ['Suspense'])

/**
* Get JSX element name from JSX identifier
*/
function getJSXElementName(nameNode) {
if (nameNode.type === 'JSXIdentifier') {
return nameNode.name
}
if (nameNode.type === 'JSXMemberExpression') {
return `${getJSXElementName(nameNode.object)}.${nameNode.property.name}`
}
return null
}

/**
* Check if a node is inside a Suspense boundary
*/
function isInsideSuspenseBoundary(node) {
let parent = node.parent
while (parent) {
if (parent.type === 'JSXElement' && parent.openingElement && parent.openingElement.name) {
const elementName = getJSXElementName(parent.openingElement.name)
if (suspenseWrappers.has(elementName)) {
return true
}
}
parent = parent.parent
}
return false
}

/**
* Check if a call expression is a suspense hook
*/
function isSuspenseHook(node) {
if (node.type !== 'CallExpression') return false

let name = null
if (node.callee.type === 'Identifier') {
name = node.callee.name
} else if (node.callee.type === 'MemberExpression' && node.callee.property.type === 'Identifier') {
name = node.callee.property.name
}

return name && suspenseHooks.has(name)
}

/**
* Check if a JSX element is a lazy component
*/
function isLazyComponent(node) {
if (node.type !== 'JSXElement') return false

const elementName = getJSXElementName(node.openingElement.name)
if (!elementName) return false

// Check if the component was created with lazy()
const scope = context.sourceCode.getScope(node)
let currentScope = scope
while (currentScope) {
const variable = currentScope.set.get(elementName)
if (variable && variable.defs.length > 0) {
const def = variable.defs[0]
if (def.node.type === 'VariableDeclarator' && def.node.init) {
return isLazyCall(def.node.init)
}
}
currentScope = currentScope.upper
}

return false
}

/**
* Check if a call expression is a lazy() call
*/
function isLazyCall(node) {
if (node.type !== 'CallExpression') return false

return (
(node.callee.type === 'Identifier' && node.callee.name === 'lazy') ||
(node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'lazy')
)
}

return {
// Check for suspense hooks
CallExpression: function (node) {
if (isSuspenseHook(node) && !isInsideSuspenseBoundary(node)) {
let name = null
if (node.callee.type === 'Identifier') {
name = node.callee.name
} else if (node.callee.type === 'MemberExpression') {
name = node.callee.property.name
}

context.report({
node: node.callee,
messageId: 'missingSuspenseWrapper',
data: { name },
})
}
},

// Check suspense components and lazy components
JSXElement: function (node) {
const elementName = getJSXElementName(node.openingElement.name)

// Check if this is a suspense component that needs wrapping
if (elementName && suspenseComponents.has(elementName) && !isInsideSuspenseBoundary(node)) {
context.report({
node: node.openingElement.name,
messageId: 'missingSuspenseWrapper',
data: { name: elementName },
})
}

// Check if this is a lazy component that needs wrapping
if (isLazyComponent(node) && !isInsideSuspenseBoundary(node)) {
context.report({
node: node.openingElement.name,
messageId: 'missingLazySuspenseWrapper',
data: { name: elementName },
})
}
},
}
},
}
5 changes: 5 additions & 0 deletions configs/eslint-config/rules/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import checkParentSuspense from './check-parent-suspense.js'

export default {
'check-parent-suspense': checkParentSuspense,
}
Loading