Skip to content

Commit 26456f3

Browse files
msilivonik-scclaude
andcommitted
feat: auto-recover from LensAbortError on lens change
useApplyLens silently short-circuited when sdkStatus was "error", leaving consumers wedged on the error view even when handed a new, playable lensId. useApplyLens now reinitializes the SDK when the lens intent (lensId + groupId + launch-data hash) changes while in a LensAbortError, then the existing apply effect applies the new lens once the SDK is ready. Gated to lens-abort only (bootstrap failures are unrelated to the requested lens) via the existing sdkError; emits an auto_reinit_on_lens_change metric. Always-on, no loop on a re-aborting lens. No public API changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c315e96 commit 26456f3

2 files changed

Lines changed: 146 additions & 2 deletions

File tree

src/useApplyLens.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@ import { renderHook, waitFor } from "@testing-library/react";
22
import hash from "stable-hash";
33
import { useApplyLens } from "./useApplyLens";
44
import { useInternalCameraKit } from "./CameraKitProvider";
5+
import { metricsReporter } from "./internal/metrics";
56

67
jest.mock("stable-hash");
78
jest.mock("@snap/camera-kit", () => ({}));
89
jest.mock("./CameraKitProvider");
10+
jest.mock("./internal/metrics", () => ({
11+
metricsReporter: { reportCount: jest.fn() },
12+
}));
913

1014
const mockUseInternalCameraKit = useInternalCameraKit as jest.MockedFunction<typeof useInternalCameraKit>;
1115
const mockHash = hash as jest.MockedFunction<typeof hash>;
16+
const mockReportCount = metricsReporter.reportCount as jest.Mock;
1217

