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
5 changes: 5 additions & 0 deletions .changeset/pay-later-eligibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@paypal/react-paypal-js": patch
---

Integrate useEligibleMethods hook into PayLaterOneTimePaymentButton component to automatically fetch and dispatch eligibility to context. Also fixes React Strict Mode compatibility by resetting lastFetchRef on cleanup.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { usePayPal } from "../hooks/usePayPal";

import type { UsePayLaterOneTimePaymentSessionProps } from "../hooks/usePayLaterOneTimePaymentSession";

type PayLaterOneTimePaymentButtonProps =
export type PayLaterOneTimePaymentButtonProps =
UsePayLaterOneTimePaymentSessionProps & {
autoRedirect?: never;
disabled?: boolean;
Expand Down
59 changes: 45 additions & 14 deletions packages/react-paypal-js/src/v6/hooks/useEligibleMethods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,16 +110,17 @@ describe("useEligibleMethods", () => {
});

describe("isLoading state", () => {
test("should return isLoading=false initially when not fetching", () => {
test("should return isLoading=true when no eligibility data and no error", () => {
const { result } = renderHook(() => useEligibleMethods(), {
wrapper: createWrapper({
loadingStatus: INSTANCE_LOADING_STATE.PENDING,
eligiblePaymentMethods: null,
}),
});

// isLoading reflects isFetching state, not loadingStatus
// Initially false because no fetch has started yet (no sdkInstance)
expect(result.current.isLoading).toBe(false);
// isLoading is true when we don't have eligibility data yet
// This prevents UI flash before effect runs
expect(result.current.isLoading).toBe(true);
});

test("should return isLoading=false when eligibility already in context", () => {
Expand All @@ -139,15 +140,23 @@ describe("useEligibleMethods", () => {
expect(result.current.isLoading).toBe(false);
});

test("should return isLoading=false when context has error", () => {
test("should return isLoading=true when context has error but no eligibility data", () => {
// Context error (SDK load failure) doesn't set eligibilityError,
// but we still don't have eligibility data, so isLoading is true
const { result } = renderHook(() => useEligibleMethods(), {
wrapper: createWrapper({
loadingStatus: INSTANCE_LOADING_STATE.REJECTED,
error: new Error("Failed"),
eligiblePaymentMethods: null,
}),
});

expect(result.current.isLoading).toBe(false);
// isLoading is true because we don't have eligibility data
// The context error is returned separately
expect(result.current.isLoading).toBe(true);
expect(result.current.error?.message).toContain(
"PayPal context error",
);
});
});

Expand Down Expand Up @@ -185,26 +194,28 @@ describe("useEligibleMethods", () => {
}),
});

// isLoading is false because no fetch has started (no sdkInstance)
// isLoading is true because we don't have eligibility data yet
// This prevents UI flash before the effect has a chance to run
expect(result.current).toEqual({
eligiblePaymentMethods: null,
isLoading: false,
isLoading: true,
error: null,
});
});

test("should return correct shape during error state", () => {
test("should return correct shape during error state with no eligibility", () => {
const { result } = renderHook(() => useEligibleMethods(), {
wrapper: createWrapper({
eligiblePaymentMethods: null,
loadingStatus: INSTANCE_LOADING_STATE.REJECTED,
}),
});

// Error from hook's local state, not context
// isLoading is true because we have no eligibility data and no eligibility error
// (context error is different from eligibility fetch error)
expect(result.current).toEqual({
eligiblePaymentMethods: null,
isLoading: false,
isLoading: true,
error: null,
});
});
Expand Down Expand Up @@ -298,7 +309,7 @@ describe("useEligibleMethods", () => {
expect(mockDispatch).not.toHaveBeenCalled();
});

test("should set isLoading=true while fetching and false after", async () => {
test("should set isLoading=true while fetching", async () => {
let resolvePromise: (value: unknown) => void;
const pendingPromise = new Promise((resolve) => {
resolvePromise = resolve;
Expand All @@ -315,13 +326,33 @@ describe("useEligibleMethods", () => {
}),
});

// Effect runs synchronously, so isLoading is already true
// isLoading is true because no eligibility data yet
expect(result.current.isLoading).toBe(true);

// After promise resolves, should be false
// After promise resolves, isLoading is still true because
// eligiblePaymentMethods in context hasn't been updated
// (dispatch is mocked). In real usage, context would update.
await act(async () => {
resolvePromise!(mockEligibilityResult);
});
// The fetch itself completes but context isn't updated in this test setup
// so isLoading remains true. See "isLoading state" tests for cases
// where eligibility is in context.
expect(result.current.isLoading).toBe(true);
});

test("should return isLoading=false when eligibility data exists", () => {
const mockSdkInstance = createMockSdkInstance();

const { result } = renderHook(() => useEligibleMethods(), {
wrapper: createWrapper({
sdkInstance: mockSdkInstance,
eligiblePaymentMethods: mockEligibilityResult,
loadingStatus: INSTANCE_LOADING_STATE.RESOLVED,
}),
});

// isLoading is false because eligibility data exists in context
expect(result.current.isLoading).toBe(false);
});

Expand Down
29 changes: 19 additions & 10 deletions packages/react-paypal-js/src/v6/hooks/useEligibleMethods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ export function useEligibleMethods(
const [eligibilityError, setError] = useError();
const [isFetching, setIsFetching] = useState(false);

// Use ref to access eligiblePaymentMethods in effect without adding to deps
const eligiblePaymentMethodsRef = useRef(eligiblePaymentMethods);
eligiblePaymentMethodsRef.current = eligiblePaymentMethods;

// Memoize payload to avoid unnecessary re-fetches when object reference changes
const memoizedPayload = useDeepCompareMemoize(payload);

Expand Down Expand Up @@ -100,7 +104,10 @@ export function useEligibleMethods(

// If eligibility exists and we haven't fetched anything yet (e.g., server hydration),
// mark as fetched to avoid unnecessary re-fetch with same payload
if (eligiblePaymentMethods && lastFetchRef.current === null) {
if (
eligiblePaymentMethodsRef.current &&
lastFetchRef.current === null
) {
lastFetchRef.current = {
instance: sdkInstance,
payload: memoizedPayload,
Expand Down Expand Up @@ -140,26 +147,28 @@ export function useEligibleMethods(

return () => {
isSubscribed = false;
lastFetchRef.current = null; // Reset fetch tracking on unmount or dependency change
Copy link
Contributor

@nityasp nityasp Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of setting lastFetchRef.current to null, can we let the dependency array trigger new fetches when needed. That way only when payload/instance changes it triggers a new fetch.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. I think this fix was related to StrictMode double mounting practice.

Copy link
Contributor Author

@HackTheW2d HackTheW2d Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @nityasp This is to pass our StrictMode test in react, Consider this flow:
Step: Mount 1
Without = null: fetch, lastFetchRef = {A, X}
With = null: fetch, lastFetchRef = {A, X}
────────────────────────────────────────
Step: Unmount
Without = null: isSubscribed=false, lastFetchRef = {A, X}
With = null: isSubscribed=false, lastFetchRef=null
────────────────────────────────────────
Step: Mount 2
Without = null: hasFetchedThisConfig=true, will skip fetch
With = null: hasFetchedThisConfig=false, will do a new fetch
────────────────────────────────────────
Step: Result
without = null: ❌ The first fetch's dispatch won't execute (isSubscribed=false), no data of eligibleMethods
with = null: ✅ Second fetch can dispatch and there's result stored in context

I hope this illustration can clarify your confusion. Lmk if you have any other questions. Thank you!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for explaining it in detail. I understood it.

};
}, [
sdkInstance,
memoizedPayload,
eligiblePaymentMethods,
dispatch,
setError,
]);
}, [sdkInstance, memoizedPayload, dispatch, setError]);

// isLoading should be true if:
// 1. We're actively fetching, OR
// 2. We don't have eligibility data yet and no error occurred
// This prevents a flash where isLoading=false before the effect runs
const isLoading =
isFetching || (!eligiblePaymentMethods && !eligibilityError);
Comment on lines +154 to +159
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice 👍


if (contextError) {
return {
eligiblePaymentMethods,
isLoading: isFetching,
isLoading,
error: new Error(`PayPal context error: ${contextError}`),
};
}

return {
eligiblePaymentMethods,
isLoading: isFetching,
isLoading,
error: eligibilityError,
};
}
5 changes: 4 additions & 1 deletion packages/react-paypal-js/src/v6/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ export {
PayPalCardFieldsProvider,
type CardFieldsSessionType,
} from "./components/PayPalCardFieldsProvider";
export { PayLaterOneTimePaymentButton } from "./components/PayLaterOneTimePaymentButton";
export {
PayLaterOneTimePaymentButton,
type PayLaterOneTimePaymentButtonProps,
} from "./components/PayLaterOneTimePaymentButton";
export { PayPalGuestPaymentButton } from "./components/PayPalGuestPaymentButton";
export { PayPalOneTimePaymentButton } from "./components/PayPalOneTimePaymentButton";
export { PayPalProvider } from "./components/PayPalProvider";
Expand Down
Loading