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

chore: relax forbidden react server API errors #27878

Merged
merged 5 commits into from Apr 5, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions packages/babel-preset-expo/CHANGELOG.md
Expand Up @@ -17,6 +17,7 @@

### 💡 Others

- Relax forbidden React server API errors to better support shared components. ([#27878](https://github.com/expo/expo/pull/27878) by [@EvanBacon](https://github.com/EvanBacon))
- Remove unused peer dependency on `@babel/preset-env`. ([#27705](https://github.com/expo/expo/pull/27705) by [@EvanBacon](https://github.com/EvanBacon))
- Disable color in snapshot tests in CI. ([#27301](https://github.com/expo/expo/pull/27301) by [@EvanBacon](https://github.com/EvanBacon))
- Add additional tests for undefined platform minification behavior. ([#27515](https://github.com/expo/expo/pull/27515) by [@EvanBacon](https://github.com/EvanBacon))
Expand Down
26 changes: 24 additions & 2 deletions packages/babel-preset-expo/build/restricted-react-api-plugin.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

This file was deleted.

Expand Up @@ -22,7 +22,7 @@ const DEF_OPTIONS = {
filename: '/unknown',

babelrc: false,
presets: [[preset, { disableImportExportTransform: true }]],
presets: [[preset, {}]],
sourceMaps: true,
configFile: false,
compact: false,
Expand All @@ -49,42 +49,50 @@ function getOpts(caller: Record<string, string | boolean>) {
}

describe('forbidden React server APIs', () => {
function runServerPass(src: string) {
function runServerPass(src: string, options = {}) {
return babel.transform(
src,
getOpts({
isReactServer: true,
platform: 'ios',
...options,
})
);
}
it(`does not assert importing client-side APIs in client components (react server mode)`, () => {
// This test covers the order of server registry running before the assertion to remove the import.
expect(runServerPass(`"use client"; import { useState } from 'react';`).code).toMatch(
expect(runServerPass(`"use client"; import { useState } from 'react';`)!.code).toMatch(
'react-server-dom-webpack'
);
});
it(`does not assert importing client-side React functions in server components`, () => {
runServerPass(`import { useState } from 'react';`);
runServerPass(`import { useRef, useContext } from 'react';`);
runServerPass(`import React, { useRef } from 'react';`);
runServerPass(`import { useRandom } from 'react';`);
});
it(`asserts importing client-side React APIs in server components`, () => {
expect(() => runServerPass(`import { useState } from 'react';`)).toThrowErrorMatchingSnapshot();
expect(() => runServerPass(`import { useRef, useContext } from 'react';`)).toThrowError();
expect(() => runServerPass(`import React, { useRef } from 'react';`)).toThrowError();
expect(() => runServerPass(`import React, { PureComponent } from 'react';`)).toThrowError();
expect(() => runServerPass(`import { PureComponent } from 'react';`)).toThrowError();
expect(() => runServerPass(`import { Component } from 'react';`)).toThrowError();
expect(() => runServerPass(`import { useRandom } from 'react';`)).not.toThrowError();
});
it(`asserts importing client-side react-dom APIs in server components`, () => {
expect(() =>
runServerPass(`import { findDOMNode } from 'react-dom';`)
).toThrowErrorMatchingSnapshot();
expect(() => runServerPass(`import { useRandom } from 'react-dom';`)).not.toThrowError();
// Support importing but not using. This allows for shared components to import React APIs and only have assertions on usage.
it(`does not assert importing client-side react-dom APIs in server components`, () => {
runServerPass(`import { findDOMNode } from 'react-dom';`);
runServerPass(`import { useRandom } from 'react-dom';`);
});
it(`does not assert importing client-side react-dom APIs in server components if they are in node modules`, () => {
expect(
babel.transform(`import { findDOMNode } from 'react-dom';`, {
...DEF_OPTIONS,
filename: '/bacon/node_modules/@bacons/breakfast.js',
caller: getCaller({ ...ENABLED_CALLER, isReactServer: true, platform: 'ios' }),
}).code
caller: getCaller({
...ENABLED_CALLER,
supportsStaticESM: true,
isReactServer: true,
platform: 'ios',
}),
})!.code
).toBe(`import { findDOMNode } from 'react-dom';`);
});

Expand All @@ -100,6 +108,32 @@ describe('forbidden React server APIs', () => {
`)
).toThrow(/cannot be used in a React server component/);
});
it(`asserts if client-side React APIs are used in server components from a named import`, () => {
expect(() =>
runServerPass(`
import { useState } from 'react';

export default function App() {
const [index, setIndex] = useState(0)
return <div>{index}</div>;
}
`)
).toThrow(/cannot be used in a React server component/);
});
it(`does not asserts client-side code without React API usage in server components (named import)`, () => {
expect(() =>
runServerPass(`
import { useState } from 'react';

export default function App() {
const [index, setIndex] = foo()
return <div>{index}</div>;
}

function foo() {}
`)
).not.toThrow();
});
it(`asserts client-side React API usage in server components (default import)`, () => {
expect(() =>
runServerPass(`
Expand Down
37 changes: 33 additions & 4 deletions packages/babel-preset-expo/src/restricted-react-api-plugin.ts
Expand Up @@ -67,10 +67,22 @@ export function environmentRestrictedReactAPIsPlugin(
const isForbidden = forbiddenList.includes(importName);

if (isForbidden) {
// Add special handling for `Component` since it is different to a function API.
throw path.buildCodeFrameError(
`Client-only "${sourceValue}" API "${importName}" cannot be imported in a React server component. Add the "use client" directive to the top of this file or one of the parent files to enable running this stateful code on a user's device.`
);
if (['Component', 'PureComponent'].includes(importName)) {
// Add special handling for `Component` since it is different to a function API.
throw path.buildCodeFrameError(
`Client-only "${sourceValue}" API "${importName}" cannot be imported in a React server component. Add the "use client" directive to the top of this file or one of the parent files to enable running this stateful code on a user's device.`
);
} else {
const forbiddenImports: Map<string, Set<string>> = path.scope.getData(
'forbiddenImports'
) || new Map();
EvanBacon marked this conversation as resolved.
Show resolved Hide resolved

if (!forbiddenImports.has(sourceValue))
forbiddenImports.set(sourceValue, new Set());
forbiddenImports.get(sourceValue)!.add(importName);

path.scope.setData('forbiddenImports', forbiddenImports);
}
}
} else {
const importName = t.isStringLiteral(specifier.local)
Expand All @@ -83,6 +95,23 @@ export function environmentRestrictedReactAPIsPlugin(
});
}
},

// Match against `var _useState = useState(0),`
VariableDeclarator(path) {
const importedSpecifiers: undefined | Map<string, Set<string>> =
path.scope.getData('forbiddenImports');
if (!importedSpecifiers) return;
importedSpecifiers.forEach((forbiddenApis, importName) => {
if (t.isCallExpression(path.node.init) && t.isIdentifier(path.node.init.callee)) {
if (forbiddenApis.has(path.node.init.callee.name)) {
throw path.buildCodeFrameError(
`Client-only "useState" API cannot be used in a React server component. Add the "use client" directive to the top of this file or one of the parent files to enable running this stateful code on a user's device.`
EvanBacon marked this conversation as resolved.
Show resolved Hide resolved
);
}
}
});
},

MemberExpression(path) {
const importedNamespaces = path.scope.getData('importedNamespace') || {};
Object.keys(importedNamespaces).forEach((namespace) => {
Expand Down