Skip to content

Commit c4d3bc4

Browse files
yosriadyclaude
andauthored
Fix three issues found reviewing the 1.0.1 lifecycle work (#73)
* 1.0.1 * Fix three issues found reviewing the 1.0.1 lifecycle work 1. CrashReporter could sever the handler chain. The installed handler read `this.previousHandler`, which cleanup() cleared. With two reporters — the provider re-initialising, or a customer's own reporter wrapping ours — the sequence A.start, B.start, A.cleanup, crash left B forwarding into A, and A forwarding into `undefined`: the real RN/default handler never ran, and A reported a duplicate despite being stopped. The previous handler is now captured in the closure at install time, cleanup no longer clears it, and a stopped reporter forwards without reporting. 2. buildScreenUrl let the screen name change the URL's structure. screen("Checkout?coupon=X") parsed with pathname "/Checkout" and the rest silently dropped. '?' and '#' are now percent-encoded; '/' deliberately is not, since router-style names are meant to be path segments. Dot segments are knowingly left alone: the URL spec decodes %2E before resolving path segments, so JS collapses "a/../Admin" either way — and it does not matter, because the pipeline parses with ClickHouse's path(), which performs no dot-segment normalisation. Verified against the real parser. 3. Deep Link Opened was gated on attribution.deeplinks rather than its own autocapture flag, because that check also guarded the Linking hook both features share. Turning attribution off silently disabled the event with no indication an unrelated setting was responsible. The hook is now installed if either consumer needs it, and each behaviour checks its own flag. 297 tests (6 new), typecheck, lint and build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Escape '%' before encoding URL delimiters, keeping screen names distinct Review follow-up. Encoding '?' as %3F without first escaping '%' made the transform non-injective: a screen literally named "Checkout%3Fx" and one named "Checkout?x" both produced ".../Checkout%3Fx", silently merging two distinct screens in the analytics. '%' is now encoded first, so "Checkout?x" -> "Checkout%3Fx" and "Checkout%3Fx" -> "Checkout%253Fx". Test asserts a mixed set of names maps to the same number of distinct URLs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 858d3ed commit c4d3bc4

6 files changed

Lines changed: 204 additions & 12 deletions

File tree

src/FormoAnalytics.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,16 @@ export class FormoAnalytics implements IFormoAnalytics {
153153
// is a fast native bridge call; the Android Play Install Referrer is a fast
154154
// one-shot native call (and no-ops instantly when the native module or the
155155
// platform isn't Android), so awaiting it does not meaningfully delay init.
156-
if (analytics.isAttributionEnabled("deeplinks")) {
156+
// Hook Linking if EITHER consumer needs it. The two flags control different
157+
// things — attribution.deeplinks parses UTMs into context, autocapture
158+
// .deepLinks emits Deep Link Opened — but both depend on observing the link
159+
// in the first place. Gating the hook on attribution alone silently
160+
// disabled the event for anyone who turned attribution off, with no
161+
// indication that an unrelated setting was responsible.
162+
if (
163+
analytics.isAttributionEnabled("deeplinks") ||
164+
analytics.isAutocaptureEnabled("deepLinks")
165+
) {
157166
try {
158167
await analytics.startDeepLinkCapture();
159168
} catch (error) {
@@ -220,7 +229,9 @@ export class FormoAnalytics implements IFormoAnalytics {
220229
try {
221230
const url = await Linking.getInitialURL();
222231
if (url) {
223-
this.setTrafficSourceFromUrl(url);
232+
if (this.isAttributionEnabled("deeplinks")) {
233+
this.setTrafficSourceFromUrl(url);
234+
}
224235
// Held, not emitted: this runs before lifecycle tracking starts, and
225236
// the Segment spec orders `Deep Link Opened` after `Application
226237
// Opened`. Emitted by trackInitialDeepLink() once lifecycle has fired.
@@ -233,7 +244,11 @@ export class FormoAnalytics implements IFormoAnalytics {
233244
// Runtime deep links (foreground opens, universal links).
234245
this.linkingSubscription = Linking.addEventListener("url", (event) => {
235246
if (!event?.url) return;
236-
this.setTrafficSourceFromUrl(event.url);
247+
// Each behaviour checks its own flag: the hook may exist because only one
248+
// of them is enabled.
249+
if (this.isAttributionEnabled("deeplinks")) {
250+
this.setTrafficSourceFromUrl(event.url);
251+
}
237252
void this.trackDeepLinkOpened(event.url);
238253
});
239254
}

src/__tests__/crashReporter.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,72 @@ describe("CrashReporter", () => {
158158
expect(analytics.track).not.toHaveBeenCalled();
159159
});
160160
});
161+
162+
describe("CrashReporter chained with another reporter", () => {
163+
// The failure this guards: two reporters exist (e.g. the provider
164+
// re-initialising, or a customer's own reporter wrapping ours), the first is
165+
// cleaned up, then the app crashes. If our installed handler reads
166+
// `this.previousHandler` at crash time — which cleanup() had cleared — the
167+
// chain terminates at us and the real RN/default handler never runs.
168+
type Handler = (error: Error, isFatal?: boolean) => void;
169+
170+
let original: jest.Mock;
171+
let current: Handler | undefined;
172+
let analyticsA: { track: jest.Mock; flush: jest.Mock };
173+
let analyticsB: { track: jest.Mock; flush: jest.Mock };
174+
175+
const g = globalThis as {
176+
ErrorUtils?: {
177+
getGlobalHandler: () => Handler | undefined;
178+
setGlobalHandler: (h: Handler) => void;
179+
};
180+
};
181+
182+
const mkAnalytics = () => ({
183+
track: jest.fn().mockResolvedValue(undefined),
184+
flush: jest.fn().mockResolvedValue(undefined),
185+
});
186+
187+
beforeEach(() => {
188+
original = jest.fn();
189+
current = original;
190+
g.ErrorUtils = {
191+
getGlobalHandler: () => current,
192+
setGlobalHandler: (h: Handler) => {
193+
current = h;
194+
},
195+
};
196+
analyticsA = mkAnalytics();
197+
analyticsB = mkAnalytics();
198+
});
199+
200+
afterEach(() => {
201+
delete g.ErrorUtils;
202+
});
203+
204+
it("still reaches the original handler after the inner reporter is cleaned up", () => {
205+
const a = new CrashReporter(analyticsA);
206+
const b = new CrashReporter(analyticsB);
207+
a.start();
208+
b.start();
209+
a.cleanup(); // b's chain still points at a's installed handler
210+
211+
const error = new Error("boom");
212+
current!(error, true);
213+
214+
expect(original).toHaveBeenCalledWith(error, true);
215+
});
216+
217+
it("does not double-report from the cleaned-up reporter", () => {
218+
const a = new CrashReporter(analyticsA);
219+
const b = new CrashReporter(analyticsB);
220+
a.start();
221+
b.start();
222+
a.cleanup();
223+
224+
current!(new Error("boom"), true);
225+
226+
expect(analyticsB.track).toHaveBeenCalledTimes(1);
227+
expect(analyticsA.track).not.toHaveBeenCalled();
228+
});
229+
});

src/__tests__/deepLinkEvent.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,25 @@ describe("Deep Link Opened", () => {
8787
expect(trackedEvents).not.toContain("Deep Link Opened");
8888
});
8989

90-
it("does not subscribe at all when deep-link attribution is disabled", async () => {
90+
it("still emits the event when only attribution is disabled", async () => {
91+
// The two flags are independent: attribution.deeplinks parses UTMs into
92+
// context, autocapture.deepLinks emits the event. Turning attribution off
93+
// must not silently take the event with it — the Linking hook has to be
94+
// installed if EITHER consumer needs it.
9195
await init({ attribution: { deeplinks: false } });
96+
const handler = addEventListener.mock.calls.at(-1)?.[1];
97+
expect(handler).toBeDefined();
98+
99+
await handler({ url: "myapp://product" });
100+
101+
expect(trackedEvents).toContain("Deep Link Opened");
102+
});
103+
104+
it("does not subscribe when BOTH deep-link flags are disabled", async () => {
105+
await init({
106+
attribution: { deeplinks: false },
107+
autocapture: { deepLinks: false },
108+
});
92109

93110
expect(addEventListener).not.toHaveBeenCalled();
94111
});

src/__tests__/screenEvent.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,67 @@ describe("generateScreenEvent bundle id resolution", () => {
126126
);
127127
});
128128
});
129+
130+
describe("buildScreenUrl structural characters", () => {
131+
// The URL is parsed downstream with standard URL functions, so any character
132+
// that changes URL STRUCTURE silently truncates or merges screen names.
133+
it("keeps a '?' inside the screen name instead of starting a query", () => {
134+
const url = buildScreenUrl("com.acme.wallet", "Checkout?coupon=SUMMER");
135+
expect(url).toBe("app://com.acme.wallet/Checkout%3Fcoupon=SUMMER");
136+
expect(new URL(url).pathname).toBe("/Checkout%3Fcoupon=SUMMER");
137+
expect(new URL(url).search).toBe("");
138+
});
139+
140+
it("keeps a '#' inside the screen name instead of starting a fragment", () => {
141+
const url = buildScreenUrl("com.acme.wallet", "Order#123");
142+
expect(new URL(url).hash).toBe("");
143+
expect(new URL(url).pathname).toBe("/Order%23123");
144+
});
145+
146+
it("passes dot segments through verbatim", () => {
147+
// Deliberately NOT encoded: the URL spec decodes %2E before resolving path
148+
// segments, so a JS `new URL()` collapses "a/../Admin" to "/Admin" either
149+
// way. The ingestion pipeline uses ClickHouse's path(), which does no
150+
// dot-segment normalisation, so the raw string is what matters here.
151+
expect(buildScreenUrl("com.acme.wallet", "a/../Admin")).toBe(
152+
"app://com.acme.wallet/a/../Admin",
153+
);
154+
});
155+
156+
it("still treats '/' as a real path separator for router-style names", () => {
157+
const url = buildScreenUrl("com.acme.wallet", "/tabs/leaderboard");
158+
expect(url).toBe("app://com.acme.wallet/tabs/leaderboard");
159+
expect(new URL(url).pathname).toBe("/tabs/leaderboard");
160+
});
161+
162+
it("leaves ordinary names untouched", () => {
163+
expect(buildScreenUrl("com.acme.wallet", "Home")).toBe(
164+
"app://com.acme.wallet/Home",
165+
);
166+
});
167+
});
168+
169+
describe("buildScreenUrl encoding is injective", () => {
170+
// Encoding '?' as %3F without first escaping '%' would make a screen named
171+
// "Checkout%3Fx" indistinguishable from "Checkout?x", silently merging two
172+
// distinct screens in the analytics.
173+
it("does not collide a literal %3F with an encoded ?", () => {
174+
const fromQuestionMark = buildScreenUrl("com.acme.wallet", "Checkout?x");
175+
const fromLiteral = buildScreenUrl("com.acme.wallet", "Checkout%3Fx");
176+
expect(fromQuestionMark).not.toBe(fromLiteral);
177+
expect(fromQuestionMark).toBe("app://com.acme.wallet/Checkout%3Fx");
178+
expect(fromLiteral).toBe("app://com.acme.wallet/Checkout%253Fx");
179+
});
180+
181+
it("does not collide a literal %23 with an encoded #", () => {
182+
expect(buildScreenUrl("com.acme.wallet", "Order#1")).not.toBe(
183+
buildScreenUrl("com.acme.wallet", "Order%231"),
184+
);
185+
});
186+
187+
it("keeps distinct names distinct across a mixed set", () => {
188+
const names = ["Home", "Home?x", "Home%3Fx", "Home#y", "Home%23y", "a/b", "a%2Fb"];
189+
const urls = names.map((n) => buildScreenUrl("com.acme.wallet", n));
190+
expect(new Set(urls).size).toBe(names.length);
191+
});
192+
});

src/lib/crash/index.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,18 +64,31 @@ export class CrashReporter {
6464
return;
6565
}
6666

67-
this.previousHandler = errorUtils.getGlobalHandler();
67+
// Captured in the closure, NOT read from `this` at crash time. If another
68+
// reporter wraps us afterwards our handler stays reachable through its
69+
// chain, and cleanup() clearing the field would otherwise sever the chain
70+
// at the moment it matters: A.start, B.start, A.cleanup, crash — B calls
71+
// our handler, which would then forward to `undefined` and the real
72+
// RN/default handler would never run.
73+
const previousHandler = errorUtils.getGlobalHandler();
74+
this.previousHandler = previousHandler;
6875

6976
const handler: ErrorHandler = (error, isFatal) => {
7077
// Nothing in here may throw: this runs while the app is already failing,
7178
// and an exception would replace the real crash with ours.
72-
try {
73-
this.report(error, isFatal);
74-
} catch (reportingError) {
75-
logger.debug("CrashReporter: failed to report crash", reportingError);
79+
//
80+
// `started` is checked so a cleaned-up reporter still FORWARDS (keeping
81+
// the chain intact) but no longer reports — otherwise the sequence above
82+
// would emit a duplicate Application Crashed from the stopped instance.
83+
if (this.started) {
84+
try {
85+
this.report(error, isFatal);
86+
} catch (reportingError) {
87+
logger.debug("CrashReporter: failed to report crash", reportingError);
88+
}
7689
}
7790

78-
this.previousHandler?.(error, isFatal);
91+
previousHandler?.(error, isFatal);
7992
};
8093

8194
this.installedHandler = handler;
@@ -123,9 +136,10 @@ export class CrashReporter {
123136
errorUtils.setGlobalHandler(this.previousHandler);
124137
}
125138

139+
// previousHandler is deliberately NOT cleared: if another reporter wrapped
140+
// us, our installed handler is still in its chain and must keep forwarding.
126141
this.started = false;
127142
this.installedHandler = undefined;
128-
this.previousHandler = undefined;
129143
logger.info("CrashReporter: Cleaned up");
130144
}
131145
}

src/lib/event/EventFactory.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,20 @@ interface DeviceInfoResult {
156156
* ("/tabs/leaderboard") does not produce a doubled separator.
157157
*/
158158
export function buildScreenUrl(bundleId: string, name: string): string {
159-
const screen = (name ?? "").replace(/^\/+/, "");
159+
const screen = (name ?? "")
160+
.replace(/^\/+/, "")
161+
// Percent-encode the characters that would otherwise change the URL's
162+
// STRUCTURE rather than its path. '?' starts a query and '#' a fragment, so
163+
// screen("Checkout?coupon=X") would parse with pathname "/Checkout" and the
164+
// rest silently dropped from the screen name. '/' is deliberately NOT
165+
// encoded — router-style names like "/tabs/leaderboard" are meant to be
166+
// path segments.
167+
// '%' FIRST, so the transform stays injective. Without it a screen literally
168+
// named "Checkout%3Fx" and one named "Checkout?x" both produce
169+
// ".../Checkout%3Fx" and two distinct screens merge in the analytics.
170+
.replace(/%/g, "%25")
171+
.replace(/\?/g, "%3F")
172+
.replace(/#/g, "%23");
160173
return `app://${bundleId ?? ""}/${screen}`;
161174
}
162175

0 commit comments

Comments
 (0)