Skip to content

Commit fd23d07

Browse files
justin808claude
andauthored
Add ESLint rule to prevent 'use client' in server files (#1919)
* Add ESLint rule to prevent 'use client' in server files Add custom ESLint rule no-use-client-in-server-files to catch and prevent 'use client' directives in .server.tsx files, which causes React 19 server bundle compilation errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Integrate ESLint rule and improve implementation - Integrate no-use-client-in-server-files rule into eslint.config.ts - Fix regex to only match at file start (remove multiline flag) - Use template literal for cleaner error message - Remove console.log from test file for cleaner CI output 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix ESLint rule edge cases and improve code quality - Use backreference in regex to ensure matching quotes (prevents 'use client" false positives) - Update PR reference URL from #1896 to #1919 - Simplify newline handling by letting regex capture it consistently 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2def04b commit fd23d07

File tree

3 files changed

+240
-0
lines changed

3 files changed

+240
-0
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* @fileoverview Prevent 'use client' directive in .server.tsx files
3+
* @author React on Rails Team
4+
*/
5+
6+
/**
7+
* ESLint rule to prevent 'use client' directives in .server.tsx files.
8+
*
9+
* Files ending with .server.tsx are intended for server-side rendering in
10+
* React Server Components architecture. The 'use client' directive forces
11+
* webpack to bundle these as client components, which causes errors when
12+
* using React's react-server conditional exports with Shakapacker 9.3.0+.
13+
*
14+
* @type {import('eslint').Rule.RuleModule}
15+
*/
16+
module.exports = {
17+
meta: {
18+
type: 'problem',
19+
docs: {
20+
description: "Prevent 'use client' directive in .server.tsx files",
21+
category: 'Best Practices',
22+
recommended: true,
23+
url: 'https://github.com/shakacode/react_on_rails/pull/1919',
24+
},
25+
messages: {
26+
useClientInServerFile: `Files with '.server.tsx' extension should not have 'use client' directive. Server files are for React Server Components and should not use client-only APIs. If this component needs client-side features, rename it to .client.tsx or .tsx instead.`,
27+
},
28+
schema: [],
29+
fixable: 'code',
30+
},
31+
32+
create(context) {
33+
const filename = context.filename || context.getFilename();
34+
35+
// Only check .server.tsx files
36+
if (!filename.endsWith('.server.tsx') && !filename.endsWith('.server.ts')) {
37+
return {};
38+
}
39+
40+
return {
41+
Program(node) {
42+
const sourceCode = context.sourceCode || context.getSourceCode();
43+
const text = sourceCode.getText();
44+
45+
// Check for 'use client' directive at the start of the file
46+
// Uses backreference (\1) to ensure matching quotes (both single or both double)
47+
// Only matches at the very beginning of the file
48+
const useClientPattern = /^\s*(['"])use client\1;?\s*\n?/;
49+
const match = text.match(useClientPattern);
50+
51+
if (match) {
52+
// Find the exact position of the directive
53+
const directiveIndex = text.indexOf(match[0]);
54+
55+
context.report({
56+
node,
57+
messageId: 'useClientInServerFile',
58+
fix(fixer) {
59+
// Remove the 'use client' directive (regex already captures trailing newline)
60+
return fixer.removeRange([directiveIndex, directiveIndex + match[0].length]);
61+
},
62+
});
63+
}
64+
},
65+
};
66+
},
67+
};
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* @fileoverview Tests for no-use-client-in-server-files rule
3+
*/
4+
5+
const { RuleTester } = require('eslint');
6+
const rule = require('./no-use-client-in-server-files.cjs');
7+
8+
const ruleTester = new RuleTester({
9+
languageOptions: {
10+
ecmaVersion: 2022,
11+
sourceType: 'module',
12+
parserOptions: {
13+
ecmaFeatures: {
14+
jsx: true,
15+
},
16+
},
17+
},
18+
});
19+
20+
ruleTester.run('no-use-client-in-server-files', rule, {
21+
valid: [
22+
{
23+
code: `
24+
import React from 'react';
25+
26+
export function ServerComponent() {
27+
return <div>Server Component</div>;
28+
}
29+
`,
30+
filename: 'Component.server.tsx',
31+
},
32+
{
33+
code: `
34+
import { renderToString } from 'react-dom/server';
35+
36+
export function render() {
37+
return renderToString(<div>Hello</div>);
38+
}
39+
`,
40+
filename: 'ComponentRenderer.server.tsx',
41+
},
42+
{
43+
code: `
44+
'use client';
45+
46+
import React from 'react';
47+
48+
export function ClientComponent() {
49+
return <div>Client Component</div>;
50+
}
51+
`,
52+
filename: 'Component.client.tsx',
53+
},
54+
{
55+
code: `
56+
'use client';
57+
58+
import React from 'react';
59+
60+
export function ClientComponent() {
61+
return <div>Client Component</div>;
62+
}
63+
`,
64+
filename: 'Component.tsx',
65+
},
66+
{
67+
code: `
68+
import React from 'react';
69+
70+
// This is fine - no 'use client' directive
71+
export function ServerComponent() {
72+
return <div>Server</div>;
73+
}
74+
`,
75+
filename: 'App.server.ts',
76+
},
77+
],
78+
79+
invalid: [
80+
{
81+
code: `'use client';
82+
83+
import React from 'react';
84+
85+
export function Component() {
86+
return <div>Component</div>;
87+
}
88+
`,
89+
filename: 'Component.server.tsx',
90+
errors: [
91+
{
92+
messageId: 'useClientInServerFile',
93+
},
94+
],
95+
output: `import React from 'react';
96+
97+
export function Component() {
98+
return <div>Component</div>;
99+
}
100+
`,
101+
},
102+
{
103+
code: `"use client";
104+
105+
import React from 'react';
106+
`,
107+
filename: 'Component.server.tsx',
108+
errors: [
109+
{
110+
messageId: 'useClientInServerFile',
111+
},
112+
],
113+
output: `import React from 'react';
114+
`,
115+
},
116+
{
117+
code: `'use client'
118+
119+
import React from 'react';
120+
`,
121+
filename: 'Component.server.tsx',
122+
errors: [
123+
{
124+
messageId: 'useClientInServerFile',
125+
},
126+
],
127+
output: `import React from 'react';
128+
`,
129+
},
130+
{
131+
code: ` 'use client';
132+
133+
import wrapServerComponentRenderer from 'react-on-rails-pro/wrapServerComponentRenderer/server';
134+
`,
135+
filename: 'AsyncOnServerSyncOnClient.server.tsx',
136+
errors: [
137+
{
138+
messageId: 'useClientInServerFile',
139+
},
140+
],
141+
output: `import wrapServerComponentRenderer from 'react-on-rails-pro/wrapServerComponentRenderer/server';
142+
`,
143+
},
144+
{
145+
code: `'use client';
146+
147+
import React from 'react';
148+
`,
149+
filename: 'Component.server.ts',
150+
errors: [
151+
{
152+
messageId: 'useClientInServerFile',
153+
},
154+
],
155+
output: `import React from 'react';
156+
`,
157+
},
158+
],
159+
});

eslint.config.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import tsEslint from 'typescript-eslint';
88
import { includeIgnoreFile } from '@eslint/compat';
99
import js from '@eslint/js';
1010
import { FlatCompat } from '@eslint/eslintrc';
11+
import noUseClientInServerFiles from './eslint-rules/no-use-client-in-server-files.cjs';
1112

1213
const compat = new FlatCompat({
1314
baseDirectory: __dirname,
@@ -165,6 +166,19 @@ const config = tsEslint.config([
165166
'import/named': 'off',
166167
},
167168
},
169+
{
170+
files: ['**/*.server.ts', '**/*.server.tsx'],
171+
plugins: {
172+
'react-on-rails': {
173+
rules: {
174+
'no-use-client-in-server-files': noUseClientInServerFiles,
175+
},
176+
},
177+
},
178+
rules: {
179+
'react-on-rails/no-use-client-in-server-files': 'error',
180+
},
181+
},
168182
{
169183
files: ['lib/generators/react_on_rails/templates/**/*'],
170184
rules: {

0 commit comments

Comments
 (0)