Skip to content

Commit

Permalink
refactor: memoize getStateFromPath and getPathFromState (react-naviga…
Browse files Browse the repository at this point in the history
…tion#12120)

**_Problem_**

When `getStateFromPath` receives big _linking config_
([example](https://github.com/Expensify/App/blob/071f11ce1012db7d07eb750d74f58b552eba6144/src/libs/Navigation/linkingConfig/config.ts#L1152))
as an option parameter, it tends to slow down quite drastically.

**_Investigations_** 

The config-related data is created every time`getStateFromPath` is
called, resulting in noticeable overhead.

Often times, configs provided to `getStateFromPath` are going to be
static (e.g. initialised once at the app setup and remained untouched
throughout its lifetime) and the data derived from it could also be
static.

**_Solution_**

PR tries to improve `getStateFromPath` helper performance by extracting
and caching calculations related to last linking config provided (due to
its static nature, keeping reference to the latest value seems an OK
heuristic).

Here is an example of potential gains for Exfy app and its config:
Expensify/App#48150

_**Test plan**_

No manual testing required.

_**PR introduces**_

- [x] `getStateFromPath` config data caching
- [x] `setTimeout` typings fixes
  • Loading branch information
kacper-mikolajczak authored Aug 30, 2024
1 parent 35ff87b commit 26463c8
Show file tree
Hide file tree
Showing 7 changed files with 175 additions and 96 deletions.
15 changes: 12 additions & 3 deletions packages/core/src/getPathFromState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ const getActiveRoute = (state: State): { name: string; params?: object } => {
return route;
};

let cachedNormalizedConfigs: [
PathConfigMap<{}> | undefined,
Record<string, ConfigItem>,
] = [undefined, {}];

/**
* Utility to serialize a navigation state object to a path string.
*
Expand Down Expand Up @@ -81,9 +86,13 @@ export function getPathFromState<ParamList extends {}>(
}

// Create a normalized configs object which will be easier to use
const configs: Record<string, ConfigItem> = options?.screens
? createNormalizedConfigs(options?.screens)
: {};
if (cachedNormalizedConfigs[0] !== options?.screens) {
cachedNormalizedConfigs = [
options?.screens,
options?.screens ? createNormalizedConfigs(options.screens) : {},
];
}
const configs: Record<string, ConfigItem> = cachedNormalizedConfigs[1];

let path = '/';
let current: State | undefined = state;
Expand Down
184 changes: 121 additions & 63 deletions packages/core/src/getStateFromPath.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ type ParsedRoute = {
params?: Record<string, any> | undefined;
};

type ConfigResources = {
initialRoutes: InitialRouteConfig[];
configs: RouteConfig[];
configWithRegexes: RouteConfig[];
};

/**
* Utility to parse a path string to initial state object accepted by the container.
* This is useful for deep linking when we need to handle the incoming URL.
Expand All @@ -67,18 +73,8 @@ export function getStateFromPath<ParamList extends {}>(
path: string,
options?: Options<ParamList>
): ResultState | undefined {
if (options) {
validatePathConfig(options);
}

const initialRoutes: InitialRouteConfig[] = [];

if (options?.initialRouteName) {
initialRoutes.push({
initialRouteName: options.initialRouteName,
parentScreens: [],
});
}
const { initialRoutes, configs, configWithRegexes } =
getConfigResources(options);

const screens = options?.screens;

Expand Down Expand Up @@ -122,8 +118,111 @@ export function getStateFromPath<ParamList extends {}>(
return undefined;
}

if (remaining === '/') {
// We need to add special handling of empty path so navigation to empty path also works
// When handling empty path, we should only look at the root level config
const match = configs.find(
(config) =>
config.path === '' &&
config.routeNames.every(
// Make sure that none of the parent configs have a non-empty path defined
(name) => !configs.find((c) => c.screen === name)?.path
)
);

if (match) {
return createNestedStateObject(
path,
match.routeNames.map((name) => ({ name })),
initialRoutes,
configs
);
}

return undefined;
}

let result: PartialState<NavigationState> | undefined;
let current: PartialState<NavigationState> | undefined;

// We match the whole path against the regex instead of segments
// This makes sure matches such as wildcard will catch any unmatched routes, even if nested
const { routes, remainingPath } = matchAgainstConfigs(
remaining,
configWithRegexes
);

if (routes !== undefined) {
// This will always be empty if full path matched
current = createNestedStateObject(path, routes, initialRoutes, configs);
remaining = remainingPath;
result = current;
}

if (current == null || result == null) {
return undefined;
}

return result;
}

/**
* Reference to the last used config resources. This is used to avoid recomputing the config resources when the options are the same.
*/
let cachedConfigResources: [Options<{}> | undefined, ConfigResources] = [
undefined,
prepareConfigResources(),
];

function getConfigResources<ParamList extends {}>(
options: Options<ParamList> | undefined
) {
if (cachedConfigResources[0] !== options) {
cachedConfigResources = [options, prepareConfigResources(options)];
}

return cachedConfigResources[1];
}

function prepareConfigResources(options?: Options<{}>) {
if (options) {
validatePathConfig(options);
}

const initialRoutes = getInitialRoutes(options);

const configs = getNormalizedConfigs(initialRoutes, options?.screens);

checkForDuplicatedConfigs(configs);

const configWithRegexes = getConfigsWithRegexes(configs);

return {
initialRoutes,
configs,
configWithRegexes,
};
}

function getInitialRoutes(options?: Options<{}>) {
const initialRoutes: InitialRouteConfig[] = [];

if (options?.initialRouteName) {
initialRoutes.push({
initialRouteName: options.initialRouteName,
parentScreens: [],
});
}

return initialRoutes;
}

function getNormalizedConfigs(
initialRoutes: InitialRouteConfig[],
screens: PathConfigMap<object> = {}
) {
// Create a normalized configs array which will be easier to use
const configs = ([] as RouteConfig[])
return ([] as RouteConfig[])
.concat(
...Object.keys(screens).map((key) =>
createNormalizedConfigs(
Expand Down Expand Up @@ -185,7 +284,9 @@ export function getStateFromPath<ParamList extends {}>(
}
return bParts.length - aParts.length;
});
}

function checkForDuplicatedConfigs(configs: RouteConfig[]) {
// Check for duplicate patterns in the config
configs.reduce<Record<string, RouteConfig>>((acc, config) => {
if (acc[config.pattern]) {
Expand Down Expand Up @@ -214,57 +315,14 @@ export function getStateFromPath<ParamList extends {}>(
[config.pattern]: config,
});
}, {});
}

if (remaining === '/') {
// We need to add special handling of empty path so navigation to empty path also works
// When handling empty path, we should only look at the root level config
const match = configs.find(
(config) =>
config.path === '' &&
config.routeNames.every(
// Make sure that none of the parent configs have a non-empty path defined
(name) => !configs.find((c) => c.screen === name)?.path
)
);

if (match) {
return createNestedStateObject(
path,
match.routeNames.map((name) => ({ name })),
initialRoutes,
configs
);
}

return undefined;
}

let result: PartialState<NavigationState> | undefined;
let current: PartialState<NavigationState> | undefined;

// We match the whole path against the regex instead of segments
// This makes sure matches such as wildcard will catch any unmatched routes, even if nested
const { routes, remainingPath } = matchAgainstConfigs(
remaining,
configs.map((c) => ({
...c,
// Add `$` to the regex to make sure it matches till end of the path and not just beginning
regex: c.regex ? new RegExp(c.regex.source + '$') : undefined,
}))
);

if (routes !== undefined) {
// This will always be empty if full path matched
current = createNestedStateObject(path, routes, initialRoutes, configs);
remaining = remainingPath;
result = current;
}

if (current == null || result == null) {
return undefined;
}

return result;
function getConfigsWithRegexes(configs: RouteConfig[]) {
return configs.map((c) => ({
...c,
// Add `$` to the regex to make sure it matches till end of the path and not just beginning
regex: c.regex ? new RegExp(c.regex.source + '$') : undefined,
}));
}

const joinPaths = (...paths: string[]): string =>
Expand Down
2 changes: 1 addition & 1 deletion packages/elements/src/SafeAreaProviderCompat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ const SafeAreaFrameProvider = ({
height: rect.height,
});

let timeout: NodeJS.Timeout;
let timeout: ReturnType<typeof setTimeout>;

const observer = new ResizeObserver((entries) => {
const entry = entries[0];
Expand Down
2 changes: 1 addition & 1 deletion packages/native-stack/src/utils/debounce.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export function debounce<T extends (...args: any[]) => void>(
func: T,
duration: number
): T {
let timeout: NodeJS.Timeout;
let timeout: ReturnType<typeof setTimeout>;

return function (this: unknown, ...args) {
clearTimeout(timeout);
Expand Down
64 changes: 38 additions & 26 deletions packages/native/src/createStaticNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,46 @@ export function createStaticNavigation(tree: StaticNavigation<any, any, any>) {
{ linking, ...rest }: Props,
ref: React.Ref<NavigationContainerRef<ParamListBase>>
) {
const screens = React.useMemo(() => {
if (tree.config.screens) {
return createPathConfigForStaticNavigation(
tree,
{ initialRouteName: linking?.config?.initialRouteName },
linking?.enabled === 'auto'
);
const linkingConfig = React.useMemo(() => {
if (!tree.config.screens) return;

const screens = createPathConfigForStaticNavigation(
tree,
{ initialRouteName: linking?.config?.initialRouteName },
linking?.enabled === 'auto'
);

if (!screens) return;

return {
path: linking?.config?.path,
initialRouteName: linking?.config?.initialRouteName,
screens,
};
}, [
linking?.enabled,
linking?.config?.path,
linking?.config?.initialRouteName,
]);

const memoizedLinking = React.useMemo(() => {
if (!linking) {
return undefined;
}

return undefined;
}, [linking?.config, linking?.enabled]);
const enabled =
typeof linking.enabled === 'boolean'
? linking.enabled
: linkingConfig?.screens != null;

return {
...linking,
enabled,
config: linkingConfig,
};
}, [linking, linkingConfig]);

if (linking?.enabled === true && screens == null) {
if (linking?.enabled === true && linkingConfig?.screens == null) {
throw new Error(
'Linking is enabled but no linking configuration was found for the screens.\n\n' +
'To solve this:\n' +
Expand All @@ -74,22 +101,7 @@ export function createStaticNavigation(tree: StaticNavigation<any, any, any>) {
}

return (
<NavigationContainer
{...rest}
ref={ref}
linking={
linking
? {
...linking,
enabled:
typeof linking.enabled === 'boolean'
? linking.enabled
: screens != null,
config: screens ? { ...linking.config, screens } : undefined,
}
: undefined
}
>
<NavigationContainer {...rest} ref={ref} linking={memoizedLinking}>
<Component />
</NavigationContainer>
);
Expand Down
2 changes: 1 addition & 1 deletion packages/react-native-tab-view/src/SceneView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function SceneView<T extends Route>({
};

let unsubscribe: (() => void) | undefined;
let timer: NodeJS.Timeout | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;

if (lazy && isLoading) {
// If lazy mode is enabled, listen to when we enter screens
Expand Down
2 changes: 1 addition & 1 deletion packages/stack/src/utils/throttle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export function throttle<T extends (...args: any[]) => void>(
func: T,
duration: number
): T {
let timeout: NodeJS.Timeout | undefined;
let timeout: ReturnType<typeof setTimeout> | undefined;

return function (this: unknown, ...args) {
if (timeout == null) {
Expand Down

0 comments on commit 26463c8

Please sign in to comment.