diff --git a/README.md b/README.md index ccc8c55..e055964 100644 --- a/README.md +++ b/README.md @@ -169,12 +169,13 @@ import { useApplyLens } from "@snap/react-camera-kit"; useApplyLens("YOUR_LENS_ID", "YOUR_LENS_GROUP_ID"); ``` -| Parameter | Type | Description | -| ---------------- | --------------------- | ------------------------------------ | -| `lensId` | `string \| undefined` | Lens ID — pass `undefined` to remove | -| `lensGroupId` | `string \| undefined` | Lens Group ID | -| `lensLaunchData` | `LensLaunchData` | Optional launch parameters | -| `lensReadyGuard` | `() => Promise` | Optional async ready guard | +| Parameter | Type | Description | +| ---------------- | --------------------- | -------------------------------------------- | +| `lensId` | `string \| undefined` | Lens ID — pass `undefined` to remove | +| `lensGroupId` | `string \| undefined` | Lens Group ID | +| `lensLaunchData` | `LensLaunchData` | Optional launch parameters | +| `lensReadyGuard` | `() => Promise` | Optional async ready guard | +| `refreshTrigger` | `unknown` | Reapply with current parameters when changed | ### useApplySource diff --git a/package-lock.json b/package-lock.json index 9c2975e..3c21ff0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@snap/react-camera-kit", - "version": "0.5.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@snap/react-camera-kit", - "version": "0.5.1", + "version": "0.6.0", "license": "MIT", "dependencies": { "stable-hash": "^0.0.6" diff --git a/package.json b/package.json index d98013d..66ed649 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@snap/react-camera-kit", - "version": "0.5.1", + "version": "0.6.0", "description": "React Camera Kit for web applications", "type": "module", "main": "./dist/cjs/index.js", diff --git a/src/LensPlayer.test.tsx b/src/LensPlayer.test.tsx index d1c4be0..34b0df5 100644 --- a/src/LensPlayer.test.tsx +++ b/src/LensPlayer.test.tsx @@ -10,37 +10,6 @@ jest.mock("@snap/camera-kit", () => ({ bootstrapCameraKit: jest.fn(), })); -const mockRefreshLens = jest.fn(); - -// Mock CameraKitProvider -jest.mock("./CameraKitProvider", () => ({ - useCameraKit: jest.fn(() => ({ - cameraKitStatus: "ready" as const, - cameraKitError: undefined, - liveCanvas: document.createElement("canvas"), - captureCanvas: document.createElement("canvas"), - source: { status: "none" as const, error: undefined, input: undefined, initializedInput: undefined }, - lens: { - status: "none" as const, - error: undefined, - lensId: undefined, - lensGroupId: undefined, - lensLaunchData: undefined, - lensReadyGuard: undefined, - lens: undefined, - }, - reinitialize: jest.fn(), - // Lens manager methods - fetchLens: jest.fn(), - fetchLenses: jest.fn(), - applyLens: jest.fn(), - loadAndApplyLens: jest.fn(), - removeLens: jest.fn(), - refreshLens: mockRefreshLens, - lenses: [], - })), -})); - // Mock all the custom hooks jest.mock("./useApplySource"); jest.mock("./useApplyLens"); @@ -100,78 +69,18 @@ describe("LensPlayer", () => { />, ); - expect(mockUseApplyLens).toHaveBeenCalledWith(lensId, lensGroupId, lensLaunchData, lensReadyGuard); + expect(mockUseApplyLens).toHaveBeenCalledWith(lensId, lensGroupId, lensLaunchData, lensReadyGuard, undefined); }); it("should call useApplyLens with undefined values when not provided", () => { render(); - expect(mockUseApplyLens).toHaveBeenCalledWith(undefined, undefined, undefined, undefined); - }); - }); - - describe("Refresh Trigger", () => { - it("should NOT call refreshLens on initial mount even with a defined refreshTrigger value", () => { - render(); - expect(mockRefreshLens).not.toHaveBeenCalled(); - }); - - it("should NOT call refreshLens when component re-renders but refreshTrigger stays the same", () => { - const { rerender } = render(); - expect(mockRefreshLens).not.toHaveBeenCalled(); - - // Re-render with different props but same refreshTrigger - rerender(); - expect(mockRefreshLens).not.toHaveBeenCalled(); - - // Re-render again with same refreshTrigger - rerender(); - expect(mockRefreshLens).not.toHaveBeenCalled(); + expect(mockUseApplyLens).toHaveBeenCalledWith(undefined, undefined, undefined, undefined, undefined); }); - it("should call refreshLens when refreshTrigger changes", () => { - const { rerender } = render(); - expect(mockRefreshLens).not.toHaveBeenCalled(); + it("should pass refreshTrigger to useApplyLens", () => { + render(); - rerender(); - expect(mockRefreshLens).toHaveBeenCalledTimes(1); - - rerender(); - expect(mockRefreshLens).toHaveBeenCalledTimes(2); - }); - - it("should not call refreshLens when refreshTrigger is undefined and stays undefined", () => { - const { rerender } = render(); - expect(mockRefreshLens).not.toHaveBeenCalled(); - - rerender(); - expect(mockRefreshLens).not.toHaveBeenCalled(); - }); - - it("should call refreshLens when refreshTrigger changes from undefined to defined", () => { - const { rerender } = render(); - expect(mockRefreshLens).not.toHaveBeenCalled(); - - rerender(); - expect(mockRefreshLens).toHaveBeenCalledTimes(1); - }); - - it("should call refreshLens when refreshTrigger changes from defined to undefined", () => { - const { rerender } = render(); - expect(mockRefreshLens).not.toHaveBeenCalled(); - - rerender(); - expect(mockRefreshLens).toHaveBeenCalledTimes(1); - }); - - it("should handle different types of refreshTrigger values", () => { - const { rerender } = render(); - expect(mockRefreshLens).not.toHaveBeenCalled(); - - rerender(); - expect(mockRefreshLens).toHaveBeenCalledTimes(1); - - rerender(); - expect(mockRefreshLens).toHaveBeenCalledTimes(2); + expect(mockUseApplyLens).toHaveBeenCalledWith("lens-1", "group-1", undefined, undefined, 2); }); }); @@ -289,10 +198,10 @@ describe("LensPlayer", () => { describe("Props Changes", () => { it("should handle lensId changes", () => { const { rerender } = render(); - expect(mockUseApplyLens).toHaveBeenCalledWith("lens-1", "group-1", undefined, undefined); + expect(mockUseApplyLens).toHaveBeenCalledWith("lens-1", "group-1", undefined, undefined, undefined); rerender(); - expect(mockUseApplyLens).toHaveBeenCalledWith("lens-2", "group-1", undefined, undefined); + expect(mockUseApplyLens).toHaveBeenCalledWith("lens-2", "group-1", undefined, undefined, undefined); }); it("should handle source changes", () => { @@ -368,9 +277,8 @@ describe("LensPlayer", () => { props.lensGroupId, props.lensLaunchData, props.lensReadyGuard, + props.refreshTrigger, ); - // refreshLens should NOT be called on initial mount - expect(mockRefreshLens).not.toHaveBeenCalled(); const canvas = screen.getByTestId("capture-canvas"); expect(canvas).toHaveClass("test-class"); @@ -388,16 +296,6 @@ describe("LensPlayer", () => { expect(screen.queryByTestId("capture-canvas")).not.toBeInTheDocument(); expect(screen.queryByTestId("live-canvas")).not.toBeInTheDocument(); }); - - it("should handle rapid refreshTrigger changes", () => { - const { rerender } = render(); - - for (let i = 1; i <= 5; i++) { - rerender(); - } - - expect(mockRefreshLens).toHaveBeenCalledTimes(5); // Only actual changes, not initial mount - }); }); describe("Cleanup", () => { diff --git a/src/LensPlayer.tsx b/src/LensPlayer.tsx index 6e236ca..9254ef0 100644 --- a/src/LensPlayer.tsx +++ b/src/LensPlayer.tsx @@ -1,11 +1,10 @@ -import { ReactNode, useEffect, useRef } from "react"; +import { ReactNode } from "react"; import { CameraKitSessionEvents, Lens, LensLaunchData, ScreenRegions } from "@snap/camera-kit"; import { CanvasType, OutputSize, SourceInput } from "./types"; import { useApplySource } from "./useApplySource"; import { useApplyLens } from "./useApplyLens"; import { usePlaybackOptions } from "./usePlaybackOptions"; import { CaptureCanvas, LiveCanvas } from "./Canvas"; -import { useCameraKit } from "./CameraKitProvider"; /** * Props for the LensPlayer component. @@ -34,8 +33,9 @@ export interface LensPlayerProps { /** * Trigger to refresh the current Lens. When this value changes, - * the Lens will be removed and reapplied. Useful for restarting - * the Lens experience without moving LensPlayer to another component. + * the Lens will be removed and reapplied with the current Lens props. + * Useful for restarting the Lens experience without moving LensPlayer + * to another component. */ refreshTrigger?: unknown; @@ -140,17 +140,7 @@ export const LensPlayer: React.FC = ({ }) => { usePlaybackOptions({ fpsLimit, muted, screenRegions, onError }); useApplySource(source, outputSize); - useApplyLens(lensId, lensGroupId, lensLaunchData, lensReadyGuard); - - // Handle refresh trigger - only refresh when the value actually changes (not on mount) - const { refreshLens } = useCameraKit(); - const prevRefreshTrigger = useRef(refreshTrigger); - useEffect(() => { - if (prevRefreshTrigger.current !== refreshTrigger) { - prevRefreshTrigger.current = refreshTrigger; - refreshLens(); - } - }, [refreshTrigger, refreshLens]); + useApplyLens(lensId, lensGroupId, lensLaunchData, lensReadyGuard, refreshTrigger); if (children) { // If custom children were provided, we wrap them to allow styling at the outer div. diff --git a/src/useApplyLens.test.ts b/src/useApplyLens.test.ts index d94fa2c..28ef10e 100644 --- a/src/useApplyLens.test.ts +++ b/src/useApplyLens.test.ts @@ -267,6 +267,52 @@ describe("useApplyLens", () => { // Should not reapply since stable key is the same expect(mockApplyLens).not.toHaveBeenCalled(); }); + + it("should reapply the lens with current launch data when refreshTrigger changes", async () => { + const launchData = { launchParams: { hint: "face" } }; + const { rerender } = renderHook( + ({ refreshTrigger }) => useApplyLens("lens-1", "group-1", launchData, undefined, refreshTrigger), + { initialProps: { refreshTrigger: 1 } }, + ); + + await waitFor(() => { + expect(mockApplyLens).toHaveBeenCalledTimes(1); + }); + mockApplyLens.mockClear(); + + rerender({ refreshTrigger: 1 }); + expect(mockApplyLens).not.toHaveBeenCalled(); + + rerender({ refreshTrigger: 2 }); + await waitFor(() => { + expect(mockApplyLens).toHaveBeenCalledWith("lens-1", "group-1", launchData, undefined); + }); + }); + + it("should apply updated launch data once when it changes with refreshTrigger", async () => { + const { rerender } = renderHook( + ({ launchData, refreshTrigger }) => useApplyLens("lens-1", "group-1", launchData, undefined, refreshTrigger), + { + initialProps: { + launchData: { launchParams: { hint: "face" } }, + refreshTrigger: 1, + }, + }, + ); + + await waitFor(() => { + expect(mockApplyLens).toHaveBeenCalledTimes(1); + }); + mockApplyLens.mockClear(); + + const updatedLaunchData = { launchParams: { hint: "hand" } }; + rerender({ launchData: updatedLaunchData, refreshTrigger: 2 }); + + await waitFor(() => { + expect(mockApplyLens).toHaveBeenCalledTimes(1); + expect(mockApplyLens).toHaveBeenCalledWith("lens-1", "group-1", updatedLaunchData, undefined); + }); + }); }); describe("Cleanup on unmount", () => { @@ -489,6 +535,35 @@ describe("useApplyLens", () => { expect(mockReinitialize).toHaveBeenCalledTimes(1); }); + it("reinitializes when refreshTrigger changes while in a LensAbortError", () => { + mockUseInternalCameraKit.mockReturnValue(errorContext(abortError)); + + const { rerender } = renderHook( + ({ refreshTrigger }) => useApplyLens("lens-1", "group-1", undefined, undefined, refreshTrigger), + { initialProps: { refreshTrigger: 1 } }, + ); + + expect(mockReinitialize).not.toHaveBeenCalled(); + + rerender({ refreshTrigger: 2 }); + + expect(mockReinitialize).toHaveBeenCalledTimes(1); + expect(mockReportCount).toHaveBeenCalledWith("auto_reinit_on_refresh"); + }); + + it("does NOT reinitialize when refreshTrigger is unchanged", () => { + mockUseInternalCameraKit.mockReturnValue(errorContext(abortError)); + + const { rerender } = renderHook( + ({ refreshTrigger }) => useApplyLens("lens-1", "group-1", undefined, undefined, refreshTrigger), + { initialProps: { refreshTrigger: 1 } }, + ); + + rerender({ refreshTrigger: 1 }); + + expect(mockReinitialize).not.toHaveBeenCalled(); + }); + it("does NOT reinitialize for a bootstrap-failure error", () => { const bootError = Object.assign(new Error("boot failed"), { name: "BootstrapError" }); mockUseInternalCameraKit.mockReturnValue(errorContext(bootError)); diff --git a/src/useApplyLens.ts b/src/useApplyLens.ts index 1d3f893..1801cc3 100644 --- a/src/useApplyLens.ts +++ b/src/useApplyLens.ts @@ -18,6 +18,7 @@ const LENS_ABORT_ERROR_NAME: LensAbortError["name"] = "LensAbortError"; * @param lensLaunchData - Optional launch parameters to pass to the lens. * @param lensReadyGuard - Optional async guard that must resolve before the lens is considered ready. * Useful for coordinating lens application with animations or other async operations. + * @param refreshTrigger - Optional value that forces the current Lens to be reapplied when it changes. * * @example * ```tsx @@ -33,6 +34,7 @@ export function useApplyLens( lensGroupId?: string, lensLaunchData?: LensLaunchData, lensReadyGuard?: () => Promise, + refreshTrigger?: unknown, ) { const { cameraKit, sdkStatus, sdkError, currentSession, applyLens, removeLens, reinitialize, getLogger } = useInternalCameraKit(); @@ -53,7 +55,7 @@ export function useApplyLens( }); // Synchronize the current CameraKit session with the requested Lens. - // * Runs when lensId, lensGroupId, or lensLaunchData meaningfully change (see stable-key check). + // * Runs when lensId, lensGroupId, lensLaunchData, or refreshTrigger meaningfully change. // * Applies the Lens once, with an abort-guard so late resolutions don’t touch an unmounted component. useEffect(() => { if (sdkStatus !== "ready" || !cameraKit || !currentSession) return; @@ -89,25 +91,38 @@ export function useApplyLens( log.warn("remove_on_unmount_failed", { lensId, groupId: lensGroupId }, err); }); }; - }, [lensId, lensGroupId, launchKey, sdkStatus, cameraKit, currentSession, applyLens, removeLens, log]); + }, [ + lensId, + lensGroupId, + launchKey, + refreshTrigger, + 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. + // Auto-recovery: when a LensAbortError has wedged the SDK, a new lens application + // intent (id, group, launch data, or refresh trigger) reinitializes the SDK. Once + // it returns to "ready", the apply effect above applies the current lens. Gated to + // LensAbortError only: a bootstrap failure is unrelated to the requested lens. const recoveryKey = `${lensId ?? ""}::${lensGroupId ?? ""}::${launchKey}`; - const prevRecoveryKeyRef = useRef(recoveryKey); + const previousRecoveryIntentRef = useRef({ recoveryKey, refreshTrigger }); useEffect(() => { - const changed = prevRecoveryKeyRef.current !== recoveryKey; - prevRecoveryKeyRef.current = recoveryKey; - if (!changed) return; + const previousIntent = previousRecoveryIntentRef.current; + const lensChanged = previousIntent.recoveryKey !== recoveryKey; + const refreshRequested = !Object.is(previousIntent.refreshTrigger, refreshTrigger); + previousRecoveryIntentRef.current = { recoveryKey, refreshTrigger }; + if (!lensChanged && !refreshRequested) 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"); + const recoveryReason = lensChanged ? "lens_change" : "refresh"; + log.info("auto_reinit_on_lens_intent", { lensId, groupId: lensGroupId, recoveryReason }); + metricsReporter.reportCount(`auto_reinit_on_${recoveryReason}`); reinitialize(); } - }, [recoveryKey, sdkStatus, sdkError, lensId, lensGroupId, reinitialize, log]); + }, [recoveryKey, refreshTrigger, sdkStatus, sdkError, lensId, lensGroupId, reinitialize, log]); }