Skip to content
Merged
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
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