Skip to content

Commit 1ae196a

Browse files
committed
feat(expo): silent-disable when projectKey or intakeUrl is empty
- normalizeConfig returns null for empty keys/urls instead of throwing - ReproProvider splits into ActiveProvider + DisabledProvider so host apps can opt out via env-var fallback (`projectKey: process.env.X ?? ""`) - useRepro().disabled flag + all methods no-op in the disabled path - ReproLauncher hides when disabled - Singleton still resolves cleanly in dev (no "provider not mounted" warning) - Typos in a non-empty key still throw so mistakes are caught
1 parent 674c638 commit 1ae196a

6 files changed

Lines changed: 97 additions & 21 deletions

File tree

packages/expo/src/config.test.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { test, expect } from "bun:test"
22
import { normalizeConfig } from "./config"
33

4+
const VALID_KEY = "rp_pk_" + "a".repeat(24)
5+
const VALID_URL = "https://example.com/api/intake"
6+
47
test("normalizeConfig fills sensible defaults", () => {
5-
const cfg = normalizeConfig({
6-
projectKey: "rp_pk_" + "a".repeat(24),
7-
intakeUrl: "https://example.com/api/intake",
8-
})
8+
const cfg = normalizeConfig({ projectKey: VALID_KEY, intakeUrl: VALID_URL })
9+
expect(cfg).not.toBeNull()
10+
if (!cfg) return
911
expect(cfg.collectors.console).toBe(true)
1012
expect(cfg.collectors.network.enabled).toBe(true)
1113
expect(cfg.queue.maxReports).toBe(5)
@@ -15,19 +17,27 @@ test("normalizeConfig fills sensible defaults", () => {
1517
})
1618

1719
test("normalizeConfig rejects malformed projectKey at runtime", () => {
18-
expect(() =>
19-
normalizeConfig({ projectKey: "nope", intakeUrl: "https://example.com/api/intake" }),
20-
).toThrow(/projectKey/)
20+
expect(() => normalizeConfig({ projectKey: "nope", intakeUrl: VALID_URL })).toThrow(/projectKey/)
2121
})
2222

2323
test("normalizeConfig respects overrides", () => {
2424
const cfg = normalizeConfig({
25-
projectKey: "rp_pk_" + "a".repeat(24),
26-
intakeUrl: "https://example.com/api/intake",
25+
projectKey: VALID_KEY,
26+
intakeUrl: VALID_URL,
2727
queue: { maxReports: 3 },
2828
collectors: { network: { enabled: false } },
2929
})
30+
expect(cfg).not.toBeNull()
31+
if (!cfg) return
3032
expect(cfg.queue.maxReports).toBe(3)
3133
expect(cfg.queue.maxBytes).toBe(10 * 1024 * 1024)
3234
expect(cfg.collectors.network.enabled).toBe(false)
3335
})
36+
37+
test("normalizeConfig returns null when projectKey is empty (silent disable)", () => {
38+
expect(normalizeConfig({ projectKey: "", intakeUrl: VALID_URL })).toBeNull()
39+
})
40+
41+
test("normalizeConfig returns null when intakeUrl is empty (silent disable)", () => {
42+
expect(normalizeConfig({ projectKey: VALID_KEY, intakeUrl: "" })).toBeNull()
43+
})

packages/expo/src/config.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,23 @@ export interface ReproConfig {
5050
metadata: Record<string, string | number | boolean>
5151
}
5252

53-
export function normalizeConfig(input: ReproConfigInput): ReproConfig {
53+
/**
54+
* Normalize a ReproConfigInput to a fully-defaulted ReproConfig.
55+
*
56+
* Returns `null` when `projectKey` or `intakeUrl` are empty — this is the
57+
* silent-disable path that lets hosts turn the SDK off by simply leaving
58+
* their env vars unset (e.g. `projectKey: process.env.EXPO_PUBLIC_KEY ?? ""`).
59+
*
60+
* Throws only when a non-empty value is malformed (typo protection).
61+
*/
62+
export function normalizeConfig(input: ReproConfigInput): ReproConfig | null {
63+
if (!input.projectKey || !input.intakeUrl) {
64+
return null
65+
}
5466
if (!PROJECT_KEY_PATTERN.test(input.projectKey)) {
5567
throw new Error(`Repro: invalid projectKey shape`)
5668
}
57-
if (!input.intakeUrl || !/^https?:\/\//.test(input.intakeUrl)) {
69+
if (!/^https?:\/\//.test(input.intakeUrl)) {
5870
throw new Error(`Repro: invalid intakeUrl — must be http(s)`)
5971
}
6072
return {

packages/expo/src/context.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import type { Breadcrumb } from "@reprojs/sdk-utils"
44
import type { ReporterIdentity } from "@reprojs/shared"
55

66
export interface ReproInternalContext {
7-
config: ReproConfig
7+
/**
8+
* `null` indicates the silent-disable state — the provider rendered but
9+
* neither `projectKey` nor `intakeUrl` were supplied. All callback methods
10+
* on this context are no-ops; `useRepro().disabled` reflects this flag.
11+
*/
12+
config: ReproConfig | null
813
getReporter: () => ReporterIdentity | null
914
setReporter: (r: ReporterIdentity | null) => void
1015
getMetadata: () => Record<string, string | number | boolean>

packages/expo/src/launcher.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export function ReproLauncher({
108108
const gesture = draggable ? Gesture.Race(pan, tap) : tap
109109

110110
if (hideWhen?.()) return null
111+
if (repro.disabled) return null
111112

112113
const posStyles = {
113114
position: "absolute" as const,

packages/expo/src/provider.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { useEffect, useMemo, useRef, useState } from "react"
22
import { AppState, View } from "react-native"
33
import { ReproContext, type ReproInternalContext } from "./context"
4-
import { normalizeConfig, type ReproConfigInput } from "./config"
4+
import { normalizeConfig, type ReproConfig, type ReproConfigInput } from "./config"
55
import { createConsoleCollector } from "./collectors/console"
66
import { createNetworkCollector } from "./collectors/network"
77
import { createBreadcrumbsCollector } from "@reprojs/sdk-utils"
@@ -20,8 +20,44 @@ interface Props {
2020
children: React.ReactNode
2121
}
2222

23+
/**
24+
* When `projectKey` or `intakeUrl` are empty, the SDK silently disables itself
25+
* and renders children untouched — no collectors, no launcher, no network. Let
26+
* hosts opt out with `projectKey: process.env.X ?? ""` rather than a separate
27+
* `enabled: false` flag.
28+
*/
29+
const DISABLED_CONTEXT: ReproInternalContext = {
30+
config: null,
31+
getReporter: () => null,
32+
setReporter: () => undefined,
33+
getMetadata: () => ({}),
34+
setMetadata: () => undefined,
35+
logBreadcrumb: () => undefined,
36+
openWizard: () => undefined,
37+
closeWizard: () => undefined,
38+
captureRoot: async () => ({ uri: "", width: 0, height: 0 }),
39+
snapshotBreadcrumbs: () => [],
40+
queueStatus: () => ({ pending: 0, lastError: null }),
41+
flushQueue: async () => undefined,
42+
}
43+
44+
function DisabledProvider({ children }: { children: React.ReactNode }) {
45+
useEffect(() => {
46+
setSingletonHandle(DISABLED_CONTEXT)
47+
return () => clearSingletonHandle()
48+
}, [])
49+
return <ReproContext.Provider value={DISABLED_CONTEXT}>{children}</ReproContext.Provider>
50+
}
51+
2352
export function ReproProvider({ config: rawConfig, children }: Props) {
2453
const config = useMemo(() => normalizeConfig(rawConfig), [rawConfig])
54+
if (config === null) {
55+
return <DisabledProvider>{children}</DisabledProvider>
56+
}
57+
return <ActiveProvider config={config}>{children}</ActiveProvider>
58+
}
59+
60+
function ActiveProvider({ config, children }: { config: ReproConfig; children: React.ReactNode }) {
2561
const rootRef = useRef<View | null>(null)
2662
const [reporter, setReporter] = useState<ReporterIdentity | null>(config.reporter)
2763
const [metadata, setMetadata] = useState<Record<string, string | number | boolean>>(

packages/expo/src/use-repro.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import type { ReporterIdentity } from "@reprojs/shared"
55
declare const __DEV__: boolean
66

77
export interface ReproHandle {
8+
/** True when the provider is in silent-disable mode (empty projectKey or
9+
* intakeUrl). Callers that render UI based on Repro availability can gate
10+
* on this — e.g. `<ReproLauncher />` returns null when disabled. */
11+
disabled: boolean
812
open: (opts?: { initialTitle?: string; initialDescription?: string }) => void
913
close: () => void
1014
identify: (reporter: ReporterIdentity | null) => void
@@ -13,23 +17,31 @@ export interface ReproHandle {
1317
queue: { pending: number; lastError: string | null; flush: () => Promise<void> }
1418
}
1519

20+
const NOOP_HANDLE: ReproHandle = {
21+
disabled: true,
22+
open: () => undefined,
23+
close: () => undefined,
24+
identify: () => undefined,
25+
log: () => undefined,
26+
setMetadata: () => undefined,
27+
queue: { pending: 0, lastError: null, flush: async () => undefined },
28+
}
29+
1630
export function useRepro(): ReproHandle {
1731
const ctx = useContext(ReproContext)
1832
if (!ctx) {
1933
if (typeof __DEV__ !== "undefined" && __DEV__) {
2034
throw new Error("Repro: useRepro() must be called inside <ReproProvider>")
2135
}
22-
return {
23-
open: () => undefined,
24-
close: () => undefined,
25-
identify: () => undefined,
26-
log: () => undefined,
27-
setMetadata: () => undefined,
28-
queue: { pending: 0, lastError: null, flush: async () => undefined },
29-
}
36+
return NOOP_HANDLE
37+
}
38+
// Silent-disable: provider rendered but projectKey/intakeUrl were empty.
39+
if (ctx.config === null) {
40+
return NOOP_HANDLE
3041
}
3142
const status = ctx.queueStatus()
3243
return {
44+
disabled: false,
3345
open: ctx.openWizard,
3446
close: ctx.closeWizard,
3547
identify: ctx.setReporter,

0 commit comments

Comments
 (0)