feat: update growthbook and handle network errors (#61374)

Co-authored-by: ahmad abdolsaheb <ahmad.abdolsaheb@gmail.com>
This commit is contained in:
Oliver Eyton-Williams
2025-09-22 17:36:38 +02:00
committed by GitHub
parent 8d791e0718
commit 00a015cd92
7 changed files with 247 additions and 46 deletions

View File

@@ -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",

View File

@@ -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<string, string>;
vi.mock('../analytics');
vi.mock('@growthbook/growthbook-react', () => ({
useFeatureIsOn: () => false
}));
const { apiLocation } = envData as Record<string, string>;
describe('<ShowSettings />', () => {
it('renders to the DOM when user is logged in', () => {

View File

@@ -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(

View File

@@ -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) => (
<GrowthBookWrapper
{...({ user, userFetchState } as unknown as Record<string, unknown>)}
>
{children}
</GrowthBookWrapper>
);
vi.mock('react-redux', () => ({
connect: () => (Comp: React.ComponentType) => Comp
}));
vi.mock('./growth-book-redux-connector', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
)
}));
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<never> = () => Promise.resolve({ success: true });
const mockInit = vi.fn(() => currentInitImpl());
const mockSetPayload = vi.fn((_arg: Record<string, unknown>) =>
Promise.resolve()
);
const mockSetAttributes = vi.fn((_arg: Record<string, unknown>) =>
Promise.resolve()
);
export function setInitImpl(
impl: () => Promise<{ success: boolean; source?: string }> | Promise<never>
) {
currentInitImpl = impl;
}
vi.mock('@growthbook/growthbook-react', () => ({
GrowthBook: vi.fn().mockImplementation(() => ({
init: () => mockInit(),
setPayload: (arg: Record<string, unknown>) => mockSetPayload(arg),
setAttributes: (arg: Record<string, unknown>) => mockSetAttributes(arg)
})),
GrowthBookProvider: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
)
}));
function renderWrapper(initOptions: { complete: boolean }) {
return render(
<UnconnectedTestWrapper
user={{ completedChallenges: [], email: '', joinDate: '' }}
userFetchState={{
pending: false,
complete: initOptions.complete,
errored: false,
error: null
}}
>
<div data-testid='child' />
</UnconnectedTestWrapper>
);
}
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 } }
})
);
});
});

View File

@@ -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/<clientKey> (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<string, number | string>];
@@ -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<string, FeatureDefinition>;
};
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]);

View File

@@ -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) => (
<main className={`landing-page`}>
<main
id='landing-content'
data-testid='landing-content'
className={`landing-page`}
>
{showLandingPageRedesign ? <LandingTopB /> : <LandingTop />}
<Benefits />
<Testimonials />
@@ -25,13 +31,29 @@ const Landing = ({ showLandingPageRedesign }: LandingProps) => (
function IndexPage(): JSX.Element {
const { t } = useTranslation();
return (
<>
<SEO title={t('metaTags:title')} />
<Landing showLandingPageRedesign={true} />
</>
);
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 (
<>
<SEO title={t('metaTags:title')} />
<Landing showLandingPageRedesign={showLandingPageRedesign} />
</>
);
} else {
console.error('GrowthBook not ready yet', growthbook);
return (
<>
<SEO title={t('metaTags:title')} />
<Loader fullScreen={true} />
</>
);
}
}
IndexPage.displayName = 'IndexPage';

28
pnpm-lock.yaml generated
View File

@@ -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: