Skip to content

Commit e8eaab8

Browse files
committed
Infer threaded components from OnRuntime
1 parent ccf78ee commit e8eaab8

8 files changed

Lines changed: 455 additions & 315 deletions

File tree

App.tsx

Lines changed: 19 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { ChatBubble } from './src/chat/ChatBubble';
4040
import type { RenderedChatItem } from './src/native/ComposeChatListNativeComponent';
4141
import {
4242
call,
43+
OnRuntime,
4344
threadedComponent,
4445
Threaded,
4546
ThreadedRuntime,
@@ -1404,17 +1405,15 @@ function SecondRuntimeRnListSurface({
14041405
mode: SecondRuntimeRnBenchmarkMode;
14051406
}) {
14061407
return (
1407-
<Threaded
1408+
<OnRuntime
14081409
accessibilityLabel={`second-runtime-${mode}`}
1409-
component={SecondRuntimeRnListApp}
1410-
props={{
1411-
blockStatus: 'second-runtime',
1412-
mode,
1413-
}}
1410+
name="background-list"
14141411
style={styles.secondRuntimeSurface}
14151412
surfaceKey={mode}
14161413
testID={`second-runtime-${mode}`}
1417-
/>
1414+
>
1415+
<SecondRuntimeRnListApp blockStatus={blockStatus} mode={mode} />
1416+
</OnRuntime>
14181417
);
14191418
}
14201419

@@ -1424,24 +1423,20 @@ type SecondRuntimeRnListAppProps = {
14241423
runtimeName?: string;
14251424
};
14261425

1427-
export const SecondRuntimeRnListApp =
1428-
threadedComponent<SecondRuntimeRnListAppProps>(
1429-
'BenchmarkRnList',
1430-
function SecondRuntimeRnListApp({
1431-
blockStatus = 'second-runtime',
1432-
mode = 'flashlist',
1433-
runtimeName,
1434-
}: SecondRuntimeRnListAppProps) {
1435-
const normalizedMode: SecondRuntimeRnBenchmarkMode =
1436-
mode === 'legendlist' ? 'legendlist' : 'flashlist';
1437-
return (
1438-
<RnListBenchmarkScreen
1439-
blockStatus={`${blockStatus} / ${runtimeName ?? runtimeKind()}`}
1440-
mode={normalizedMode}
1441-
/>
1442-
);
1443-
},
1426+
export function SecondRuntimeRnListApp({
1427+
blockStatus = 'second-runtime',
1428+
mode = 'flashlist',
1429+
runtimeName,
1430+
}: SecondRuntimeRnListAppProps) {
1431+
const normalizedMode: SecondRuntimeRnBenchmarkMode =
1432+
mode === 'legendlist' ? 'legendlist' : 'flashlist';
1433+
return (
1434+
<RnListBenchmarkScreen
1435+
blockStatus={`${blockStatus} / ${runtimeName ?? runtimeKind()}`}
1436+
mode={normalizedMode}
1437+
/>
14441438
);
1439+
}
14451440

14461441
function SharedTreeRuntimeScreen() {
14471442
return (

packages/core/README.md

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ secondary React Native/Hermes runtime.
66
The package owns the JS registry and host API:
77

88
- `threadedComponent(name, Component)`
9+
- `OnRuntime`
910
- `Threaded`
1011
- `ThreadedScreen`
1112
- `withThreadedRuntime(config, options)` from
@@ -98,12 +99,12 @@ component name.
9899

99100
### 3. Mark Components And Render Them
100101

101-
Most consumers should make a component opt in once and then render it through a
102-
secondary runtime like a normal React component.
102+
Most consumers should mount a top-level component inside `OnRuntime`. Metro
103+
treats the direct child component as a threaded boundary.
103104

104105
```tsx
105106
import {
106-
Threaded,
107+
OnRuntime,
107108
ThreadedScreen,
108109
threadedComponent,
109110
} from '@react-native-runtimes/core';
@@ -113,25 +114,23 @@ type MessageListProps = {
113114
initialIndex?: number;
114115
};
115116

116-
export const MessageList = threadedComponent<MessageListProps>(
117-
'MessageList',
118-
function MessageList(props) {
119-
return <ActualMessageList {...props} />;
120-
},
121-
);
117+
function MessageList(props: MessageListProps) {
118+
return <ActualMessageList {...props} />;
119+
}
122120

123-
<Threaded
124-
component={MessageList}
125-
props={{ conversationId, initialIndex: 120 }}
126-
runtimeName="messages-runtime"
127-
/>;
121+
<OnRuntime name="messages-runtime">
122+
<MessageList conversationId={conversationId} initialIndex={120} />
123+
</OnRuntime>;
128124
```
129125

130-
`threadedComponent` is the annotation/marker that says this component may be
131-
mounted by another runtime. `Threaded` serializes `props` and mounts a native
126+
Metro sees `MessageList` as the direct child of `OnRuntime` and rewrites it to
127+
an exported `threadedComponent(...)` registration with a stable file-based id.
128+
`OnRuntime` serializes the child props and mounts a native
132129
`ThreadedRuntimeSurface` with the generated component name. Props must be
133130
JSON-serializable; large or mutable data should be passed by id/key and read
134-
through a shared native store.
131+
through a shared native store. Keep inferred components in module/global scope
132+
so Metro can generate the registration and the other runtime can require them by
133+
name.
135134

136135
For navigation or chat apps where the whole route should live on another JS
137136
runtime, use `ThreadedScreen`:
@@ -157,6 +156,9 @@ preloads the named runtime by default, and keeps the runtime alive when the
157156
screen unmounts. Set `destroyOnUnmount` when the route should release its
158157
secondary runtime immediately.
159158

159+
Use `threadedComponent` and `Threaded` directly when you want a custom component
160+
name or need to bypass the directive transform.
161+
160162
You can also prewarm the runtime before rendering the screen:
161163

162164
```tsx

packages/core/metro.js

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const DEFAULT_IGNORED_DIRS = new Set([
1414
'node_modules',
1515
]);
1616
const IGNORED_FUNCTION_DIRECTIVES = new Set([
17+
'threaded',
1718
'use asm',
1819
'use strict',
1920
'worklet',
@@ -57,7 +58,7 @@ function generateThreadedRuntimeEntry({
5758
}) {
5859
const root = path.resolve(projectRoot);
5960
const files = collectSourceFiles(root, roots);
60-
const components = files.flatMap(file => scanThreadedComponents(file));
61+
const components = files.flatMap(file => scanThreadedComponents(file, root));
6162
const runtimeFunctions = files.flatMap(file =>
6263
scanRuntimeFunctions(file, root),
6364
);
@@ -176,16 +177,45 @@ function walkDirectory(directory, files) {
176177
});
177178
}
178179

179-
function scanThreadedComponents(file) {
180+
function scanThreadedComponents(file, projectRoot) {
180181
const source = fs.readFileSync(file, 'utf8');
181182
const ast = parser.parse(source, {
182183
errorRecovery: true,
183184
plugins: ['jsx', 'typescript'],
184185
sourceType: 'module',
185186
});
186187
const components = [];
188+
const onRuntimeComponentNames = collectOnRuntimeComponentNames(ast);
187189

188190
traverse(ast, {
191+
Program(pathRef) {
192+
pathRef.get('body').forEach(bodyPath => {
193+
let functionPath = bodyPath;
194+
if (bodyPath.isExportNamedDeclaration()) {
195+
const declarationPath = bodyPath.get('declaration');
196+
if (!declarationPath.isFunctionDeclaration()) {
197+
return;
198+
}
199+
functionPath = declarationPath;
200+
}
201+
202+
if (!functionPath.isFunctionDeclaration()) {
203+
return;
204+
}
205+
206+
const functionNode = functionPath.node;
207+
if (!onRuntimeComponentNames.has(functionNode.id.name)) {
208+
return;
209+
}
210+
211+
components.push({
212+
exportName: functionNode.id.name,
213+
file,
214+
name: threadedComponentId(file, projectRoot, functionNode.id.name),
215+
});
216+
});
217+
},
218+
189219
ExportNamedDeclaration(pathRef) {
190220
const declaration = pathRef.node.declaration;
191221
if (!declaration || declaration.type !== 'VariableDeclaration') {
@@ -317,6 +347,48 @@ function isThreadedComponentCall(node) {
317347
return callee.type === 'Identifier' && callee.name === 'threadedComponent';
318348
}
319349

350+
function onRuntimeChildNameFromJsxElement(node) {
351+
if (
352+
node.openingElement.name.type !== 'JSXIdentifier' ||
353+
node.openingElement.name.name !== 'OnRuntime'
354+
) {
355+
return null;
356+
}
357+
358+
const children = node.children.filter(child => {
359+
if (child.type === 'JSXText') {
360+
return child.value.trim().length > 0;
361+
}
362+
if (
363+
child.type === 'JSXExpressionContainer' &&
364+
child.expression.type === 'JSXEmptyExpression'
365+
) {
366+
return false;
367+
}
368+
return true;
369+
});
370+
371+
if (children.length !== 1 || children[0].type !== 'JSXElement') {
372+
return null;
373+
}
374+
375+
const childName = children[0].openingElement.name;
376+
return childName.type === 'JSXIdentifier' ? childName.name : null;
377+
}
378+
379+
function collectOnRuntimeComponentNames(ast) {
380+
const componentNames = new Set();
381+
traverse(ast, {
382+
JSXElement(pathRef) {
383+
const componentName = onRuntimeChildNameFromJsxElement(pathRef.node);
384+
if (componentName) {
385+
componentNames.add(componentName);
386+
}
387+
},
388+
});
389+
return componentNames;
390+
}
391+
320392
function isRuntimeFunctionCall(node) {
321393
if (!node || node.type !== 'CallExpression') {
322394
return false;
@@ -361,6 +433,10 @@ function runtimeFunctionId(file, projectRoot, exportName) {
361433
)}.${exportName}`;
362434
}
363435

436+
function threadedComponentId(file, projectRoot, exportName) {
437+
return runtimeFunctionId(file, projectRoot, exportName);
438+
}
439+
364440
function renderGeneratedEntry({
365441
components,
366442
generatedEntry,

0 commit comments

Comments
 (0)