1318
describe("useApplyLens", () => {
1419
let mockApplyLens: jest.Mock;
@@ -17,6 +22,7 @@ describe("useApplyLens", () => {
1722
let mockLogger: any;
1823
let mockCameraKit: any;
1924
let mockSession: any;
25+
let mockReinitialize: jest.Mock;
2026

2127
beforeEach(() => {
2228
jest.clearAllMocks();
@@ -31,16 +37,19 @@ describe("useApplyLens", () => {
3137
mockApplyLens = jest.fn().mockResolvedValue(true);
3238
mockRemoveLens = jest.fn().mockResolvedValue(true);
3339
mockGetLogger = jest.fn().mockReturnValue(mockLogger);
40+
mockReinitialize = jest.fn();
3441

3542
mockCameraKit = { id: "mock-kit" };
3643
mockSession = { id: "mock-session" };
3744

3845
mockUseInternalCameraKit.mockReturnValue({
3946
cameraKit: mockCameraKit,
4047
sdkStatus: "ready",
48+
sdkError: undefined,
4149
currentSession: mockSession,
4250
applyLens: mockApplyLens,
4351
removeLens: mockRemoveLens,
52+
reinitialize: mockReinitialize,
4453
getLogger: mockGetLogger,
4554
} as any);
4655

@@ -401,4 +410,115 @@ describe("useApplyLens", () => {
401410
});
402411
});
403412
});
413+
414+
describe("Auto-recovery on LensAbortError", () => {
415+
const abortError = Object.assign(new Error("aborted"), { name: "LensAbortError" });
416+
417+
const errorContext = (sdkError: Error) =>
418+
({
419+
cameraKit: null,
420+
sdkStatus: "error",
421+
sdkError,
422+
currentSession: null,
423+
applyLens: mockApplyLens,
424+
removeLens: mockRemoveLens,
425+
reinitialize: mockReinitialize,
426+
getLogger: mockGetLogger,
427+
}) as any;
428+
429+
it("reinitializes when lensId changes while SDK is in a LensAbortError", () => {
430+
mockUseInternalCameraKit.mockReturnValue(errorContext(abortError));
431+
432+
const { rerender } = renderHook(({ lensId }) => useApplyLens(lensId, "group-1"), {
433+
initialProps: { lensId: "lens-1" },
434+
});
435+
436+
// Must not fire on mount.
437+
expect(mockReinitialize).not.toHaveBeenCalled();
438+
439+
rerender({ lensId: "lens-2" });
440+
441+
expect(mockReinitialize).toHaveBeenCalledTimes(1);
442+
// Recovery only un-wedges the SDK; it does not apply the lens itself.
443+
expect(mockApplyLens).not.toHaveBeenCalled();
444+
});
445+
446+
it("reinitializes when launch data changes while in a LensAbortError", () => {
447+
mockHash.mockImplementation((obj) => JSON.stringify(obj));
448+
mockUseInternalCameraKit.mockReturnValue(errorContext(abortError));
449+
450+
const { rerender } = renderHook(({ launchData }) => useApplyLens("lens-1", "group-1", launchData), {
451+
initialProps: { launchData: { launchParams: { hint: "face" } } },
452+
});
453+
454+
rerender({ launchData: { launchParams: { hint: "hand" } } });
455+
456+
expect(mockReinitialize).toHaveBeenCalledTimes(1);
457+
});
458+
459+
it("does NOT reinitialize for a bootstrap-failure error", () => {
460+
const bootError = Object.assign(new Error("boot failed"), { name: "BootstrapError" });
461+
mockUseInternalCameraKit.mockReturnValue(errorContext(bootError));
462+
463+
const { rerender } = renderHook(({ lensId }) => useApplyLens(lensId, "group-1"), {
464+
initialProps: { lensId: "lens-1" },
465+
});
466+
467+
rerender({ lensId: "lens-2" });
468+
469+
expect(mockReinitialize).not.toHaveBeenCalled();
470+
});
471+
472+
it("does NOT reinitialize when no target lens is set", () => {
473+
mockUseInternalCameraKit.mockReturnValue(errorContext(abortError));
474+
475+
const { rerender } = renderHook(({ groupId }) => useApplyLens(undefined, groupId), {
476+
initialProps: { groupId: "group-1" },
477+
});
478+
479+
rerender({ groupId: "group-2" });
480+
481+
expect(mockReinitialize).not.toHaveBeenCalled();
482+
});
483+
484+
it("does NOT reinitialize when SDK is ready (no regression)", async () => {
485+
const { rerender } = renderHook(({ lensId }) => useApplyLens(lensId, "group-1"), {
486+
initialProps: { lensId: "lens-1" },
487+
});
488+
489+
rerender({ lensId: "lens-2" });
490+
491+
await waitFor(() => {
492+
expect(mockApplyLens).toHaveBeenCalledWith("lens-2", "group-1", undefined, undefined);
493+
});
494+
expect(mockReinitialize).not.toHaveBeenCalled();
495+
});
496+
497+
it("reinitializes only once for a lens that keeps aborting under the same id", () => {
498+
mockUseInternalCameraKit.mockReturnValue(errorContext(abortError));
499+
500+
const { rerender } = renderHook(({ lensId }) => useApplyLens(lensId, "group-1"), {
501+
initialProps: { lensId: "lens-1" },
502+
});
503+
504+
rerender({ lensId: "lens-2" });
505+
expect(mockReinitialize).toHaveBeenCalledTimes(1);
506+
507+
// The new lens re-aborts; the id has not changed, so we must not reinit again.
508+
rerender({ lensId: "lens-2" });
509+
expect(mockReinitialize).toHaveBeenCalledTimes(1);
510+
});
511+
512+
it("emits the auto_reinit_on_lens_change metric when it fires", () => {
513+
mockUseInternalCameraKit.mockReturnValue(errorContext(abortError));
514+
515+
const { rerender } = renderHook(({ lensId }) => useApplyLens(lensId, "group-1"), {
516+
initialProps: { lensId: "lens-1" },
517+
});
518+
519+
rerender({ lensId: "lens-2" });
520+
521+
expect(mockReportCount).toHaveBeenCalledWith("auto_reinit_on_lens_change");
522+
});
523+
});
404524
});

src/useApplyLens.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { useEffect, useMemo, useRef } from "react";
2-
import { LensLaunchData } from "@snap/camera-kit";
2+
import { LensAbortError, LensLaunchData } from "@snap/camera-kit";
33
import hash from "stable-hash";
44
import { useInternalCameraKit } from "./CameraKitProvider";
5+
import { metricsReporter } from "./internal/metrics";
6+
7+
const LENS_ABORT_ERROR_NAME: LensAbortError["name"] = "LensAbortError";
58

69
/**
710
* Declaratively applies a Lens to the current CameraKit session.
@@ -31,7 +34,8 @@ export function useApplyLens(
3134
lensLaunchData?: LensLaunchData,
3235
lensReadyGuard?: () => Promise<void>,
3336
) {
34-
const { cameraKit, sdkStatus, currentSession, applyLens, removeLens, getLogger } = useInternalCameraKit();
37+
const { cameraKit, sdkStatus, sdkError, currentSession, applyLens, removeLens, reinitialize, getLogger } =
38+
useInternalCameraKit();
3539
const log = getLogger("useApplyLens");
3640

3741
const launchKey = hash(lensLaunchData);
@@ -89,4 +93,24 @@ export function useApplyLens(
8993
});
9094
};
9195
}, [lensId, lensGroupId, launchKey, sdkStatus, cameraKit, currentSession, applyLens, removeLens, log]);
96+
97+
// Auto-recovery: when a LensAbortError has wedged the SDK, a *new* lens intent
98+
// (id, group, or launch data) means "try this other lens" — so reinitialize the
99+
// SDK. Once it returns to "ready", the apply effect above runs and applies the
100+
// current lens. Gated to LensAbortError only: a bootstrap failure is unrelated to
101+
// the requested lens, so a lens change must not trigger a rebuild there.
102+
const recoveryKey = `${lensId ?? ""}::${lensGroupId ?? ""}::${launchKey}`;
103+
const prevRecoveryKeyRef = useRef(recoveryKey);
104+
useEffect(() => {
105+
const changed = prevRecoveryKeyRef.current !== recoveryKey;
106+
prevRecoveryKeyRef.current = recoveryKey;
107+
if (!changed) return;
108+
if (!lensId || !lensGroupId) return;
109+
110+
if (sdkStatus === "error" && sdkError?.name === LENS_ABORT_ERROR_NAME) {
111+
log.info("auto_reinit_on_lens_change", { lensId, groupId: lensGroupId });
112+
metricsReporter.reportCount("auto_reinit_on_lens_change");
113+
reinitialize();
114+
}
115+
}, [recoveryKey, sdkStatus, sdkError, lensId, lensGroupId, reinitialize, log]);
92116
}

0 commit comments

Comments
 (0)