From 00a015cd924cb82b188d7b31a9a8745ca14f320c Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Mon, 22 Sep 2025 17:36:38 +0200 Subject: [PATCH] feat: update growthbook and handle network errors (#61374) Co-authored-by: ahmad abdolsaheb --- client/package.json | 2 +- .../client-only-routes/show-settings.test.tsx | 8 +- client/src/components/Intro/intro.test.tsx | 4 + .../growth-book/growth-book-wrapper.test.tsx | 145 ++++++++++++++++++ .../growth-book/growth-book-wrapper.tsx | 68 +++++--- client/src/pages/index.tsx | 38 ++++- pnpm-lock.yaml | 28 ++-- 7 files changed, 247 insertions(+), 46 deletions(-) create mode 100644 client/src/components/growth-book/growth-book-wrapper.test.tsx diff --git a/client/package.json b/client/package.json index de526f5ebd1..b21b14eee17 100644 --- a/client/package.json +++ b/client/package.json @@ -52,7 +52,7 @@ "@freecodecamp/loop-protect": "3.0.0", "@freecodecamp/ui": "4.3.0", "@gatsbyjs/reach-router": "1.3.9", - "@growthbook/growthbook-react": "0.20.0", + "@growthbook/growthbook-react": "1.6.0", "@headlessui/react": "1.7.19", "@loadable/component": "5.16.3", "@redux-devtools/extension": "3.3.0", diff --git a/client/src/client-only-routes/show-settings.test.tsx b/client/src/client-only-routes/show-settings.test.tsx index 4925b579521..71d390681f0 100644 --- a/client/src/client-only-routes/show-settings.test.tsx +++ b/client/src/client-only-routes/show-settings.test.tsx @@ -4,12 +4,14 @@ import React from 'react'; import ShallowRenderer from 'react-test-renderer/shallow'; import { describe, it, expect, vi } from 'vitest'; import envData from '../../config/env.json'; - import { ShowSettings } from './show-settings'; -const { apiLocation } = envData as Record; - vi.mock('../analytics'); +vi.mock('@growthbook/growthbook-react', () => ({ + useFeatureIsOn: () => false +})); + +const { apiLocation } = envData as Record; describe('', () => { it('renders to the DOM when user is logged in', () => { diff --git a/client/src/components/Intro/intro.test.tsx b/client/src/components/Intro/intro.test.tsx index 21d2b7bdb3b..95e69cc281d 100644 --- a/client/src/components/Intro/intro.test.tsx +++ b/client/src/components/Intro/intro.test.tsx @@ -7,6 +7,10 @@ import { createStore } from '../../redux/create-store'; import Intro from '.'; vi.mock('../../analytics'); +vi.mock('@growthbook/growthbook-react', () => ({ + useFeature: () => ({ on: false, value: undefined }), + useFeatureIsOn: () => false +})); vi.mock('../../utils/get-words'); function renderWithRedux( diff --git a/client/src/components/growth-book/growth-book-wrapper.test.tsx b/client/src/components/growth-book/growth-book-wrapper.test.tsx new file mode 100644 index 00000000000..4191fa1045c --- /dev/null +++ b/client/src/components/growth-book/growth-book-wrapper.test.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { vi, describe, test, expect, beforeEach } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import GrowthBookWrapper from './growth-book-wrapper'; + +interface MinimalUser { + completedChallenges: unknown[]; + email: string; + joinDate: string; +} + +interface TestWrapperProps { + user: MinimalUser | null; + userFetchState: { + pending: boolean; + complete: boolean; + errored: boolean; + error: string | null; + }; + children: JSX.Element; +} + +const UnconnectedTestWrapper = ({ + children, + user, + userFetchState +}: TestWrapperProps) => ( + )} + > + {children} + +); + +vi.mock('react-redux', () => ({ + connect: () => (Comp: React.ComponentType) => Comp +})); + +vi.mock('./growth-book-redux-connector', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ) +})); + +vi.mock('../../../config/env.json', () => ({ + default: { + clientLocale: 'en', + growthbookUri: 'https://example.com/api/features/gb_client_123' + } +})); + +vi.mock('../../../config/growthbook-features-default.json', () => ({ + default: { + mockFeature: { defaultValue: true } + } +})); + +let currentInitImpl: () => + | Promise<{ success: boolean; source?: string }> + | Promise = () => Promise.resolve({ success: true }); + +const mockInit = vi.fn(() => currentInitImpl()); +const mockSetPayload = vi.fn((_arg: Record) => + Promise.resolve() +); +const mockSetAttributes = vi.fn((_arg: Record) => + Promise.resolve() +); + +export function setInitImpl( + impl: () => Promise<{ success: boolean; source?: string }> | Promise +) { + currentInitImpl = impl; +} + +vi.mock('@growthbook/growthbook-react', () => ({ + GrowthBook: vi.fn().mockImplementation(() => ({ + init: () => mockInit(), + setPayload: (arg: Record) => mockSetPayload(arg), + setAttributes: (arg: Record) => mockSetAttributes(arg) + })), + GrowthBookProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ) +})); + +function renderWrapper(initOptions: { complete: boolean }) { + return render( + +
+ + ); +} + +describe('GrowthBookWrapper init effect', () => { + beforeEach(() => { + vi.clearAllMocks(); + setInitImpl(() => Promise.resolve({ success: true })); + mockInit.mockImplementation(() => currentInitImpl()); + }); + + test('does not apply fallback when init succeeds', async () => { + setInitImpl(() => Promise.resolve({ success: true })); + + renderWrapper({ complete: false }); + + await waitFor(() => expect(mockInit).toHaveBeenCalled()); + expect(mockSetPayload).not.toHaveBeenCalled(); + }); + + test('applies fallback when init resolves with success: false', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + setInitImpl(() => Promise.resolve({ success: false, source: 'network' })); + + renderWrapper({ complete: false }); + + await waitFor(() => expect(mockInit).toHaveBeenCalled()); + expect(mockSetPayload).toHaveBeenCalledWith({ + features: { mockFeature: { defaultValue: true } } + }); + }); + + test('applies fallback when init rejects', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + setInitImpl(() => Promise.reject(new Error('boom'))); + + renderWrapper({ complete: false }); + + await waitFor(() => expect(mockInit).toHaveBeenCalled()); + await waitFor(() => + expect(mockSetPayload).toHaveBeenCalledWith({ + features: { mockFeature: { defaultValue: true } } + }) + ); + }); +}); diff --git a/client/src/components/growth-book/growth-book-wrapper.tsx b/client/src/components/growth-book/growth-book-wrapper.tsx index ba236e09861..24add2fedf9 100644 --- a/client/src/components/growth-book/growth-book-wrapper.tsx +++ b/client/src/components/growth-book/growth-book-wrapper.tsx @@ -1,9 +1,5 @@ import React, { useEffect, useMemo } from 'react'; -import { - FeatureDefinition, - GrowthBook, - GrowthBookProvider -} from '@growthbook/growthbook-react'; +import { GrowthBook, GrowthBookProvider } from '@growthbook/growthbook-react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { userSelector, userFetchStateSelector } from '../../redux/selectors'; @@ -19,6 +15,25 @@ const { clientLocale, growthbookUri } = envData as { growthbookUri: string | null; }; +// Parses GrowthBook URL to extract apiHost and clientKey +function parseGrowthBookUrl( + url: string | null | undefined +): { apiHost: string; clientKey: string } | null { + if (!url) return null; + try { + const u = new URL(url); + // Expect: /api/features/ (with optional trailing slash) + const match = u.pathname.match(/^\/api\/features\/([^/]+)\/?$/); + if (!match) return null; + const clientKey = match[1]; + const apiHost = `${u.protocol}//${u.host}`; + if (!clientKey || !apiHost) return null; + return { apiHost, clientKey }; + } catch { + return null; + } +} + declare global { interface Window { dataLayer: [Record]; @@ -53,9 +68,14 @@ const GrowthBookWrapper = ({ user, userFetchState }: GrowthBookWrapper) => { + const parsedUrl = parseGrowthBookUrl(growthbookUri); const growthbook = useMemo( () => new GrowthBook({ + ...(parsedUrl && { + apiHost: parsedUrl.apiHost, + clientKey: parsedUrl.clientKey + }), trackingCallback: (experiment, result) => { callGA({ event: 'experiment_viewed', @@ -66,25 +86,33 @@ const GrowthBookWrapper = ({ } }), - [] + [parsedUrl] ); useEffect(() => { - async function setGrowthBookFeatures() { + void growthbook + .init({ timeout: 1000 }) + .then(res => { + if (!res || !res.success) { + console.warn('GrowthBook initialization failed.', { + source: res?.source, + error: res?.error + }); + void growthbook.setPayload({ features: defaultGrowthBookFeatures }); + return; + } + }) + .catch(error => { + console.error('Error initializing GrowthBook:', error); + void growthbook.setPayload({ features: defaultGrowthBookFeatures }); + }); + }, [growthbook]); + + useEffect(() => { + function setGrowthBookFeatures() { if (!growthbookUri) { // Defaults are added to facilitate testing, and avoid passing the related env - growthbook.setFeatures(defaultGrowthBookFeatures); - } else { - try { - const res = await fetch(growthbookUri); - const data = (await res.json()) as { - features: Record; - }; - growthbook.setFeatures(data.features); - } catch (e) { - // TODO: report to sentry when it's enabled - console.error(e); - } + void growthbook.setPayload({ features: defaultGrowthBookFeatures }); } } @@ -111,7 +139,7 @@ const GrowthBookWrapper = ({ signedIn: true }; } - growthbook.setAttributes(userAttributes); + void growthbook.setAttributes(userAttributes); } }, [user, userFetchState, growthbook]); diff --git a/client/src/pages/index.tsx b/client/src/pages/index.tsx index f3d4f8f56ef..6399ca48f11 100644 --- a/client/src/pages/index.tsx +++ b/client/src/pages/index.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import { useGrowthBook } from '@growthbook/growthbook-react'; import SEO from '../components/seo'; +import { Loader } from '../components/helpers'; import LandingTopB from '../components/landing/components/landing-top-b'; import LandingTop from '../components/landing/components/landing-top'; import Testimonials from '../components/landing/components/testimonials'; @@ -14,7 +16,11 @@ type LandingProps = { }; const Landing = ({ showLandingPageRedesign }: LandingProps) => ( -
+
{showLandingPageRedesign ? : } @@ -25,13 +31,29 @@ const Landing = ({ showLandingPageRedesign }: LandingProps) => ( function IndexPage(): JSX.Element { const { t } = useTranslation(); - - return ( - <> - - - - ); + const growthbook = useGrowthBook(); + if (growthbook && growthbook.ready) { + console.error('GrowthBook Ready', growthbook); + const showLandingPageRedesign = growthbook.getFeatureValue( + 'landing-top-skill-focused', + false + ); + growthbook.getFeatureValue('landing-aa-test', false); + return ( + <> + + + + ); + } else { + console.error('GrowthBook not ready yet', growthbook); + return ( + <> + + + + ); + } } IndexPage.displayName = 'IndexPage'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90f8ea84286..0b2dbb641bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -346,8 +346,8 @@ importers: specifier: 1.3.9 version: 1.3.9(react-dom@17.0.2(react@17.0.2))(react@17.0.2) '@growthbook/growthbook-react': - specifier: 0.20.0 - version: 0.20.0(react@17.0.2) + specifier: 1.6.0 + version: 1.6.0(react@17.0.2) '@headlessui/react': specifier: 1.7.19 version: 1.7.19(react-dom@17.0.2(react@17.0.2))(react@17.0.2) @@ -3044,20 +3044,20 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - '@growthbook/growthbook-react@0.20.0': - resolution: {integrity: sha512-mFlp0z6oZv71uRcze7QMkr5xRqoWAFxXahY9DFa/mwHwLS1giqC7wE5DQUKz2ZaC8G3YZ7ORpXB9bOc9ucILZw==} + '@growthbook/growthbook-react@1.6.0': + resolution: {integrity: sha512-B1zSLfjRKfDlXNXMCvDLBGRAEslNDu2acUgCtHLvtGm/ZeiXtT4TuSG6dy9U2XcXczMFHoDtDln3skGb6mbo7g==} engines: {node: '>=10'} peerDependencies: - react: ^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0 - - '@growthbook/growthbook@0.30.0': - resolution: {integrity: sha512-ennMHKZIhAZokHHcnA9/tOTLFdBB4DQR0WHT8Lf1pD80jWVv8JBCAzsV1jzSjYUQhb8R8pLR2Kho0IfgjzIZGQ==} - engines: {node: '>=10'} + react: ^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 '@growthbook/growthbook@1.3.1': resolution: {integrity: sha512-ewtwq6+86rRKwcYUXEmBVR1JuiEIYZhxow/Z52qyAxJwEHdXmpS4Yk8sVeVD9bphCwE2r0zuifxFkBxmnIL4Mg==} engines: {node: '>=10'} + '@growthbook/growthbook@1.6.0': + resolution: {integrity: sha512-0aMB8j1lVLob4uXpFpTmylMEzlMsaaeFZiXgtaveCQwF+vwdKZG+aCOX/XQyLEvdsU1x3M4s/kZSiFM1lRMgQw==} + engines: {node: '>=10'} + '@hapi/address@2.1.4': resolution: {integrity: sha512-QD1PhQk+s31P1ixsX0H0Suoupp3VMXzIVMSwobR3F3MSUO2YCV0B7xqLcUw/Bh8yuvd3LhpyqLQWTNcRmp6IdQ==} deprecated: Moved to 'npm install @sideway/address' @@ -17603,16 +17603,16 @@ snapshots: dependencies: graphql: 15.8.0 - '@growthbook/growthbook-react@0.20.0(react@17.0.2)': + '@growthbook/growthbook-react@1.6.0(react@17.0.2)': dependencies: - '@growthbook/growthbook': 0.30.0 + '@growthbook/growthbook': 1.6.0 react: 17.0.2 - '@growthbook/growthbook@0.30.0': + '@growthbook/growthbook@1.3.1': dependencies: dom-mutator: 0.6.0 - '@growthbook/growthbook@1.3.1': + '@growthbook/growthbook@1.6.0': dependencies: dom-mutator: 0.6.0 @@ -24368,7 +24368,7 @@ snapshots: '@babel/plugin-proposal-numeric-separator': 7.18.6(@babel/core@7.23.7) '@babel/plugin-proposal-optional-chaining': 7.17.12(@babel/core@7.23.7) '@babel/preset-typescript': 7.23.3(@babel/core@7.23.7) - '@babel/runtime': 7.27.3 + '@babel/runtime': 7.23.9 babel-plugin-remove-graphql-queries: 3.15.0(@babel/core@7.23.7)(gatsby@3.15.0(@types/node@20.12.8)(babel-eslint@10.1.0(eslint@7.32.0))(eslint-import-resolver-typescript@3.10.1)(eslint-plugin-testing-library@3.9.0(eslint@7.32.0)(typescript@5.2.2))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(typescript@5.2.2)) gatsby: 3.15.0(@types/node@20.12.8)(babel-eslint@10.1.0(eslint@7.32.0))(eslint-import-resolver-typescript@3.10.1)(eslint-plugin-testing-library@3.9.0(eslint@7.32.0)(typescript@5.2.2))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(typescript@5.2.2) transitivePeerDependencies: