diff --git a/package-lock.json b/package-lock.json index e34b948..a303325 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@snap/react-camera-kit", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@snap/react-camera-kit", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "stable-hash": "^0.0.6" diff --git a/package.json b/package.json index 1036be6..c714a2f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/useApplyLens.test.ts b/src/useApplyLens.test.ts index afabf7a..a059058 100644 --- a/src/useApplyLens.test.ts +++ b/src/useApplyLens.test.ts @@ -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; const mockHash = hash as jest.MockedFunction; +const mockReportCount = metricsReporter.reportCount as jest.Mock; describe("useApplyLens", () => { let mockApplyLens: jest.Mock; @@ -17,6 +22,7 @@ describe("useApplyLens", () => { let mockLogger: any; let mockCameraKit: any; let mockSession: any; + let mockReinitialize: jest.Mock; beforeEach(() => { jest.clearAllMocks(); @@ -31,6 +37,7 @@ 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" }; @@ -38,9 +45,11 @@ describe("useApplyLens", () => { mockUseInternalCameraKit.mockReturnValue({ cameraKit: mockCameraKit, sdkStatus: "ready", + sdkError: undefined, currentSession: mockSession, applyLens: mockApplyLens, removeLens: mockRemoveLens, + reinitialize: mockReinitialize, getLogger: mockGetLogger, } as any); @@ -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"); + }); + }); }); diff --git a/src/useApplyLens.ts b/src/useApplyLens.ts index 0f1bcfe..0c9a559 100644 --- a/src/useApplyLens.ts +++ b/src/useApplyLens.ts @@ -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. @@ -31,7 +34,8 @@ export function useApplyLens( lensLaunchData?: LensLaunchData, lensReadyGuard?: () => Promise, ) { - 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); @@ -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; + 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]); }