/
restricted-react-api-plugin.ts
143 lines (128 loc) · 5.82 KB
/
restricted-react-api-plugin.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
/**
* Copyright © 2024 650 Industries.
*/
import { ConfigAPI, types } from '@babel/core';
const INVALID_SERVER_REACT_DOM_APIS = [
'findDOMNode',
'flushSync',
'unstable_batchedUpdates',
'useFormStatus',
'useFormState',
];
// From the React docs: https://github.com/vercel/next.js/blob/d43a387d271263f2c1c4da6b9db826e382fc489c/packages/next-swc/crates/next-custom-transforms/src/transforms/react_server_components.rs#L665-L681
const INVALID_SERVER_REACT_APIS = [
'Component',
'createContext',
'createFactory',
'PureComponent',
'useDeferredValue',
'useEffect',
'useImperativeHandle',
'useInsertionEffect',
'useLayoutEffect',
'useReducer',
'useRef',
'useState',
'useSyncExternalStore',
'useTransition',
'useOptimistic',
];
function isNodeModule(path: string | null | undefined) {
return path != null && /[\\/]node_modules[\\/]/.test(path);
}
// Restricts imports from `react` and `react-dom` when using React Server Components.
const FORBIDDEN_IMPORTS: Record<string, string[]> = {
react: INVALID_SERVER_REACT_APIS,
'react-dom': INVALID_SERVER_REACT_DOM_APIS,
};
export function environmentRestrictedReactAPIsPlugin(
api: ConfigAPI & { types: typeof types }
): babel.PluginObj {
const { types: t } = api;
return {
name: 'expo-environment-restricted-react-api-plugin',
visitor: {
ImportDeclaration(path, state) {
// Skip node_modules
if (isNodeModule(state.file.opts.filename)) {
return;
}
const sourceValue = path.node.source.value;
const forbiddenList = FORBIDDEN_IMPORTS[sourceValue];
if (forbiddenList) {
path.node.specifiers.forEach((specifier) => {
if (t.isImportSpecifier(specifier)) {
const importName = t.isStringLiteral(specifier.imported)
? specifier.imported.value
: specifier.imported.name;
// Check for both named and namespace imports
const isForbidden = forbiddenList.includes(importName);
if (isForbidden) {
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();
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)
? specifier.local
: specifier.local.name;
// Save namespace import for later checks in MemberExpression
path.scope.setData('importedNamespace', { [importName]: sourceValue });
}
});
}
},
// 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.`
);
}
}
});
},
MemberExpression(path) {
const importedNamespaces = path.scope.getData('importedNamespace') || {};
Object.keys(importedNamespaces).forEach((namespace) => {
const library = importedNamespaces[namespace];
const forbiddenList = FORBIDDEN_IMPORTS[library];
const objectName = t.isIdentifier(path.node.object) ? path.node.object.name : null;
if (
objectName === namespace &&
forbiddenList &&
t.isIdentifier(path.node.property) &&
forbiddenList.includes(path.node.property.name)
) {
// Throw a special error for class components since it's not always clear why they cannot be used in RSC.
// e.g. https://x.com/Baconbrix/status/1749223042440392806?s=20
if (path.node.property.name === 'Component') {
throw path.buildCodeFrameError(
`Class components cannot be used in a React server component due to their ability to contain stateful and interactive APIs that cannot be statically evaluated in non-interactive environments such as a server or at build-time. Migrate to a function component, or add the "use client" directive to the top of this file or one of the parent files to render this class component on a user's device.`
);
}
throw path.buildCodeFrameError(
`Client-only "${namespace}" API "${path.node.property.name}" 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.`
);
}
});
},
},
};
}