Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions examples/bare/e2e/api-arguments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,11 @@ describeDevice(`overloads and arguments on ${TARGET_NAME}`, () => {

/**
* The implementation plan (docs/design/plans/2026-09-16-implementation-plan.md,
* Phase 1 Red) says a blank token rejects with `E_TOKEN`. `assertUsableToken`
* (packages/react-native/src/index.ts) throws a plain Error with no `code`.
* Wrapper divergence, to fix (sdk-issues-filed.md "to fix").
* Phase 1 Red) says a blank token rejects with `E_TOKEN`, the code the
* native modules use. `assertUsableToken` (packages/react-native/src/index.ts)
* runs first and carries the same code and message (W2).
*/
it.failing('[API-01c] the rejection carries code E_TOKEN', () => {
it('[API-01c] the rejection carries code E_TOKEN', () => {
for (const key of ['blank', 'spaces', 'number']) {
expect(jsonAfter<Settled>(rejected.text, key).error?.code).toBe('E_TOKEN');
}
Expand Down
13 changes: 5 additions & 8 deletions examples/bare/e2e/console-levels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,12 @@ describeDevice(`console levels and the native-only log stream on ${TARGET_NAME}`
});

/**
* iOS (XS and simulator, beta5, Debug, 2026-10-07): the line lands three
* times -- the bridge's Custom (98) line at Warning (2), and twice more as
* source 2 at level 4, RN's own stderr/os_log output of the RCTLog, which
* the SDK captures -- and the log filter runs three times (counts.rct 3).
* BGSRNConsoleCapture.mm forwards a native RCTLog line without arming the
* echo drop it arms for console.* lines. A wrapper bug (G16), to fix:
* sdk-issues-filed.md W1. Android: once.
* iOS used to record this line three times (the bridge's Custom line plus
* two stderr echoes: RN's own delivery and LogBox's JS relog of the
* warning) and filter it three times (W1). BGSRNConsoleCapture.mm now arms
* the echo drop for a native line and its relog. Android: logcat, once.
*/
(ON_IOS ? it.failing : it)('[FLOW-13] a native RCTLog warning with no console call lands once and is filtered once', () => {
it('[FLOW-13] a native RCTLog warning with no console call lands once and is filtered once', () => {
expect(linesOf('rct')).toHaveLength(1);
expect(counts.rct).toBe(1);
});
Expand Down
6 changes: 6 additions & 0 deletions packages/react-native/ios/BGSRNConsoleCapture.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ NS_ASSUME_NONNULL_BEGIN
* that echo. Keeping the channel line ends the equal-text claim. A later
* Custom line of the same text is kept, including `Bugsee.log` and a native
* `RCTLog` forwarded as Custom.
*
* A native line arms the same echo drop before the rest of the log function
* chain runs, so an app that mirrors RCTLog to stderr does not record it a
* second time. In a Debug build RN re-logs a native warning from JS (LogBox);
* that JavaScript delivery arms one more drop, for its own echo. The line is
* recorded and filtered once.
*/
void BGSRNInstallConsoleCapture(void);

Expand Down
146 changes: 119 additions & 27 deletions packages/react-native/ios/BGSRNConsoleCapture.mm
Original file line number Diff line number Diff line change
Expand Up @@ -43,29 +43,6 @@ static BOOL BGSRNConsoleCaptureEnabled(void) {
return [value boolValue];
}

static void BGSRNOnRCTLog(RCTLogLevel level, RCTLogSource source, NSString *message) {
// JavaScript source is the console echo. The JS patch forwards that call
// in dev and in release; recording it here would run the filter twice.
// Release builds often never produce this source for console.*, and
// dropping the patch instead of this echo would drop those logs.
if (source == RCTLogSourceJavaScript) {
return;
}
if (message.length == 0) {
return;
}
NSMutableDictionary *locals = NSThread.currentThread.threadDictionary;
if (locals[BGSRNConsoleCaptureKey] != nil) {
return;
}
locals[BGSRNConsoleCaptureKey] = @YES;
if (BGSRNConsoleCaptureEnabled()) {
[BGSRNWrapperChannelHolder.shared logMessage:message
level:BGSRNWireLevelForRCTLogLevel(level)];
}
[locals removeObjectForKey:BGSRNConsoleCaptureKey];
}

static const NSTimeInterval BGSRNEchoWindowSeconds = 2.0;
static const NSUInteger BGSRNEchoNoteCap = 32;

Expand Down Expand Up @@ -238,15 +215,130 @@ BOOL BGSRNDropConsoleEcho(NSString *line, NSInteger source) {
}
}

/// Native warnings React Native re-logs from JS. In a Debug build
/// `_RCTLogNativeInternal` also calls `RCTLog.logIfNoNativeHook`, and
/// LogBox's warning handler passes a `warn` to the `console.warn` it saved
/// before any patch. That reaches RCTLog a second time, as
/// `RCTLogSourceJavaScript`, with the same text. This hook does not record
/// it, but whatever the log function chain writes for it (an app's stderr
/// mirror, an NSLog) is a second echo of the line already recorded. Each
/// entry arms one more echo drop when that delivery arrives. Under the echo
/// lock.
static NSMutableArray<BGSRNEchoNote *> *BGSRNRelogNotes;

static void BGSRNExpectJsRelog(NSString *message) {
@synchronized(BGSRNEchoLock()) {
const NSTimeInterval now = [NSDate date].timeIntervalSince1970;
if (BGSRNRelogNotes == nil) {
BGSRNRelogNotes = [NSMutableArray array];
}
BGSRNEchoNote *note = [BGSRNEchoNote new];
note.text = message;
note.expires = now + BGSRNEchoWindowSeconds;
[BGSRNRelogNotes addObject:note];
while (BGSRNRelogNotes.count > BGSRNEchoNoteCap) {
[BGSRNRelogNotes removeObjectAtIndex:0];
}
}
}

/// YES once per expected relog of `message`, within the echo window.
static BOOL BGSRNClaimJsRelog(NSString *message) {
@synchronized(BGSRNEchoLock()) {
const NSTimeInterval now = [NSDate date].timeIntervalSince1970;
for (NSInteger index = (NSInteger)BGSRNRelogNotes.count - 1; index >= 0; index--) {
if (BGSRNRelogNotes[(NSUInteger)index].expires <= now) {
[BGSRNRelogNotes removeObjectAtIndex:(NSUInteger)index];
}
}
for (NSUInteger index = 0; index < BGSRNRelogNotes.count; index++) {
if ([BGSRNRelogNotes[index].text isEqualToString:message]) {
[BGSRNRelogNotes removeObjectAtIndex:index];
return YES;
}
}
return NO;
}
}

/**
* Runs before the rest of the log function chain, so every echo drop is
* armed before anything in that chain can write the line where the SDK
* captures it. Returns YES when this line is to be recorded on the channel
* after the chain has run.
*
* JavaScript source is the console echo. The JS patch forwards that call in
* dev and in release; recording it here would run the filter twice. Release
* builds often never produce this source for console.*, and dropping the
* patch instead of this echo would drop those logs. A JavaScript delivery
* that is the relog of a native warning arms one more echo drop.
*
* A native line is recorded here, once, and its echo drop is armed the way
* the JS patch arms one for a console call: a stderr stamp and one raw
* stdout or stderr line of that text.
*/
static BOOL BGSRNBeginRCTLog(RCTLogLevel level, RCTLogSource source, NSString *message) {
if (message.length == 0) {
return NO;
}
if (source == RCTLogSourceJavaScript) {
if (BGSRNClaimJsRelog(message)) {
BGSRNNoteConsoleEcho(message);
}
return NO;
}
if (NSThread.currentThread.threadDictionary[BGSRNConsoleCaptureKey] != nil) {
return NO;
}
if (!BGSRNConsoleCaptureEnabled()) {
return NO;
}
BGSRNNoteConsoleEcho(message);
#if RCT_DEBUG
if (level == RCTLogLevelWarning) {
BGSRNExpectJsRelog(message);
}
#endif
return YES;
}

/// Records a native line on the channel, as Custom. The filter runs inside
/// this call on this thread: that is the channel line, which is kept.
static void BGSRNForwardRCTLog(RCTLogLevel level, NSString *message) {
NSMutableDictionary *locals = NSThread.currentThread.threadDictionary;
locals[BGSRNConsoleCaptureKey] = @YES;
BGSRNBeginChannelLine(message);
@try {
[BGSRNWrapperChannelHolder.shared logMessage:message
level:BGSRNWireLevelForRCTLogLevel(level)];
} @finally {
BGSRNEndChannelLine(message);
[locals removeObjectForKey:BGSRNConsoleCaptureKey];
}
}

/**
* Wraps the current log function rather than appending to it
* (`RCTAddLogFunction` runs the existing function first): the echo drop of a
* line must be armed before the existing function writes that line to
* stderr, or the SDK could capture and filter the echo first.
*/
void BGSRNInstallConsoleCapture(void) {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
RCTAddLogFunction(^(RCTLogLevel level,
RCTLogFunction existing = RCTGetLogFunction();
RCTSetLogFunction(^(RCTLogLevel level,
RCTLogSource source,
__unused NSString *fileName,
__unused NSNumber *lineNumber,
NSString *fileName,
NSNumber *lineNumber,
NSString *message) {
BGSRNOnRCTLog(level, source, message);
const BOOL forward = BGSRNBeginRCTLog(level, source, message);
if (existing != nil) {
existing(level, source, fileName, lineNumber, message);
}
if (forward) {
BGSRNForwardRCTLog(level, message);
}
});
});
}
16 changes: 16 additions & 0 deletions packages/react-native/src/__tests__/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,22 @@ describe('launch', () => {
expect(native.launch).not.toHaveBeenCalled();
});

// The native modules reject a blank token with E_TOKEN (implementation plan,
// Task 1.3). The JS check runs first on both platforms, so it must carry
// the same code and message, or a caller matching on `.code` sees nothing.
it.each(['', ' ', undefined, null, 42])(
'rejects the unusable token %p with code E_TOKEN and the native message',
async (token) => {
const rejection = Bugsee.launch(token as unknown as string);
await expect(rejection).rejects.toBeInstanceOf(Error);
await expect(rejection).rejects.toMatchObject({
code: 'E_TOKEN',
message: 'Bugsee.launch requires a non-empty app token',
});
expect(native.launch).not.toHaveBeenCalled();
},
);

it('surfaces a native rejection to the caller', async () => {
native.launch.mockRejectedValue(new Error('boom'));
await expect(Bugsee.launch('tok')).rejects.toThrow('boom');
Expand Down
9 changes: 8 additions & 1 deletion packages/react-native/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -861,9 +861,16 @@ function optionalReportText(
return value;
}

/**
* Rejects a blank or non-string token before the bridge, with the code and
* message the native modules use for the same refusal (`E_TOKEN`), so a
* caller matching on `.code` sees one answer whichever side raised it.
*/
function assertUsableToken(token: string): void {
if (typeof token !== 'string' || token.trim().length === 0) {
throw new Error('Bugsee.launch requires a non-empty app token');
throw Object.assign(new Error('Bugsee.launch requires a non-empty app token'), {
code: 'E_TOKEN',
});
}
}

Expand Down
Loading