Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/shared/src/components/GrowthBookProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const ServerError = dynamic(
);
type GetFeatureValue = <T extends JSONValue>(
feature: Feature<T>,
) => WidenPrimitives<T>;
) => WidenPrimitives<T> | undefined;

export type FeaturesReadyContextValue = {
ready: boolean;
Expand All @@ -53,7 +53,7 @@ export const useFeaturesReadyContext = (): FeaturesReadyContextValue =>

export type GrowthBookProviderProps = {
app: BootApp;
user: BootCacheData['user'];
user?: BootCacheData['user'];
deviceId: string;
version?: string;
experimentation?: BootCacheData['exp'];
Expand All @@ -78,7 +78,7 @@ export const GrowthBookProvider = ({
method: 'POST',
body: JSON.stringify({
event_timestamp: new Date(),
user_id: user.id,
user_id: user?.id,
device_id: deviceId,
experiment_id: data.experimentId,
variation_id: data.variationId,
Expand Down Expand Up @@ -126,6 +126,9 @@ export const GrowthBookProvider = ({
variationId,
});
updateExperimentation?.({
// default values in case of experimentation missing
f: '{}',
a: [],
...experimentation,
e: [...(experimentation?.e ?? []), key],
});
Expand Down
73 changes: 62 additions & 11 deletions packages/shared/src/contexts/BootProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ import {
} from './SettingsContext';
import { storageWrapper as storage } from '../lib/storageWrapper';
import { useRefreshToken } from '../hooks/useRefreshToken';
import useDebounceFn from '../hooks/useDebounceFn';
import { NotificationsContextProvider } from './NotificationsContext';
import { BOOT_LOCAL_KEY, BOOT_QUERY_KEY } from './common';
import { GrowthBookProvider } from '../components/GrowthBookProvider';
import { useHostStatus } from '../hooks/useHostPermissionStatus';
import { checkIsExtension, isIOSNative } from '../lib/func';
import type { Feed, FeedList } from '../graphql/feed';
import { gqlClient } from '../graphql/common';
import type { ApiErrorResult } from '../graphql/common';
import { ApiError, getApiError, gqlClient } from '../graphql/common';
import { ErrorBoundary } from '../components/ErrorBoundary';
import { LogContextProvider } from './LogContext';
import { REQUEST_APP_ACCOUNT_TOKEN_MUTATION } from '../graphql/users';
Expand Down Expand Up @@ -63,14 +65,14 @@ export type BootDataProviderProps = {
export const getLocalBootData = (): BootCacheData | null => {
const local = storage.getItem(BOOT_LOCAL_KEY);
if (local) {
return JSON.parse(storage.getItem(BOOT_LOCAL_KEY)) as BootCacheData;
return JSON.parse(local) as BootCacheData;
}

return null;
};

const updateLocalBootData = (
current: Partial<BootCacheData>,
current: Partial<BootCacheData> | undefined,
boot: Partial<BootCacheData>,
) => {
const localData = { ...current, ...boot, lastModifier: 'extension' };
Expand All @@ -94,7 +96,8 @@ const updateLocalBootData = (

const getCachedOrNull = () => {
try {
return JSON.parse(storage.getItem(BOOT_LOCAL_KEY));
// catch below fallbacks falsy values
return JSON.parse(storage.getItem(BOOT_LOCAL_KEY) as string);
} catch (err) {
return null;
}
Expand All @@ -118,8 +121,7 @@ export const BootDataProvider = ({
getPage,
}: BootDataProviderProps): ReactElement => {
const queryClient = useQueryClient();
const preloadFeedsRef = useRef<PreloadFeeds>();
preloadFeedsRef.current = ({ feeds, user }) => {
const preloadFeedsFn: PreloadFeeds = ({ feeds, user }) => {
if (!feeds || !user) {
return;
}
Expand All @@ -134,8 +136,10 @@ export const BootDataProvider = ({
},
);
};
const preloadFeedsRef = useRef(preloadFeedsFn);
preloadFeedsRef.current = preloadFeedsFn;

const [initialLoad, setInitialLoad] = useState<boolean>(null);
const [initialLoad, setInitialLoad] = useState<boolean>();
const [cachedBootData, setCachedBootData] = useState<Partial<Boot>>();

useEffect(() => {
Expand All @@ -148,7 +152,7 @@ export const BootDataProvider = ({
const boot = getLocalBootData();

if (!boot) {
setCachedBootData(null);
setCachedBootData(undefined);

return;
}
Expand Down Expand Up @@ -195,7 +199,34 @@ export const BootDataProvider = ({
cachedBootData || {};

useRefreshToken(remoteData?.accessToken, refetch);
const updatedAtActive = user ? dataUpdatedAt : null;

const [debouncedRefetch] = useDebounceFn(refetch, 200, 1000 * 60);

useEffect(() => {
// subscribe to forbidden errors and in case token expired at the
// time of error refetch boot to get the new one
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
if (event.type !== 'updated' || event.action.type !== 'error') {
return;
}

const err = event.action.error as unknown as ApiErrorResult;

if (!getApiError(err, ApiError.Forbidden)) {
return;
}

const expiresIn = remoteData?.accessToken?.expiresIn;

if (expiresIn && new Date(expiresIn) < new Date()) {
debouncedRefetch();
}
});

return unsubscribe;
}, [queryClient, remoteData?.accessToken?.expiresIn, debouncedRefetch]);

const updatedAtActive = user ? dataUpdatedAt : 0;
const updateBootData = useCallback(
(updatedBootData: Partial<BootCacheData>, update = true) => {
const cachedData = getCachedOrNull() || {};
Expand All @@ -216,7 +247,7 @@ export const BootDataProvider = ({
if (cachedData?.lastModifier !== 'companion' && lastAppliedChange) {
updatedData = { ...updatedData, ...lastAppliedChange };
}
lastAppliedChangeRef.current = null;
lastAppliedChangeRef.current = undefined;
}

const updated = updateLocalBootData(cachedData, updatedData);
Expand Down Expand Up @@ -262,12 +293,32 @@ export const BootDataProvider = ({

useEffect(() => {
if (remoteData) {
setInitialLoad(initialLoad === null);
setInitialLoad(typeof initialLoad === 'undefined');
updateBootData(remoteData);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [remoteData]);

// invalidate forbidden queries when token refreshes to recover from
// any auth errors due to token being expired after inactivity
useEffect(() => {
if (!remoteData?.accessToken?.token) {
return;
}

queryClient.invalidateQueries({
predicate: (query) => {
if (query.state.status !== 'error') {
return false;
}

const err = query.state.error as unknown as ApiErrorResult;

return !!getApiError(err, ApiError.Forbidden);
},
});
}, [remoteData?.accessToken?.token, queryClient]);

useEffect(() => {
if (
isIOSNative() &&
Expand Down
4 changes: 2 additions & 2 deletions packages/shared/src/hooks/useRefreshToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import type { AccessToken } from '../lib/boot';
import useDebounceFn from './useDebounceFn';

export function useRefreshToken(
accessToken: AccessToken,
accessToken: AccessToken | undefined,
refresh: () => Promise<unknown>,
): void {
const difference = differenceInMilliseconds(
new Date(accessToken?.expiresIn),
accessToken?.expiresIn ? new Date(accessToken.expiresIn) : new Date(),
new Date(),
);
const differencePlusTwoMinutes = difference - 1000 * 60 * 2;
Expand Down
5 changes: 3 additions & 2 deletions packages/shared/src/lib/featureManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import { BriefingType } from '../graphql/posts';
export class Feature<T extends JSONValue> {
readonly id: string;

readonly defaultValue?: T;
readonly defaultValue: T;

constructor(id: string, defaultValue?: T) {
constructor(id: string, defaultValue: T) {
this.id = id;
this.defaultValue = defaultValue;
}
Expand All @@ -33,6 +33,7 @@ export const discussedFeedVersion = new Feature('discussed_feed_version', 2);
export const latestFeedVersion = new Feature('latest_feed_version', 2);
export const customFeedVersion = new Feature('custom_feed_version', 2);

// @ts-expect-error stale feature without default
export const plusTakeoverContent = new Feature<{
title: string;
description: string;
Expand Down
Loading