Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
67 changes: 67 additions & 0 deletions eslint-rules/no-use-client-in-server-files.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* @fileoverview Prevent 'use client' directive in .server.tsx files
* @author React on Rails Team
*/

/**
* ESLint rule to prevent 'use client' directives in .server.tsx files.
*
* Files ending with .server.tsx are intended for server-side rendering in
* React Server Components architecture. The 'use client' directive forces
* webpack to bundle these as client components, which causes errors when
* using React's react-server conditional exports with Shakapacker 9.3.0+.
*
* @type {import('eslint').Rule.RuleModule}
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: "Prevent 'use client' directive in .server.tsx files",
category: 'Best Practices',
recommended: true,
url: 'https://github.com/shakacode/react_on_rails/pull/1919',
},
messages: {
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.`,
},
schema: [],
fixable: 'code',
},

create(context) {
const filename = context.filename || context.getFilename();

// Only check .server.tsx files
if (!filename.endsWith('.server.tsx') && !filename.endsWith('.server.ts')) {
return {};
}

return {
Program(node) {
const sourceCode = context.sourceCode || context.getSourceCode();
const text = sourceCode.getText();

// Check for 'use client' directive at the start of the file
// Uses backreference (\1) to ensure matching quotes (both single or both double)
// Only matches at the very beginning of the file
const useClientPattern = /^\s*(['"])use client\1;?\s*\n?/;
const match = text.match(useClientPattern);

if (match) {
// Find the exact position of the directive
const directiveIndex = text.indexOf(match[0]);

context.report({
node,
messageId: 'useClientInServerFile',
fix(fixer) {
// Remove the 'use client' directive (regex already captures trailing newline)
return fixer.removeRange([directiveIndex, directiveIndex + match[0].length]);
},
});
}
},
};
},
};
159 changes: 159 additions & 0 deletions eslint-rules/no-use-client-in-server-files.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* @fileoverview Tests for no-use-client-in-server-files rule
*/

const { RuleTester } = require('eslint');
const rule = require('./no-use-client-in-server-files.cjs');

const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
});

ruleTester.run('no-use-client-in-server-files', rule, {
valid: [
{
code: `
import React from 'react';

export function ServerComponent() {
return <div>Server Component</div>;
}
`,
filename: 'Component.server.tsx',
},
{
code: `
import { renderToString } from 'react-dom/server';

export function render() {
return renderToString(<div>Hello</div>);
}
`,
filename: 'ComponentRenderer.server.tsx',
},
{
code: `
'use client';

import React from 'react';

export function ClientComponent() {
return <div>Client Component</div>;
}
`,
filename: 'Component.client.tsx',
},
{
code: `
'use client';

import React from 'react';

export function ClientComponent() {
return <div>Client Component</div>;
}
`,
filename: 'Component.tsx',
},
{
code: `
import React from 'react';

// This is fine - no 'use client' directive
export function ServerComponent() {
return <div>Server</div>;
}
`,
filename: 'App.server.ts',
},
],

invalid: [
{
code: `'use client';

import React from 'react';

export function Component() {
return <div>Component</div>;
}
`,
filename: 'Component.server.tsx',
errors: [
{
messageId: 'useClientInServerFile',
},
],
output: `import React from 'react';

export function Component() {
return <div>Component</div>;
}
`,
},
{
code: `"use client";

import React from 'react';
`,
filename: 'Component.server.tsx',
errors: [
{
messageId: 'useClientInServerFile',
},
],
output: `import React from 'react';
`,
},
{
code: `'use client'

import React from 'react';
`,
filename: 'Component.server.tsx',
errors: [
{
messageId: 'useClientInServerFile',
},
],
output: `import React from 'react';
`,
},
{
code: ` 'use client';

import wrapServerComponentRenderer from 'react-on-rails-pro/wrapServerComponentRenderer/server';
`,
filename: 'AsyncOnServerSyncOnClient.server.tsx',
errors: [
{
messageId: 'useClientInServerFile',
},
],
output: `import wrapServerComponentRenderer from 'react-on-rails-pro/wrapServerComponentRenderer/server';
`,
},
{
code: `'use client';

import React from 'react';
`,
filename: 'Component.server.ts',
errors: [
{
messageId: 'useClientInServerFile',
},
],
output: `import React from 'react';
`,
},
],
});
14 changes: 14 additions & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import tsEslint from 'typescript-eslint';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import { FlatCompat } from '@eslint/eslintrc';
import noUseClientInServerFiles from './eslint-rules/no-use-client-in-server-files.cjs';

const compat = new FlatCompat({
baseDirectory: __dirname,
Expand Down Expand Up @@ -165,6 +166,19 @@ const config = tsEslint.config([
'import/named': 'off',
},
},
{
files: ['**/*.server.ts', '**/*.server.tsx'],
plugins: {
'react-on-rails': {
rules: {
'no-use-client-in-server-files': noUseClientInServerFiles,
},
},
},
rules: {
'react-on-rails/no-use-client-in-server-files': 'error',
},
},
{
files: ['lib/generators/react_on_rails/templates/**/*'],
rules: {
Expand Down
Loading