Skip to content

Commit 5f9bae7

Browse files
fix(runtime,jest,bridge): improve console forwarding types and add utilities
- Add ConsoleEvent/ConsoleLevel types to BridgeEvents union in bridge package - Remove type assertions in factory.ts now that proper types exist - Add proper generic constraints for Harness.on/off methods - Extract shared batchedUpdate utility to avoid code duplication - Add resetRenderState helper for common state reset pattern - Add rate limiting for console forwarding (100 msgs/sec) - Preserve Error details (stack/message) in forwarded console output - Fix %d placeholder to use Number() instead of parseInt for decimals
1 parent 9bb8bec commit 5f9bae7

10 files changed

Lines changed: 138 additions & 66 deletions

File tree

packages/bridge/src/shared.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
} from './shared/test-runner.js';
55
import type { TestCollectorEvents } from './shared/test-collector.js';
66
import type { BundlerEvents } from './shared/bundler.js';
7+
import type { ConsoleEvent } from './shared/console.js';
78
import type { HarnessPlatform } from '@react-native-harness/platforms';
89

910
export type FileReference = {
@@ -103,6 +104,7 @@ export type {
103104
SetupFileBundlingFailedEvent,
104105
BundlerEvents,
105106
} from './shared/bundler.js';
107+
export type { ConsoleEvent, ConsoleLevel } from './shared/console.js';
106108

107109
export type DeviceDescriptor = {
108110
platform: 'ios' | 'android' | 'vega';
@@ -114,7 +116,8 @@ export type DeviceDescriptor = {
114116
export type BridgeEvents =
115117
| TestCollectorEvents
116118
| TestRunnerEvents
117-
| BundlerEvents;
119+
| BundlerEvents
120+
| ConsoleEvent;
118121

119122
export type BridgeEventsMap = {
120123
[K in BridgeEvents['type']]: (
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export type ConsoleLevel = 'log' | 'warn' | 'error' | 'info' | 'debug';
2+
3+
export type ConsoleEvent = {
4+
type: 'console';
5+
level: ConsoleLevel;
6+
args: string[];
7+
timestamp: number;
8+
};

packages/jest/src/harness.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
getBridgeServer,
33
BridgeServer,
4+
BridgeServerEvents,
45
} from '@react-native-harness/bridge/server';
56
import {
67
HarnessContext,
@@ -31,9 +32,14 @@ export type Harness = {
3132
restart: () => Promise<void>;
3233
dispose: () => Promise<void>;
3334
crashMonitor: CrashMonitor;
34-
// Expose bridge events for console forwarding
35-
on: (event: string, handler: (...args: unknown[]) => void) => void;
36-
off: (event: string, handler: (...args: unknown[]) => void) => void;
35+
on: <T extends keyof BridgeServerEvents>(
36+
event: T,
37+
handler: BridgeServerEvents[T]
38+
) => void;
39+
off: <T extends keyof BridgeServerEvents>(
40+
event: T,
41+
handler: BridgeServerEvents[T]
42+
) => void;
3743
};
3844

3945
export const waitForAppReady = async (options: {
@@ -208,11 +214,8 @@ const getHarnessInternal = async (
208214
restart,
209215
dispose,
210216
crashMonitor,
211-
// Expose bridge events for console forwarding
212-
on: (event: string, handler: (...args: unknown[]) => void) =>
213-
serverBridge.on(event as 'event', handler),
214-
off: (event: string, handler: (...args: unknown[]) => void) =>
215-
serverBridge.off(event as 'event', handler),
217+
on: (event, handler) => serverBridge.on(event, handler),
218+
off: (event, handler) => serverBridge.off(event, handler),
216219
};
217220
};
218221

packages/jest/src/index.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,7 @@ import { HarnessError } from '@react-native-harness/tools';
1919
import { getErrorMessage } from './logs.js';
2020
import { DeviceNotRespondingError } from '@react-native-harness/bridge/server';
2121
import { NativeCrashError } from './errors.js';
22-
23-
type ConsoleEvent = {
24-
type: 'console';
25-
level: 'log' | 'warn' | 'error' | 'info' | 'debug';
26-
args: string[];
27-
timestamp: number;
28-
};
22+
import { ConsoleEvent } from '@react-native-harness/bridge';
2923

3024
// Printf-style string interpolation for console messages
3125
const formatConsoleMessage = (args: string[]): string => {
@@ -43,6 +37,7 @@ const formatConsoleMessage = (args: string[]): string => {
4337
case '%s':
4438
return String(arg);
4539
case '%d':
40+
return String(Number(arg));
4641
case '%i':
4742
return String(parseInt(String(arg), 10));
4843
case '%o':

packages/runtime/src/client/factory.ts

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,26 @@ import type {
33
TestCollectorEvents,
44
BundlerEvents,
55
TestExecutionOptions,
6+
ConsoleLevel,
7+
ConsoleEvent,
68
} from '@react-native-harness/bridge';
79
import { getBridgeClient } from '@react-native-harness/bridge/client';
810
import { store } from '../ui/state.js';
911
import { getTestRunner, TestRunner } from '../runner/index.js';
1012
import { getTestCollector, TestCollector } from '../collector/index.js';
1113
import { combineEventEmitters, EventEmitter } from '../utils/emitter.js';
14+
import { createRateLimiter } from '../utils/rateLimiter.js';
1215
import { getWSServer } from './getWSServer.js';
1316
import { getBundler, evaluateModule, Bundler } from '../bundler/index.js';
1417
import { markTestsAsSkippedByName } from '../filtering/index.js';
1518
import { setup } from '../render/setup.js';
1619
import { runSetupFiles } from './setup-files.js';
1720
import { setClient } from './store.js';
1821

19-
type ConsoleLevel = 'log' | 'warn' | 'error' | 'info' | 'debug';
20-
type EmitEventFn = (
21-
type: string,
22-
data: {
23-
type: string;
24-
level: ConsoleLevel;
25-
args: string[];
26-
timestamp: number;
27-
}
28-
) => void;
22+
type EmitEventFn = (type: ConsoleEvent['type'], data: ConsoleEvent) => void;
23+
24+
// Rate limit: 100 messages per second
25+
const CONSOLE_RATE_LIMIT = { maxCalls: 100, windowMs: 1000 };
2926

3027
// Console forwarding setup - intercepts console calls and emits events to host
3128
const setupConsoleForwarding = (emitEvent: EmitEventFn): (() => void) => {
@@ -37,19 +34,44 @@ const setupConsoleForwarding = (emitEvent: EmitEventFn): (() => void) => {
3734
debug: console.debug,
3835
};
3936

37+
const rateLimiter = createRateLimiter(CONSOLE_RATE_LIMIT);
38+
let droppedCount = 0;
39+
let lastDropWarningTime = 0;
40+
4041
const createForwarder =
4142
(level: ConsoleLevel) =>
4243
(...args: unknown[]) => {
4344
// Call original console method
4445
originalConsole[level](...args);
46+
47+
// Check rate limit before forwarding
48+
if (!rateLimiter.tryAcquire()) {
49+
droppedCount++;
50+
// Log a warning at most once per second about dropped messages
51+
const now = Date.now();
52+
if (now - lastDropWarningTime > 1000) {
53+
lastDropWarningTime = now;
54+
originalConsole.warn(
55+
`[Harness] Console rate limit exceeded, ${droppedCount} messages dropped`
56+
);
57+
}
58+
return;
59+
}
60+
4561
// Forward to host via bridge
4662
try {
4763
emitEvent('console', {
4864
type: 'console',
4965
level,
5066
args: args.map((arg) => {
5167
try {
52-
return typeof arg === 'object' ? JSON.stringify(arg) : String(arg);
68+
if (arg instanceof Error) {
69+
return arg.stack ?? `${arg.name}: ${arg.message}`;
70+
}
71+
if (typeof arg === 'object' && arg !== null) {
72+
return JSON.stringify(arg);
73+
}
74+
return String(arg);
5375
} catch {
5476
return String(arg);
5577
}
@@ -70,6 +92,7 @@ const setupConsoleForwarding = (emitEvent: EmitEventFn): (() => void) => {
7092
// Return cleanup function to restore original console
7193
return () => {
7294
Object.assign(console, originalConsole);
95+
rateLimiter.reset();
7396
};
7497
};
7598

@@ -116,11 +139,7 @@ export const getClient = async () => {
116139

117140
// Setup console forwarding to emit console events to host
118141
cleanupConsole = setupConsoleForwarding((type, data) => {
119-
// Use type assertion since 'console' is a new event type not in the current type definitions
120-
(client.rpc.emitEvent as (type: string, data: unknown) => void)(
121-
type,
122-
data
123-
);
142+
client.rpc.emitEvent(type, data);
124143
});
125144

126145
await runSetupFiles({
Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,5 @@
1-
import { unstable_batchedUpdates } from 'react-native';
2-
import { store } from '../ui/state.js';
1+
import { resetRenderState } from './utils.js';
32

43
export const cleanup = (): void => {
5-
const doCleanup = () => {
6-
store.getState().setRenderedElement(null);
7-
store.getState().setOnLayoutCallback(null);
8-
store.getState().setOnRenderCallback(null);
9-
};
10-
11-
if (unstable_batchedUpdates) {
12-
unstable_batchedUpdates(doCleanup);
13-
} else {
14-
doCleanup();
15-
}
4+
resetRenderState();
165
};

packages/runtime/src/render/index.ts

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react';
2-
import { unstable_batchedUpdates } from 'react-native';
32
import { store } from '../ui/state.js';
3+
import { batchedUpdate } from '../utils/batchedUpdate.js';
4+
import { resetRenderState } from './utils.js';
45
import type { RenderResult, RenderOptions } from './types.js';
56

67
const wrapElement = (
@@ -13,15 +14,6 @@ const wrapElement = (
1314
return React.createElement(wrapper, { children: element });
1415
};
1516

16-
// Helper to batch state updates to avoid act() warnings
17-
const batchedUpdate = (fn: () => void): void => {
18-
if (unstable_batchedUpdates) {
19-
unstable_batchedUpdates(fn);
20-
} else {
21-
fn();
22-
}
23-
};
24-
2517
export const render = async (
2618
element: React.ReactElement,
2719
options: RenderOptions = {}
@@ -30,11 +22,7 @@ export const render = async (
3022

3123
// If an element is already rendered, unmount it first
3224
if (store.getState().renderedElement !== null) {
33-
batchedUpdate(() => {
34-
store.getState().setRenderedElement(null);
35-
store.getState().setOnLayoutCallback(null);
36-
store.getState().setOnRenderCallback(null);
37-
});
25+
resetRenderState();
3826
}
3927

4028
// Create a promise that resolves when the element is rendered.
@@ -102,12 +90,7 @@ export const render = async (
10290
if (store.getState().renderedElement === null) {
10391
return;
10492
}
105-
106-
batchedUpdate(() => {
107-
store.getState().setRenderedElement(null);
108-
store.getState().setOnLayoutCallback(null);
109-
store.getState().setOnRenderCallback(null);
110-
});
93+
resetRenderState();
11194
};
11295

11396
return {
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { store } from '../ui/state.js';
2+
import { batchedUpdate } from '../utils/batchedUpdate.js';
3+
4+
/**
5+
* Resets render-related state in a batched update.
6+
* Clears the rendered element and associated callbacks.
7+
*/
8+
export const resetRenderState = (): void => {
9+
batchedUpdate(() => {
10+
store.getState().setRenderedElement(null);
11+
store.getState().setOnLayoutCallback(null);
12+
store.getState().setOnRenderCallback(null);
13+
});
14+
};
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { unstable_batchedUpdates } from 'react-native';
2+
3+
/**
4+
* Batches state updates to avoid act() warnings in React Native.
5+
* Falls back to direct execution if unstable_batchedUpdates is unavailable.
6+
*/
7+
export const batchedUpdate = (fn: () => void): void => {
8+
if (unstable_batchedUpdates) {
9+
unstable_batchedUpdates(fn);
10+
} else {
11+
fn();
12+
}
13+
};
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
export type RateLimiterOptions = {
2+
/** Maximum number of calls allowed per window */
3+
maxCalls: number;
4+
/** Time window in milliseconds */
5+
windowMs: number;
6+
};
7+
8+
export type RateLimiter = {
9+
/** Returns true if the call is allowed, false if rate limited */
10+
tryAcquire: () => boolean;
11+
/** Reset the rate limiter state */
12+
reset: () => void;
13+
};
14+
15+
/**
16+
* Creates a simple sliding window rate limiter.
17+
* Tracks call timestamps and allows calls only if within the limit.
18+
*/
19+
export const createRateLimiter = (options: RateLimiterOptions): RateLimiter => {
20+
const { maxCalls, windowMs } = options;
21+
const timestamps: number[] = [];
22+
23+
const tryAcquire = (): boolean => {
24+
const now = Date.now();
25+
const windowStart = now - windowMs;
26+
27+
// Remove timestamps outside the window
28+
while (timestamps.length > 0 && timestamps[0] <= windowStart) {
29+
timestamps.shift();
30+
}
31+
32+
if (timestamps.length >= maxCalls) {
33+
return false;
34+
}
35+
36+
timestamps.push(now);
37+
return true;
38+
};
39+
40+
const reset = (): void => {
41+
timestamps.length = 0;
42+
};
43+
44+
return { tryAcquire, reset };
45+
};

0 commit comments

Comments
 (0)