Skip to content

Commit d566838

Browse files
huntiemeta-codesync[bot]
authored andcommitted
Hide runtime-managed class constructors from public API (#57028)
Summary: Pull Request resolved: #57028 Mark `ReactNativeElement` and `ReadOnlyNode` as non-user-constructible in the generated TypeScript definitions by emitting `protected constructor()`. **Motivation** - Correctness — like web DOM elements (`HTMLElement`, etc.), `ReactNativeElement` instances are created by the React Native runtime, not in user space. See also D107227212. - Reduce public API surface (in particular, since this is also aliased to the `HostInstance` type). **Changes** - Adds a new `build-types protected-constructor` directive and corresponding `build-types` post-transform. - Applies directive to `ReactNativeElement` and `ReadOnlyNode`. - Also extracts shared `build-types` directive helpers into `transforms/typescript/utils/buildDirectives.js`. Changelog: [Internal] Reviewed By: rubennorte Differential Revision: D107119545 fbshipit-source-id: 8320780db12475b00910745d4b8cee8c9f22d547
1 parent 3278f30 commit d566838

8 files changed

Lines changed: 330 additions & 169 deletions

File tree

packages/react-native/ReactNativeApi.d.ts

Lines changed: 103 additions & 118 deletions
Large diffs are not rendered by default.

packages/react-native/src/private/webapis/dom/nodes/ReactNativeElement.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const noop = () => {};
6767
// was slower than this method because the engine has to create an object than
6868
// we then discard to create a new one.
6969

70+
/** @build-types protected-constructor */
7071
class ReactNativeElement extends ReadOnlyElement implements NativeMethods {
7172
// These need to be accessible from `ReactFabricPublicInstanceUtils`.
7273
__nativeTag: number;

packages/react-native/src/private/webapis/dom/nodes/ReadOnlyNode.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const ReadOnlyNodeBase: typeof Object =
5050
// extend this class so it inherits all the methods and it sets the class
5151
// hierarchy correctly.
5252

53+
/** @build-types protected-constructor */
5354
class ReadOnlyNode extends ReadOnlyNodeBase {
5455
constructor(
5556
instanceHandle: InstanceHandle,
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
const replaceProtectedConstructors = require('../replaceProtectedConstructors');
12+
const babel = require('@babel/core');
13+
14+
async function transform(code: string): Promise<string> {
15+
const result = await babel.transformAsync(code, {
16+
plugins: ['@babel/plugin-syntax-typescript', replaceProtectedConstructors],
17+
});
18+
19+
return result?.code ?? '';
20+
}
21+
22+
describe('replaceProtectedConstructors', () => {
23+
test('should not modify class without annotation', async () => {
24+
const result = await transform(
25+
`declare class Foo {
26+
constructor(x: number);
27+
}`,
28+
);
29+
expect(result).toMatchInlineSnapshot(`
30+
"declare class Foo {
31+
constructor(x: number);
32+
}"
33+
`);
34+
});
35+
36+
test('should replace constructor with empty protected constructor()', async () => {
37+
const result = await transform(
38+
`/** @build-types protected-constructor */
39+
declare class Foo {
40+
constructor(x: number, y: string);
41+
blur(): void;
42+
}`,
43+
);
44+
expect(result).toMatchInlineSnapshot(`
45+
"declare class Foo {
46+
protected constructor();
47+
blur(): void;
48+
}"
49+
`);
50+
});
51+
52+
test('should handle exported class', async () => {
53+
const result = await transform(
54+
`/** @build-types protected-constructor */
55+
export declare class Foo {
56+
constructor(x: number);
57+
}`,
58+
);
59+
expect(result).toMatchInlineSnapshot(`
60+
"export declare class Foo {
61+
protected constructor();
62+
}"
63+
`);
64+
});
65+
66+
test('should handle class with extends and implements', async () => {
67+
const result = await transform(
68+
`/** @build-types protected-constructor */
69+
declare class Foo extends Bar implements Baz {
70+
constructor(tag: number, config: Config);
71+
focus(): void;
72+
}`,
73+
);
74+
expect(result).toMatchInlineSnapshot(`
75+
"declare class Foo extends Bar implements Baz {
76+
protected constructor();
77+
focus(): void;
78+
}"
79+
`);
80+
});
81+
82+
test('should preserve surrounding declarations', async () => {
83+
const result = await transform(
84+
`declare class Other {
85+
constructor(x: number);
86+
}
87+
/** @build-types protected-constructor */
88+
declare class Foo {
89+
constructor(x: number);
90+
}
91+
declare class Another {
92+
constructor(y: string);
93+
}`,
94+
);
95+
expect(result).toMatchInlineSnapshot(`
96+
"declare class Other {
97+
constructor(x: number);
98+
}
99+
declare class Foo {
100+
protected constructor();
101+
}
102+
declare class Another {
103+
constructor(y: string);
104+
}"
105+
`);
106+
});
107+
});

scripts/js-api/build-types/transforms/typescript/convertTypeAliasesToInterfaces.js

Lines changed: 7 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ import type {PluginObj} from '@babel/core';
1313

1414
import * as t from '@babel/types';
1515

16+
const {
17+
hasAnnotation,
18+
stripAnnotationComments,
19+
} = require('./utils/buildDirectives');
20+
1621
const ANNOTATION_PATTERN = /@build-types\s+emit-as-interface\b/;
1722

1823
/**
@@ -24,7 +29,7 @@ const ANNOTATION_PATTERN = /@build-types\s+emit-as-interface\b/;
2429
* only possible on `interface` declarations (open), not `type` (closed).
2530
*/
2631
function convertToInterface(path: $FlowFixMe): void {
27-
stripAnnotationComments(path);
32+
stripAnnotationComments(path, ANNOTATION_PATTERN);
2833

2934
const {typeAnnotation} = path.node;
3035
let innerType = typeAnnotation;
@@ -98,32 +103,6 @@ function convertToInterface(path: $FlowFixMe): void {
98103
path.replaceWith(interfaceNode);
99104
}
100105

101-
function hasAnnotationInComments(
102-
comments: ?ReadonlyArray<{type: string, value: string}>,
103-
): boolean {
104-
return (
105-
Array.isArray(comments) &&
106-
comments.some(
107-
comment =>
108-
comment.type === 'CommentBlock' &&
109-
ANNOTATION_PATTERN.test(comment.value),
110-
)
111-
);
112-
}
113-
114-
function hasEmitAsInterfaceAnnotation(path: $FlowFixMe): boolean {
115-
if (hasAnnotationInComments(path.node.leadingComments)) {
116-
return true;
117-
}
118-
if (
119-
path.parentPath?.isExportNamedDeclaration() &&
120-
hasAnnotationInComments(path.parentPath.node.leadingComments)
121-
) {
122-
return true;
123-
}
124-
return false;
125-
}
126-
127106
function typeToExtendsClause(
128107
tsType: t.TSType,
129108
wrapInReadonly: boolean,
@@ -156,33 +135,10 @@ function makePropertiesReadonly(members: Array<t.TSTypeElement>): void {
156135
}
157136
}
158137

159-
function stripAnnotationComments(path: $FlowFixMe): void {
160-
const filter = (comments: $FlowFixMe) =>
161-
comments?.filter(
162-
(c: $FlowFixMe) =>
163-
!(c.type === 'CommentBlock' && ANNOTATION_PATTERN.test(c.value)),
164-
) ?? [];
165-
path.node.leadingComments = filter(path.node.leadingComments);
166-
if (path.parentPath?.isExportNamedDeclaration()) {
167-
path.parentPath.node.leadingComments = filter(
168-
path.parentPath.node.leadingComments,
169-
);
170-
}
171-
const target = path.parentPath?.isExportNamedDeclaration()
172-
? path.parentPath
173-
: path;
174-
const prevSibling = target.getPrevSibling();
175-
if (prevSibling?.node) {
176-
prevSibling.node.trailingComments = filter(
177-
prevSibling.node.trailingComments,
178-
);
179-
}
180-
}
181-
182138
const visitor: PluginObj<unknown> = {
183139
visitor: {
184140
TSTypeAliasDeclaration(path) {
185-
if (!hasEmitAsInterfaceAnnotation(path)) {
141+
if (!hasAnnotation(path, ANNOTATION_PATTERN)) {
186142
return;
187143
}
188144
convertToInterface(path);
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
* @oncall react_native
10+
*/
11+
12+
import type {PluginObj} from '@babel/core';
13+
14+
const {
15+
hasAnnotation,
16+
stripAnnotationComments,
17+
} = require('./utils/buildDirectives');
18+
19+
const ANNOTATION_PATTERN = /@build-types\s+protected-constructor\b/;
20+
21+
/**
22+
* Replace the constructor of a class annotated with
23+
* `@build-types protected-constructor` with `protected constructor()`.
24+
*
25+
* This is used to hide constructor signatures from the public API, indicating
26+
* that instances are not user-constructible.
27+
*/
28+
const visitor: PluginObj<unknown> = {
29+
visitor: {
30+
ClassDeclaration(path) {
31+
if (!hasAnnotation(path, ANNOTATION_PATTERN)) {
32+
return;
33+
}
34+
stripAnnotationComments(path, ANNOTATION_PATTERN);
35+
36+
for (const member of path.node.body.body) {
37+
if (member.kind === 'constructor') {
38+
// $FlowFixMe[prop-missing]
39+
member.accessibility = 'protected';
40+
// $FlowFixMe[prop-missing]
41+
member.params = [];
42+
// $FlowFixMe[prop-missing]
43+
// $FlowFixMe[incompatible-type]
44+
member.returnType = null;
45+
}
46+
}
47+
},
48+
},
49+
};
50+
51+
module.exports = visitor;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
* @oncall react_native
10+
*/
11+
12+
function hasAnnotationInComments(
13+
comments: ?ReadonlyArray<{type: string, value: string}>,
14+
pattern: RegExp,
15+
): boolean {
16+
return (
17+
Array.isArray(comments) &&
18+
comments.some(
19+
comment => comment.type === 'CommentBlock' && pattern.test(comment.value),
20+
)
21+
);
22+
}
23+
24+
function hasAnnotation(path: $FlowFixMe, pattern: RegExp): boolean {
25+
if (hasAnnotationInComments(path.node.leadingComments, pattern)) {
26+
return true;
27+
}
28+
if (
29+
path.parentPath?.isExportNamedDeclaration() &&
30+
hasAnnotationInComments(path.parentPath.node.leadingComments, pattern)
31+
) {
32+
return true;
33+
}
34+
return false;
35+
}
36+
37+
function stripAnnotationComments(path: $FlowFixMe, pattern: RegExp): void {
38+
const filter = (comments: $FlowFixMe) =>
39+
comments?.filter(
40+
(c: $FlowFixMe) => !(c.type === 'CommentBlock' && pattern.test(c.value)),
41+
) ?? [];
42+
path.node.leadingComments = filter(path.node.leadingComments);
43+
if (path.parentPath?.isExportNamedDeclaration()) {
44+
path.parentPath.node.leadingComments = filter(
45+
path.parentPath.node.leadingComments,
46+
);
47+
}
48+
const target = path.parentPath?.isExportNamedDeclaration()
49+
? path.parentPath
50+
: path;
51+
const prevSibling = target.getPrevSibling();
52+
if (prevSibling?.node) {
53+
prevSibling.node.trailingComments = filter(
54+
prevSibling.node.trailingComments,
55+
);
56+
}
57+
}
58+
59+
module.exports = {hasAnnotation, stripAnnotationComments};

scripts/js-api/build-types/translateSourceFile.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const preTransforms: Array<PreTransformFn> = [
3030
];
3131
const postTransforms = (filePath: string): Array<PluginObj<unknown>> => [
3232
require('./transforms/typescript/convertTypeAliasesToInterfaces'),
33+
require('./transforms/typescript/replaceProtectedConstructors'),
3334
require('./transforms/typescript/replaceDefaultExportName')(filePath),
3435
];
3536
const prettierOptions = {parser: 'babel'};

0 commit comments

Comments
 (0)