mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-02-18 10:01:02 -05:00
feat: update growthbook and handle network errors (#61374)
Co-authored-by: ahmad abdolsaheb <ahmad.abdolsaheb@gmail.com>
This commit is contained in:
committed by
GitHub
parent
8d791e0718
commit
00a015cd92
@@ -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",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
145
client/src/components/growth-book/growth-book-wrapper.test.tsx
Normal file
145
client/src/components/growth-book/growth-book-wrapper.test.tsx
Normal 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 } }
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
28
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user