From 66f431a2b7e90743b3a2348f28352bc8dc1efdce Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:36:48 +0100 Subject: [PATCH] Lazy-load Stripe SDK so it only loads on checkout (#6546) # Description of Changes - Stripe SDK (`@stripe/*` + `js.stripe.com/v3`) was loading on every page; now it only loads when an upgrade/checkout modal actually opens. - Converted every import site to `React.lazy()` + ``, gated by the existing `opened` state. - Adds a Playwright spec that asserts no Stripe requests on landing or settings. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../tests/stubbed/stripe-lazy-load.spec.ts | 81 +++++++++++++++++++ .../proprietary/contexts/CheckoutContext.tsx | 31 ++++--- .../saas/components/TrialExpiredBootstrap.tsx | 37 +++++---- .../components/shared/TrialStatusBanner.tsx | 33 ++++---- .../shared/config/configSections/Plan.tsx | 61 ++++++++------ .../plan/ApiPackagesSection.tsx | 2 +- 6 files changed, 178 insertions(+), 67 deletions(-) create mode 100644 frontend/editor/src/core/tests/stubbed/stripe-lazy-load.spec.ts diff --git a/frontend/editor/src/core/tests/stubbed/stripe-lazy-load.spec.ts b/frontend/editor/src/core/tests/stubbed/stripe-lazy-load.spec.ts new file mode 100644 index 000000000..561f0d1bc --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/stripe-lazy-load.spec.ts @@ -0,0 +1,81 @@ +import { test, expect } from "@app/tests/helpers/stub-test-base"; + +/** + * Verifies that the Stripe SDK (@stripe/stripe-js, @stripe/react-stripe-js, + * and the js.stripe.com remote script) is NOT fetched on cold page loads — + * only when the checkout modal actually mounts. The proprietary + * CheckoutProvider, the SaaS TrialStatusBanner, and the SaaS Plan settings + * page all gate the modal behind React.lazy + a conditional render so the + * Stripe chunk lives in its own async bundle. + */ + +const STRIPE_URL_FRAGMENTS = [ + "js.stripe.com", + "@stripe/stripe-js", + // Vite's optimizeDeps id mangles slashes to underscores when serving + // pre-bundled dependencies from node_modules/.vite/deps. + "@stripe_stripe-js", + "@stripe/react-stripe-js", + "@stripe_react-stripe-js", +]; + +function isStripeRequest(url: string): boolean { + return STRIPE_URL_FRAGMENTS.some((fragment) => url.includes(fragment)); +} + +test.describe("Stripe SDK lazy loading", () => { + test("landing page does not fetch Stripe SDK on cold load", async ({ + page, + }) => { + const stripeRequests: string[] = []; + page.on("request", (req) => { + const url = req.url(); + if (isStripeRequest(url)) { + stripeRequests.push(url); + } + }); + + // The stub fixture already navigated to "/" with waitUntil: + // domcontentloaded. Wait for the app to actually settle so any lurking + // module-eval-time loadStripe() call would have already kicked off. + await page + .waitForLoadState("networkidle", { timeout: 15_000 }) + .catch(() => { + // Posthog / iconify keep some connections warm — fall back to a + // brief settle window if networkidle never resolves. + }); + await page.waitForTimeout(2_000); + + expect( + stripeRequests, + `Expected no Stripe-related network activity on landing, but observed:\n${stripeRequests.join("\n")}`, + ).toEqual([]); + }); + + test("settings modal does not fetch Stripe SDK", async ({ page }) => { + const stripeRequests: string[] = []; + page.on("request", (req) => { + const url = req.url(); + if (isStripeRequest(url)) { + stripeRequests.push(url); + } + }); + + // Opening Settings is the closest a default proprietary user gets to + // checkout without actually clicking Upgrade. Even rendering the + // settings drawer must NOT pull Stripe into the entry path — only the + // upgrade modal itself, which sits one click further in. + const settingsButton = page + .getByRole("button", { name: /settings/i }) + .first(); + if (await settingsButton.isVisible({ timeout: 5_000 }).catch(() => false)) { + await settingsButton.click(); + await page.waitForTimeout(1_500); + } + + expect( + stripeRequests, + `Expected no Stripe-related network activity after opening settings, but observed:\n${stripeRequests.join("\n")}`, + ).toEqual([]); + }); +}); diff --git a/frontend/editor/src/proprietary/contexts/CheckoutContext.tsx b/frontend/editor/src/proprietary/contexts/CheckoutContext.tsx index 45912667f..e050114be 100644 --- a/frontend/editor/src/proprietary/contexts/CheckoutContext.tsx +++ b/frontend/editor/src/proprietary/contexts/CheckoutContext.tsx @@ -4,6 +4,8 @@ import React, { useState, useCallback, useEffect, + lazy, + Suspense, ReactNode, } from "react"; import { useTranslation } from "react-i18next"; @@ -13,7 +15,12 @@ import licenseService, { mapLicenseToTier, PlanTier, } from "@app/services/licenseService"; -import { StripeCheckout } from "@app/components/shared/stripeCheckout"; + +const StripeCheckout = lazy(() => + import("@app/components/shared/stripeCheckout").then((m) => ({ + default: m.StripeCheckout, + })), +); import { userManagementService } from "@app/services/userManagementService"; import { alert } from "@app/components/toast"; import { @@ -433,16 +440,18 @@ export const CheckoutProvider: React.FC = ({ {/* Global Checkout Modal */} {selectedPlanGroup && ( - + + + )} ); diff --git a/frontend/editor/src/saas/components/TrialExpiredBootstrap.tsx b/frontend/editor/src/saas/components/TrialExpiredBootstrap.tsx index 1b185684c..40826f190 100644 --- a/frontend/editor/src/saas/components/TrialExpiredBootstrap.tsx +++ b/frontend/editor/src/saas/components/TrialExpiredBootstrap.tsx @@ -1,7 +1,10 @@ -import { useEffect, useState } from "react"; +import { lazy, Suspense, useEffect, useState } from "react"; import { useAuth } from "@app/auth/UseSession"; import { TrialExpiredModal } from "@app/components/shared/TrialExpiredModal"; -import StripeCheckout from "@app/components/shared/StripeCheckoutSaas"; + +const StripeCheckout = lazy( + () => import("@app/components/shared/StripeCheckoutSaas"), +); /** * Bootstrap component that shows the trial expired modal when a user's trial has ended @@ -106,20 +109,22 @@ export default function TrialExpiredBootstrap() { onSubscribe={handleSubscribe} /> - {user && ( - - console.error("[TrialExpired] Checkout error:", error) - } - isTrialConversion={false} // Trial already ended, so this is not a conversion - /> + {user && checkoutOpened && ( + + + console.error("[TrialExpired] Checkout error:", error) + } + isTrialConversion={false} // Trial already ended, so this is not a conversion + /> + )} ); diff --git a/frontend/editor/src/saas/components/shared/TrialStatusBanner.tsx b/frontend/editor/src/saas/components/shared/TrialStatusBanner.tsx index e6e7322ec..7efec40eb 100644 --- a/frontend/editor/src/saas/components/shared/TrialStatusBanner.tsx +++ b/frontend/editor/src/saas/components/shared/TrialStatusBanner.tsx @@ -1,11 +1,14 @@ -import { useEffect, useState, useCallback } from "react"; +import { lazy, Suspense, useEffect, useState, useCallback } from "react"; import { useBanner } from "@app/contexts/BannerContext"; import { useAuth } from "@app/auth/UseSession"; import { useTranslation } from "react-i18next"; import { InfoBanner } from "@app/components/shared/InfoBanner"; -import StripeCheckout from "@app/components/shared/StripeCheckoutSaas"; import { BASE_PATH } from "@app/constants/app"; +const StripeCheckout = lazy( + () => import("@app/components/shared/StripeCheckoutSaas"), +); + const SESSION_STORAGE_KEY = "trialBannerDismissed"; export function TrialStatusBanner() { @@ -113,18 +116,20 @@ export function TrialStatusBanner() { return ( <> - {trialStatus && ( - setCheckoutOpen(false)} - purchaseType="subscription" - planId="pro" - creditsPack={null} - planName="Pro" - onSuccess={handleCheckoutSuccess} - onError={(error) => console.error("Checkout error:", error)} - isTrialConversion={true} - /> + {trialStatus && checkoutOpen && ( + + setCheckoutOpen(false)} + purchaseType="subscription" + planId="pro" + creditsPack={null} + planName="Pro" + onSuccess={handleCheckoutSuccess} + onError={(error) => console.error("Checkout error:", error)} + isTrialConversion={true} + /> + )} ); diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx b/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx index af7dcb2f8..8571c5db4 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx @@ -1,11 +1,15 @@ -import React, { useState, useCallback, useEffect } from "react"; +import React, { lazy, Suspense, useState, useCallback, useEffect } from "react"; import { Divider, Loader, Alert, Select, Group, Text } from "@mantine/core"; import { usePlans, PlanTier } from "@app/hooks/usePlans"; -import StripeCheckout, { +import type { PurchaseType, CreditsPack, PlanID, } from "@app/components/shared/StripeCheckoutSaas"; + +const StripeCheckout = lazy( + () => import("@app/components/shared/StripeCheckoutSaas"), +); import AvailablePlansSection from "@app/components/shared/config/configSections/plan/AvailablePlansSection"; import ApiPackagesSection from "@app/components/shared/config/configSections/plan/ApiPackagesSection"; import ActivePlanSection from "@app/components/shared/config/configSections/plan/ActivePlanSection"; @@ -204,29 +208,36 @@ const Plan: React.FC = () => { /> {/* Stripe Checkout Modal */} - pkg.id === selectedCreditsPack) - ?.name || "" - } - onSuccess={handlePaymentSuccess} - onError={handlePaymentError} - isTrialConversion={ - trialStatus?.isTrialing && purchaseType === "subscription" - } - /> + {checkoutOpen && + (selectedPlan !== null || selectedCreditsPack !== null) && ( + + pkg.id === selectedCreditsPack, + )?.name || "" + } + onSuccess={handlePaymentSuccess} + onError={handlePaymentError} + isTrialConversion={ + trialStatus?.isTrialing && purchaseType === "subscription" + } + /> + + )} ); }; diff --git a/frontend/editor/src/saas/components/shared/config/configSections/plan/ApiPackagesSection.tsx b/frontend/editor/src/saas/components/shared/config/configSections/plan/ApiPackagesSection.tsx index 09e238be0..f707d5d9a 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/plan/ApiPackagesSection.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/plan/ApiPackagesSection.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Button, Card, Text, Stack, Flex, Slider } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { CreditsPack } from "@app/components/shared/StripeCheckoutSaas"; +import type { CreditsPack } from "@app/components/shared/StripeCheckoutSaas"; interface ApiPackage { id: string;