Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@snap/react-camera-kit",
"version": "0.2.0",
"version": "0.3.0",
"description": "React Camera Kit for web applications",
"type": "module",
"main": "./dist/cjs/index.js",
Expand Down
120 changes: 120 additions & 0 deletions src/useApplyLens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ import { renderHook, waitFor } from "@testing-library/react";
import hash from "stable-hash";
import { useApplyLens } from "./useApplyLens";
import { useInternalCameraKit } from "./CameraKitProvider";
import { metricsReporter } from "./internal/metrics";

jest.mock("stable-hash");
jest.mock("@snap/camera-kit", () => ({}));
jest.mock("./CameraKitProvider");
jest.mock("./internal/metrics", () => ({
metricsReporter: { reportCount: jest.fn() },
}));

const mockUseInternalCameraKit = useInternalCameraKit as jest.MockedFunction<typeof useInternalCameraKit>;
const mockHash = hash as jest.MockedFunction<typeof hash>;
const mockReportCount = metricsReporter.reportCount as jest.Mock;

describe("useApplyLens", () => {
let mockApplyLens: jest.Mock;
Expand All @@ -17,6 +22,7 @@ describe("useApplyLens", () => {
let mockLogger: any;
let mockCameraKit: any;
let mockSession: any;
let mockReinitialize: jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
Expand All @@ -31,16 +37,19 @@ describe("useApplyLens", () => {
mockApplyLens = jest.fn().mockResolvedValue(true);
mockRemoveLens = jest.fn().mockResolvedValue(true);
mockGetLogger = jest.fn().mockReturnValue(mockLogger);
mockReinitialize = jest.fn();

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

mockUseInternalCameraKit.mockReturnValue({
cameraKit: mockCameraKit,
sdkStatus: "ready",
sdkError: undefined,
currentSession: mockSession,
applyLens: mockApplyLens,
removeLens: mockRemoveLens,
reinitialize: mockReinitialize,
getLogger: mockGetLogger,
} as any);

Expand Down Expand Up @@ -401,4 +410,115 @@ describe("useApplyLens", () => {
});
});
});

describe("Auto-recovery on LensAbortError", () => {
const abortError = Object.assign(new Error("aborted"), { name: "LensAbortError" });

const errorContext = (sdkError: Error) =>
({
cameraKit: null,
sdkStatus: "error",
sdkError,
currentSession: null,
applyLens: mockApplyLens,
removeLens: mockRemoveLens,
reinitialize: mockReinitialize,
getLogger: mockGetLogger,
}) as any;

it("reinitializes when lensId changes while SDK is in a LensAbortError", () => {
mockUseInternalCameraKit.mockReturnValue(errorContext(abortError));

const { rerender } = renderHook(({ lensId }) => useApplyLens(lensId, "group-1"), {
initialProps: { lensId: "lens-1" },
});

// Must not fire on mount.
expect(mockReinitialize).not.toHaveBeenCalled();

rerender({ lensId: "lens-2" });

expect(mockReinitialize).toHaveBeenCalledTimes(1);
// Recovery only un-wedges the SDK; it does not apply the lens itself.
expect(mockApplyLens).not.toHaveBeenCalled();
});

it("reinitializes when launch data changes while in a LensAbortError", () => {
mockHash.mockImplementation((obj) => JSON.stringify(obj));
mockUseInternalCameraKit.mockReturnValue(errorContext(abortError));

const { rerender } = renderHook(({ launchData }) => useApplyLens("lens-1", "group-1", launchData), {
initialProps: { launchData: { launchParams: { hint: "face" } } },
});

rerender({ launchData: { launchParams: { hint: "hand" } } });

expect(mockReinitialize).toHaveBeenCalledTimes(1);
});

it("does NOT reinitialize for a bootstrap-failure error", () => {
const bootError = Object.assign(new Error("boot failed"), { name: "BootstrapError" });
mockUseInternalCameraKit.mockReturnValue(errorContext(bootError));

const { rerender } = renderHook(({ lensId }) => useApplyLens(lensId, "group-1"), {
initialProps: { lensId: "lens-1" },
});

rerender({ lensId: "lens-2" });

expect(mockReinitialize).not.toHaveBeenCalled();
});

it("does NOT reinitialize when no target lens is set", () => {
mockUseInternalCameraKit.mockReturnValue(errorContext(abortError));

const { rerender } = renderHook(({ groupId }) => useApplyLens(undefined, groupId), {
initialProps: { groupId: "group-1" },
});

rerender({ groupId: "group-2" });

expect(mockReinitialize).not.toHaveBeenCalled();
});

it("does NOT reinitialize when SDK is ready (no regression)", async () => {
const { rerender } = renderHook(({ lensId }) => useApplyLens(lensId, "group-1"), {
initialProps: { lensId: "lens-1" },
});

rerender({ lensId: "lens-2" });

await waitFor(() => {
expect(mockApplyLens).toHaveBeenCalledWith("lens-2", "group-1", undefined, undefined);
});
expect(mockReinitialize).not.toHaveBeenCalled();
});

it("reinitializes only once for a lens that keeps aborting under the same id", () => {
mockUseInternalCameraKit.mockReturnValue(errorContext(abortError));

const { rerender } = renderHook(({ lensId }) => useApplyLens(lensId, "group-1"), {
initialProps: { lensId: "lens-1" },
});

rerender({ lensId: "lens-2" });
expect(mockReinitialize).toHaveBeenCalledTimes(1);

// The new lens re-aborts; the id has not changed, so we must not reinit again.
rerender({ lensId: "lens-2" });
expect(mockReinitialize).toHaveBeenCalledTimes(1);
});

it("emits the auto_reinit_on_lens_change metric when it fires", () => {
mockUseInternalCameraKit.mockReturnValue(errorContext(abortError));

const { rerender } = renderHook(({ lensId }) => useApplyLens(lensId, "group-1"), {
initialProps: { lensId: "lens-1" },
});

rerender({ lensId: "lens-2" });

expect(mockReportCount).toHaveBeenCalledWith("auto_reinit_on_lens_change");
});
});
});
28 changes: 26 additions & 2 deletions src/useApplyLens.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { useEffect, useMemo, useRef } from "react";
import { LensLaunchData } from "@snap/camera-kit";
import { LensAbortError, LensLaunchData } from "@snap/camera-kit";
import hash from "stable-hash";
import { useInternalCameraKit } from "./CameraKitProvider";
import { metricsReporter } from "./internal/metrics";

