-
Notifications
You must be signed in to change notification settings - Fork 3.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[NV-3735]: Improve launch darkly #5517
Changes from 15 commits
53647b3
4200cd7
1066caf
0e7da6a
9389165
952ec8f
e992ffd
a98073d
961a334
00bf34e
a34a72b
953339e
037d946
59b0edb
c1cc6c9
30da614
43273d9
2ab55ab
6ed5769
81ad742
1a3ad79
9bf9e28
be3c6b9
c511889
f155b3d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,14 @@ | ||
import { CONTEXT_PATH, LAUNCH_DARKLY_CLIENT_SIDE_ID, SegmentProvider } from '@novu/shared-web'; | ||
import { Loader } from '@mantine/core'; | ||
import { colors } from '@novu/design-system'; | ||
import { CONTEXT_PATH, LaunchDarklyProvider, SegmentProvider } from '@novu/shared-web'; | ||
import * as Sentry from '@sentry/react'; | ||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; | ||
import { withLDProvider } from 'launchdarkly-react-client-sdk'; | ||
import { PropsWithChildren } from 'react'; | ||
import { HelmetProvider } from 'react-helmet-async'; | ||
import { BrowserRouter } from 'react-router-dom'; | ||
import { api } from './api/api.client'; | ||
import { AuthProvider } from './components/providers/AuthProvider'; | ||
import { css } from './styled-system/css'; | ||
|
||
const defaultQueryFn = async ({ queryKey }: { queryKey: string }) => { | ||
const response = await api.get(`${queryKey[0]}`); | ||
|
@@ -22,28 +24,41 @@ const queryClient = new QueryClient({ | |
}, | ||
}); | ||
|
||
/** Full-page loader that uses color-preferences for background */ | ||
const fallbackDisplay = ( | ||
<div | ||
className={css({ | ||
h: '100dvh', | ||
w: '100dvw', | ||
display: 'grid', | ||
placeItems: 'center', | ||
bg: 'surface.page', | ||
// Root element may not have loaded so rely on OS | ||
_osDark: { bg: 'legacy.BGDark' }, | ||
_osLight: { bg: 'legacy.BGLight' }, | ||
})} | ||
> | ||
<Loader size={64} variant="bars" color={colors.gradientMiddle} /> | ||
</div> | ||
); | ||
|
||
/** | ||
* Centralized Provider hierarchy. | ||
*/ | ||
const Providers: React.FC<PropsWithChildren<{}>> = ({ children }) => { | ||
return ( | ||
<SegmentProvider> | ||
<HelmetProvider> | ||
<BrowserRouter basename={CONTEXT_PATH}> | ||
<QueryClientProvider client={queryClient}> | ||
<AuthProvider>{children}</AuthProvider> | ||
</QueryClientProvider> | ||
</BrowserRouter> | ||
</HelmetProvider> | ||
<QueryClientProvider client={queryClient}> | ||
<AuthProvider> | ||
<LaunchDarklyProvider fallbackDisplay={fallbackDisplay}> | ||
<HelmetProvider> | ||
<BrowserRouter basename={CONTEXT_PATH}>{children}</BrowserRouter> | ||
</HelmetProvider> | ||
</LaunchDarklyProvider> | ||
</AuthProvider> | ||
</QueryClientProvider> | ||
</SegmentProvider> | ||
); | ||
}; | ||
|
||
export default Sentry.withProfiler( | ||
withLDProvider({ | ||
clientSideID: LAUNCH_DARKLY_CLIENT_SIDE_ID, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗒 note (non-blocking): Moved LD provider to its own file in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❓ question: why move to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Most of our other similar providers are there, and many of the related behaviors / imports (i.e. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤓 nitpick (non-blocking): I suggest keeping it closer to the source of the web app unless we are sure it would be reused. Otherwise, we might end up with a landfill. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree with @LetItRock. I feel that we might be overusing shared packages on some occasions. Other than that, great choice to split it into a separate component. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good, thanks for the feedback! Updated |
||
reactOptions: { | ||
useCamelCaseFlagKeys: false, | ||
}, | ||
})(Providers) | ||
); | ||
export default Sentry.withProfiler(Providers); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,19 +1,19 @@ | ||
import { FeatureFlagsKeysEnum, IOrganizationEntity, prepareBooleanStringFeatureFlag } from '@novu/shared'; | ||
import { useFlags } from 'launchdarkly-react-client-sdk'; | ||
import { useLDClient } from 'launchdarkly-react-client-sdk'; | ||
import { useFlags, useLDClient } from 'launchdarkly-react-client-sdk'; | ||
import { useEffect } from 'react'; | ||
import { checkShouldUseLaunchDarkly } from '../utils'; | ||
|
||
import { FEATURE_FLAGS } from '../config'; | ||
|
||
export const useFeatureFlags = (organization: IOrganizationEntity) => { | ||
export const useFeatureFlags = (organization?: IOrganizationEntity) => { | ||
const ldClient = useLDClient(); | ||
|
||
useEffect(() => { | ||
if (!organization?._id) { | ||
if (!checkShouldUseLaunchDarkly() || !organization?._id || !ldClient) { | ||
return; | ||
} | ||
|
||
ldClient?.identify({ | ||
ldClient.identify({ | ||
kind: 'organization', | ||
key: organization._id, | ||
name: organization.name, | ||
|
@@ -24,10 +24,12 @@ export const useFeatureFlags = (organization: IOrganizationEntity) => { | |
}; | ||
|
||
export const useFeatureFlag = (key: FeatureFlagsKeysEnum): boolean => { | ||
const { [key]: featureFlag } = useFlags(); | ||
/** We knowingly break the rule of hooks here to avoid making any LaunchDarkly calls when it is disabled */ | ||
// eslint-disable-next-line | ||
const flagValue = checkShouldUseLaunchDarkly() ? useFlags()[key] : undefined; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗒 note (non-blocking): Breaks the rule of hooks, but should be okay since it's done via a static check. |
||
const fallbackValue = false; | ||
const value = FEATURE_FLAGS[key]; | ||
const defaultValue = prepareBooleanStringFeatureFlag(value, fallbackValue); | ||
|
||
return featureFlag ?? defaultValue; | ||
return flagValue ?? defaultValue; | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
import { IOrganizationEntity } from '@novu/shared'; | ||
import { asyncWithLDProvider } from 'launchdarkly-react-client-sdk'; | ||
import { PropsWithChildren, ReactNode, useEffect, useMemo, useRef, useState } from 'react'; | ||
import { LAUNCH_DARKLY_CLIENT_SIDE_ID } from '../config'; | ||
import { useFeatureFlags } from '../hooks'; | ||
import { useAuthContext } from './AuthProvider'; | ||
import { selectShouldShowLaunchDarklyFallback, selectShouldInitializeLaunchDarkly } from '../utils/auth-selectors'; | ||
|
||
/** A provider with children required */ | ||
type GenericLDProvider = Awaited<ReturnType<typeof asyncWithLDProvider>>; | ||
|
||
/** Simply renders the children */ | ||
const DEFAULT_GENERIC_PROVIDER: GenericLDProvider = ({ children }) => <>{children}</>; | ||
|
||
export interface ILaunchDarklyProviderProps { | ||
/** Renders when LaunchDarkly is enabled and is awaiting initialization */ | ||
fallbackDisplay: ReactNode; | ||
} | ||
|
||
/** | ||
* Async provider for feature flags. | ||
* | ||
* @requires AuthProvider must be wrapped in the AuthProvider. | ||
*/ | ||
export const LaunchDarklyProvider: React.FC<PropsWithChildren<ILaunchDarklyProviderProps>> = ({ | ||
children, | ||
fallbackDisplay, | ||
}) => { | ||
const LDProvider = useRef<GenericLDProvider>(DEFAULT_GENERIC_PROVIDER); | ||
const [isLDReady, setIsLDReady] = useState<boolean>(false); | ||
antonjoel82 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
const authContext = useAuthContext(); | ||
if (!authContext) { | ||
throw new Error('LaunchDarklyProvider must be used within <AuthProvider>!'); | ||
} | ||
const { currentOrganization } = authContext; | ||
|
||
const shouldInitializeLd = useMemo(() => selectShouldInitializeLaunchDarkly(authContext), [authContext]); | ||
|
||
useEffect(() => { | ||
if (!shouldInitializeLd) { | ||
return; | ||
} | ||
|
||
const fetchLDProvider = async () => { | ||
try { | ||
LDProvider.current = await asyncWithLDProvider({ | ||
clientSideID: LAUNCH_DARKLY_CLIENT_SIDE_ID, | ||
reactOptions: { | ||
useCamelCaseFlagKeys: false, | ||
}, | ||
// determine which context to use based on if an organization is available | ||
context: currentOrganization | ||
? { | ||
kind: 'organization', | ||
key: currentOrganization._id, | ||
name: currentOrganization.name, | ||
} | ||
: { | ||
/** | ||
* When user is not authenticated, assigns an id to them to ensure consistent results. | ||
* https://docs.launchdarkly.com/sdk/features/anonymous#javascript | ||
*/ | ||
kind: 'user', | ||
anonymous: true, | ||
}, | ||
}); | ||
} catch (err: unknown) { | ||
// FIXME: what should we do here since we don't have logging? | ||
antonjoel82 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} finally { | ||
setIsLDReady(true); | ||
} | ||
}; | ||
|
||
fetchLDProvider(); | ||
}, [setIsLDReady, shouldInitializeLd, currentOrganization]); | ||
|
||
/** | ||
* For self-hosted, LD will not be enabled, so do not block initialization. | ||
* Must not show the fallback if the user isn't logged-in to avoid issues with un-authenticated routes (i.e. login). | ||
*/ | ||
if (selectShouldShowLaunchDarklyFallback(authContext, isLDReady)) { | ||
return <>{fallbackDisplay}</>; | ||
} | ||
|
||
return ( | ||
<LDProvider.current> | ||
<LaunchDarklyClientWrapper org={currentOrganization}>{children}</LaunchDarklyClientWrapper> | ||
</LDProvider.current> | ||
); | ||
}; | ||
|
||
/** | ||
* Refreshes feature flags on org change using the LaunchDarkly client from the provider. | ||
*/ | ||
function LaunchDarklyClientWrapper({ children, org }: PropsWithChildren<{ org?: IOrganizationEntity }>) { | ||
useFeatureFlags(org); | ||
|
||
return <>{children}</>; | ||
} | ||
antonjoel82 marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,3 @@ | ||
export * from './SegmentProvider'; | ||
export * from './AuthProvider'; | ||
export * from './LaunchDarklyProvider'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export * from './selectHasUserCompletedSignUp'; | ||
export * from './selectShouldShowLaunchDarklyFallback'; | ||
export * from './selectShouldInitializeLaunchDarkly'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import { UserContext } from '../../providers'; | ||
|
||
/** | ||
* Determine if a user is fully-registered; if not, they're still in onboarding. | ||
*/ | ||
export const selectHasUserCompletedSignUp = (userCtx: UserContext): boolean => { | ||
if (!userCtx) { | ||
return false; | ||
} | ||
|
||
// User has completed registration if they have an associated orgId. | ||
return !!userCtx.jwtPayload?.organizationId; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗒 note (non-blocking): This does not seem like the best way, but it is the most robust and semantic way I found thus far |
||
}; |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wanted to add tests to these files, but we do not have testing set up yet in https://linear.app/novu/issue/NV-3773/setup-tests-in-shared-web-and-implement-tests-for-launchdarkly |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
import { selectHasUserCompletedSignUp } from './selectHasUserCompletedSignUp'; | ||
import { checkShouldUseLaunchDarkly } from '../checkShouldUseLaunchDarkly'; | ||
import { UserContext } from '../../providers/AuthProvider'; | ||
|
||
/** Determine if LaunchDarkly should be initialized based on the current auth context */ | ||
export function selectShouldInitializeLaunchDarkly(userCtx: UserContext): boolean { | ||
const { isLoggedIn, currentOrganization } = userCtx; | ||
// don't show fallback if LaunchDarkly isn't enabled | ||
if (!checkShouldUseLaunchDarkly()) { | ||
return false; | ||
} | ||
|
||
// enable feature flags for unauthenticated areas of the app | ||
if (!isLoggedIn) { | ||
antonjoel82 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return true; | ||
} | ||
|
||
// allow LD to load when the user is created but still in onboarding | ||
if (!selectHasUserCompletedSignUp(userCtx)) { | ||
antonjoel82 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return true; | ||
} | ||
|
||
// if a user is fully on-boarded, but no organization has loaded, we must wait for the organization to initialize the client. | ||
return !!currentOrganization; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗒 note (non-blocking): the
/auth/application
is not a protected route 😨