Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
07b5887
Feat: Upgrade Typescript to v5 in react-paypal-js (#685)
EvanReinstein Sep 23, 2025
fe5feee
add ssr checks to load core sdk script func and add initial instance …
EvanReinstein Sep 25, 2025
8f8c959
add a basic set of hooks
EvanReinstein Sep 25, 2025
5110af3
update exports
EvanReinstein Sep 25, 2025
ba846db
mark usePayPalInstance hook as exportable
EvanReinstein Sep 25, 2025
ede8236
add isServer util and apply where appropriate
EvanReinstein Sep 25, 2025
414227f
clean up usePayPalInstance file
EvanReinstein Sep 26, 2025
eba55a7
update exports
EvanReinstein Sep 26, 2025
3fa346e
create v6 specific utils file
EvanReinstein Sep 26, 2025
599f85f
add reducer to manage various state, fix missed isServer condition in…
EvanReinstein Sep 26, 2025
f5333b2
update paypal sdk instance provider name
EvanReinstein Sep 26, 2025
853694b
remove json.stringify operation from memoizedOptions
EvanReinstein Sep 29, 2025
c59a0bb
clean up useMemo
EvanReinstein Sep 29, 2025
cfbfb0e
clean up useMemo import
EvanReinstein Sep 29, 2025
f27e7f8
update provider to account for useEffect during ssr
EvanReinstein Sep 29, 2025
b532494
add tests for client-side and server-side instance provider
EvanReinstein Sep 29, 2025
934c25a
add instance provider context test
EvanReinstein Sep 30, 2025
0005534
add context test and clean up other provider tests
EvanReinstein Sep 30, 2025
bea46b3
update initial to pending state useEffect with empty dependency array
EvanReinstein Sep 30, 2025
3f7ea83
configure provider to handle memoization of create instance and scrip…
EvanReinstein Sep 30, 2025
fe1e293
memoize context to prevent rerenders
EvanReinstein Sep 30, 2025
4755ad3
remove duplicate isServer
EvanReinstein Oct 1, 2025
ace1a38
swap deep equality check packages
EvanReinstein Oct 1, 2025
e5dc00e
update isServer to a function that also checks whether the document i…
EvanReinstein Oct 1, 2025
8397495
fix tests and missed isServer uses
EvanReinstein Oct 1, 2025
b5145df
Merge branch 'main' of https://github.com/paypal/paypal-js into featu…
EvanReinstein Oct 1, 2025
07dd9d1
Merge branch 'feature/react-paypal-js-v6' of https://github.com/paypa…
EvanReinstein Oct 1, 2025
7ffdce2
remove isServer from eligibility useEffect
EvanReinstein Oct 1, 2025
2093f29
add changeset
EvanReinstein Oct 1, 2025
c96a7e2
add ref for initial hydration
EvanReinstein Oct 1, 2025
604c6dc
remove abort controller
EvanReinstein Oct 1, 2025
12b2431
flatten create instance options props object
EvanReinstein Oct 2, 2025
f987569
update naming convention
EvanReinstein Oct 3, 2025
e19df62
update tests to reflect name and prop passing changes
EvanReinstein Oct 3, 2025
3563130
remove dead code isServer check
EvanReinstein Oct 3, 2025
c96c47d
update jsdoc
EvanReinstein Oct 3, 2025
7449a90
update imports to correct values
EvanReinstein Oct 3, 2025
77c56b9
add console warning to loadCoreSdkScript
EvanReinstein Oct 6, 2025
8e624e3
remove create instance and script options from reducer state
EvanReinstein Oct 6, 2025
ed2b6ac
remove sdkInstance check in loading useEffect
EvanReinstein Oct 7, 2025
1f7404c
Merge branch 'feature/react-paypal-js-v6' of https://github.com/paypa…
EvanReinstein Oct 7, 2025
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
6 changes: 6 additions & 0 deletions .changeset/dirty-seas-clean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@paypal/react-paypal-js": minor
"@paypal/paypal-js": minor
---

Add V6 instance provider and context hook
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.

194 changes: 97 additions & 97 deletions packages/paypal-js/CHANGELOG.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions packages/paypal-js/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,7 @@ function createScriptElement(

return newScript;
}

export function isServer(): boolean {
return typeof window === "undefined" && typeof document === "undefined";
}
12 changes: 11 additions & 1 deletion packages/paypal-js/src/v6/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { insertScriptElement } from "../utils";
import { insertScriptElement, isServer } from "../utils";
import type {
PayPalV6Namespace,
LoadCoreSdkScriptOptions,
Expand All @@ -8,6 +8,16 @@ const version = "__VERSION__";

function loadCoreSdkScript(options: LoadCoreSdkScriptOptions = {}) {
validateArguments(options);
const isServerEnv = isServer();

// SSR safeguard - warn about incorrect usage
if (isServerEnv) {
console.warn(
"PayPal JS: loadCoreSdkScript() was called on the server. This function should only be called on the client side. Please ensure you're not calling this during server-side rendering.",
);
return Promise.resolve(null);
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we want a noop here or a warning? Presumably this would only be called in a useEffect, which won't be run until the client. Seems like this being called on the server is probably more indicative of an integration problem.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great point on adding a warning!

}

const { environment, debug } = options;

const baseURL =
Expand Down
2 changes: 1 addition & 1 deletion packages/paypal-js/types/v6/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export type LoadCoreSdkScriptOptions = {

export function loadCoreSdkScript(
options: LoadCoreSdkScriptOptions,
): Promise<PayPalV6Namespace>;
): Promise<PayPalV6Namespace | null>;

// Components
export * from "./components/paypal-payments";
Expand Down
194 changes: 97 additions & 97 deletions packages/react-paypal-js/CHANGELOG.md

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions packages/react-paypal-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@
"homepage": "https://paypal.github.io/react-paypal-js/",
"dependencies": {
"@paypal/paypal-js": "^9.0.0",
"@paypal/sdk-constants": "^1.0.122"
"@paypal/sdk-constants": "^1.0.122",
"dequal": "^2.0.3"
},
"devDependencies": {
"@babel/core": "^7.17.5",
Expand Down Expand Up @@ -124,7 +125,10 @@
],
"setupFilesAfterEnv": [
"./jest.setup.ts"
]
],
"moduleNameMapper": {
"@paypal/paypal-js/sdk-v6": "<rootDir>/../../node_modules/@paypal/paypal-js/dist/v6/esm/paypal-js.js"
}
},
"bugs": {
"url": "https://github.com/paypal/react-paypal-js/issues"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/**
* @jest-environment node
*/

import React from "react";
import { renderToString } from "react-dom/server";
import { loadCoreSdkScript } from "@paypal/paypal-js/sdk-v6";

import { PayPalProvider } from "./PayPalProvider";
import { usePayPal } from "../hooks/usePayPal";
import { INSTANCE_LOADING_STATE } from "../types/PayPalProviderTypes";
import { isServer } from "../utils";
import { TEST_CLIENT_TOKEN, expectInitialState } from "./providerTestUtils";

import type {
CreateInstanceOptions,
PayPalContextState,
LoadCoreSdkScriptOptions,
} from "../types";

jest.mock("@paypal/paypal-js/sdk-v6", () => ({
loadCoreSdkScript: jest.fn(),
}));

jest.mock("../utils", () => ({
...jest.requireActual("../utils"),
isServer: () => true,
}));

const createInstanceOptions: CreateInstanceOptions<["paypal-payments"]> = {
components: ["paypal-payments"],
clientToken: TEST_CLIENT_TOKEN,
};

const scriptOptions: LoadCoreSdkScriptOptions = {
environment: "sandbox",
};

// Test utilities
function renderSSRProvider(
instanceOptions = createInstanceOptions,
scriptOpts = scriptOptions,
children?: React.ReactNode,
) {
const { state, TestComponent } = setupSSRTestComponent();
const { components, clientToken } = instanceOptions;

const html = renderToString(
<PayPalProvider
components={components}
clientToken={clientToken}
scriptOptions={scriptOpts}
>
<TestComponent>{children}</TestComponent>
</PayPalProvider>,
);

return { html, state };
}

describe("PayPalProvider SSR", () => {
beforeEach(() => {
jest.clearAllMocks();
});

test("should verify isServer mock is working", () => {
expect(isServer()).toBe(true);
});

test("should initialize correctly and never load scripts during SSR", () => {
const { state } = renderSSRProvider();

expectInitialState(state);
expect(loadCoreSdkScript).not.toHaveBeenCalled();
});

describe("Server-Side Rendering", () => {
test("should not attempt DOM access during server rendering", () => {
// In Node environment, document should be undefined
expect(typeof document).toBe("undefined");

expect(() => renderSSRProvider()).not.toThrow();
});

test("should maintain state consistency across multiple server renders", () => {
const { html: html1, state: state1 } = renderSSRProvider();
const { html: html2, state: state2 } = renderSSRProvider();

// Both renders should produce identical state and HTML
expectInitialState(state1);
expectInitialState(state2);
expect(html1).toBe(html2);
});
});

describe("Hydration Preparation", () => {
test("should prepare serializable state for client hydration", () => {
const { state } = renderSSRProvider();

// Server state should be safe for serialization
const serializedState = JSON.stringify({
loadingStatus: state.loadingStatus,
sdkInstance: state.sdkInstance,
eligiblePaymentMethods: state.eligiblePaymentMethods,
error: state.error,
});

expect(() => JSON.parse(serializedState)).not.toThrow();

const parsedState = JSON.parse(serializedState);
expectInitialState(parsedState);
});

test("should handle different options consistently", () => {
const updatedOptions = {
...createInstanceOptions,
clientToken: "updated-token",
};

const { state: state1 } = renderSSRProvider();
const { state: state2 } = renderSSRProvider(updatedOptions);

// Both should have consistent initial state regardless of options
expectInitialState(state1);
expectInitialState(state2);
expect(loadCoreSdkScript).not.toHaveBeenCalled();
});
});

describe("React SSR Integration", () => {
test("should render without warnings or errors", () => {
const consoleSpy = jest
.spyOn(console, "error")
.mockImplementation();

const { html } = renderSSRProvider(
createInstanceOptions,
scriptOptions,
<div>SSR Content</div>,
);

expect(consoleSpy).not.toHaveBeenCalled();
expect(html).toBeTruthy();
expect(typeof html).toBe("string");
expect(html).toContain("SSR Content");

consoleSpy.mockRestore();
});

test("should handle complex options without issues", () => {
const complexOptions: CreateInstanceOptions<
["paypal-payments", "venmo-payments"]
> = {
components: ["paypal-payments", "venmo-payments"],
clientToken: "complex-token-123",
locale: "en_US",
pageType: "checkout",
partnerAttributionId: "test-partner",
};

const { html } = renderSSRProvider(
// @ts-expect-error renderSSRProvider is typed for single component
complexOptions,
{ environment: "production", debug: true },
<div>Complex Options Test</div>,
);

expect(html).toBeTruthy();
expect(html).toContain("Complex Options Test");
});

test("should generate consistent HTML across renders", () => {
const { html: html1 } = renderSSRProvider(
createInstanceOptions,
scriptOptions,
<div data-testid="ssr-content">Test Content</div>,
);
const { html: html2 } = renderSSRProvider(
createInstanceOptions,
scriptOptions,
<div data-testid="ssr-content">Test Content</div>,
);

expect(html1).toBe(html2);
expect(html1).toContain('data-testid="ssr-content"');
});
});

describe("Multiple Renders", () => {
test("should handle multiple renders without side effects", () => {
// Multiple renders should not cause issues
expect(() => {
for (let i = 0; i < 5; i++) {
renderSSRProvider({
...createInstanceOptions,
clientToken: `token-${i}`,
});
}
}).not.toThrow();

// Should never attempt to load scripts regardless of render count
expect(loadCoreSdkScript).not.toHaveBeenCalled();
});
});
});

describe("usePayPalInstance SSR", () => {
test("should work correctly in SSR context", () => {
const { state } = renderSSRProvider();

expectInitialState(state);
});
});

function setupSSRTestComponent() {
const state: PayPalContextState = {
loadingStatus: INSTANCE_LOADING_STATE.INITIAL,
sdkInstance: null,
eligiblePaymentMethods: null,
error: null,
dispatch: jest.fn(),
};

function TestComponent({
children = null,
}: {
children?: React.ReactNode;
}) {
try {
const instanceState = usePayPal();
Object.assign(state, instanceState);
} catch (error) {
state.error = error as Error;
}
return <>{children}</>;
}

return {
state,
TestComponent,
};
}
Loading
Loading