const LENS_ABORT_ERROR_NAME: LensAbortError["name"] = "LensAbortError";

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

const launchKey = hash(lensLaunchData);
Expand Down Expand Up @@ -89,4 +93,24 @@ export function useApplyLens(
});
};
}, [lensId, lensGroupId, launchKey, sdkStatus, cameraKit, currentSession, applyLens, removeLens, log]);

// Auto-recovery: when a LensAbortError has wedged the SDK, a *new* lens intent
// (id, group, or launch data) means "try this other lens" — so reinitialize the
// SDK. Once it returns to "ready", the apply effect above runs and applies the
// current lens. Gated to LensAbortError only: a bootstrap failure is unrelated to
// the requested lens, so a lens change must not trigger a rebuild there.
const recoveryKey = `${lensId ?? ""}::${lensGroupId ?? ""}::${launchKey}`;
const prevRecoveryKeyRef = useRef(recoveryKey);
useEffect(() => {
const changed = prevRecoveryKeyRef.current !== recoveryKey;
prevRecoveryKeyRef.current = recoveryKey;
Comment thread
msilivonik-sc marked this conversation as resolved.
if (!changed) return;
if (!lensId || !lensGroupId) return;

if (sdkStatus === "error" && sdkError?.name === LENS_ABORT_ERROR_NAME) {
log.info("auto_reinit_on_lens_change", { lensId, groupId: lensGroupId });
metricsReporter.reportCount("auto_reinit_on_lens_change");
reinitialize();
}
}, [recoveryKey, sdkStatus, sdkError, lensId, lensGroupId, reinitialize, log]);
}
Loading