Skip to content

Commit

Permalink
Rerender optimisations (#427)
Browse files Browse the repository at this point in the history
* perf: Stablise props that change in expensive components

Most of the work here is memoising props to expensive components like
`FlatList`, and `StoryView`, and also using `React.memo` on components
to avoid rendering them at all in circumstances that don't involve them.

* perf: Apply state colocation principles and avoid prop drilling

In particular the story context state is maintained near the root of
the tree, any changes to this state end up re-rendering a large number
of components, such as the story list. By reading the state closer to
where it needs to be used, it is possible to avoid prop
drilling-inflicted rerenders.

I opted for Jotai here, since its a relatively small and well tested
and maintained state management library, that imposes basically
nothing on an application's existing structure.
---------

Co-authored-by: Jonathan Jacobs <[email protected]>
Co-authored-by: Daniel Williams <[email protected]>
  • Loading branch information
3 people authored Feb 18, 2023
1 parent 9b07b0f commit 3ec4216
Show file tree
Hide file tree
Showing 8 changed files with 135 additions and 71 deletions.
1 change: 1 addition & 0 deletions app/react-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"commander": "^8.2.0",
"emotion-theming": "^10.0.19",
"glob": "^7.1.7",
"jotai": "^2.0.2",
"prettier": "^2.4.1",
"react-native-swipe-gestures": "^1.0.5",
"util": "^0.12.4"
Expand Down
43 changes: 43 additions & 0 deletions app/react-native/src/hooks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useMemo } from 'react';
import type { StoryContext } from '@storybook/csf';
import { atom, useAtomValue, useSetAtom } from 'jotai';

import type { ReactNativeFramework } from './types/types-6.0';

const storyContextAtom = atom(null as (StoryContext<ReactNativeFramework> | null));

/**
* Hook that returns a function to set the current story context.
*/
export function useSetStoryContext() {
return useSetAtom(storyContextAtom);
}

/**
* Hook to read the current story context.
*/
export function useStoryContext() {
return useAtomValue(storyContextAtom);
}

/**
* Hook that reads the value of a specific story context parameter.
*/
export function useStoryContextParam<T = any>(name: string, defaultValue?: T): T {
const paramAtom = useMemo(() => atom(get => get(storyContextAtom)?.parameters?.[name]), [name]);
return useAtomValue(paramAtom) ?? defaultValue;
}

/**
* Hook that indicates if `storyId` is the currently selected story.
*/
export function useIsStorySelected(storyId: string) {
return useAtomValue(useMemo(() => atom(get => get(storyContextAtom)?.id === storyId), [storyId]));
}

/**
* Hook that indicates if `title` is the currently selected story section.
*/
export function useIsStorySectionSelected(title: string) {
return useAtomValue(useMemo(() => atom(get => get(storyContextAtom)?.title === title), [title]));
}
10 changes: 6 additions & 4 deletions app/react-native/src/preview/View.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React, { useEffect, useState, useReducer } from 'react';
import React, { useEffect, useReducer } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { StoryIndex, SelectionSpecifier } from '@storybook/store';
import { StoryContext, toId } from '@storybook/csf';
import { addons } from '@storybook/addons';
import { ThemeProvider } from 'emotion-theming';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { useSetStoryContext } from '../hooks';
import OnDeviceUI from './components/OnDeviceUI';
import { theme } from './components/Shared/theme';
import type { ReactNativeFramework } from '../types/types-6.0';
Expand Down Expand Up @@ -143,7 +144,7 @@ export class View {

const appliedTheme = { ...theme, ...params.theme };
return () => {
const [context, setContext] = useState<StoryContext<ReactNativeFramework>>();
const setContext = useSetStoryContext();
const [, forceUpdate] = useReducer((x) => x + 1, 0);
useEffect(() => {
self._setStory = (newStory: StoryContext<ReactNativeFramework>) => {
Expand All @@ -159,14 +160,15 @@ export class View {
self._preview.urlStore.selectionSpecifier = story;
self._preview.selectSpecifiedStory();
});

// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

if (onDeviceUI) {
return (
<SafeAreaProvider>
<ThemeProvider theme={appliedTheme}>
<OnDeviceUI
context={context}
storyIndex={self._storyIndex}
isUIHidden={params.isUIHidden}
tabOpen={params.tabOpen}
Expand All @@ -177,7 +179,7 @@ export class View {
</SafeAreaProvider>
);
} else {
return <StoryView context={context} />;
return <StoryView />;
}
};
};
Expand Down
19 changes: 8 additions & 11 deletions app/react-native/src/preview/components/OnDeviceUI/OnDeviceUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
StyleSheet,
View,
} from 'react-native';
import { useStoryContextParam } from '../../../hooks';
import StoryListView from '../StoryListView';
import StoryView from '../StoryView';
import AbsolutePositionedKeyboardAwareView, {
Expand All @@ -32,8 +33,6 @@ import { PREVIEW, ADDONS } from './navigation/constants';
import Panel from './Panel';
import { useWindowDimensions } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { StoryContext } from '@storybook/csf';
import { ReactNativeFramework } from 'src/types/types-6.0';

const ANIMATION_DURATION = 300;
const IS_IOS = Platform.OS === 'ios';
Expand All @@ -43,7 +42,6 @@ export const IS_EXPO = getExpoRoot() !== undefined;
const IS_ANDROID = Platform.OS === 'android';
const BREAKPOINT = 1024;
interface OnDeviceUIProps {
context: StoryContext<ReactNativeFramework>;
storyIndex: StoryIndex;
url?: string;
tabOpen?: number;
Expand Down Expand Up @@ -75,7 +73,6 @@ const styles = StyleSheet.create({
});

const OnDeviceUI = ({
context,
storyIndex,
isUIHidden,
shouldDisableKeyboardAvoidingView,
Expand All @@ -84,16 +81,16 @@ const OnDeviceUI = ({
}: OnDeviceUIProps) => {
const [tabOpen, setTabOpen] = useState(initialTabOpen || PREVIEW);
const [slideBetweenAnimation, setSlideBetweenAnimation] = useState(false);
const [previewDimensions, setPreviewDimensions] = useState<PreviewDimens>({
const [previewDimensions, setPreviewDimensions] = useState<PreviewDimens>(() => ({
width: Dimensions.get('window').width,
height: Dimensions.get('window').height,
});
}));
const animatedValue = useRef(new Animated.Value(tabOpen));
const wide = useWindowDimensions().width >= BREAKPOINT;
const insets = useSafeAreaInsets();
const [isUIVisible, setIsUIVisible] = useState(isUIHidden !== undefined ? !isUIHidden : true);

const handleToggleTab = (newTabOpen: number) => {
const handleToggleTab = React.useCallback((newTabOpen: number) => {
if (newTabOpen === tabOpen) {
return;
}
Expand All @@ -110,9 +107,9 @@ const OnDeviceUI = ({
if (newTabOpen === PREVIEW) {
Keyboard.dismiss();
}
};
}, [tabOpen]);

const noSafeArea = context?.parameters?.noSafeArea ?? false;
const noSafeArea = useStoryContextParam<boolean>('noSafeArea', false);
const previewWrapperStyles = [
flex,
getPreviewPosition({
Expand Down Expand Up @@ -146,7 +143,7 @@ const OnDeviceUI = ({
<Animated.View style={previewStyles}>
<Preview disabled={tabOpen === PREVIEW}>
<WrapperView style={[flex, wrapperMargin]}>
<StoryView context={context} />
<StoryView />
</WrapperView>
</Preview>
{tabOpen !== PREVIEW ? (
Expand All @@ -164,7 +161,7 @@ const OnDeviceUI = ({
wide
)}
>
<StoryListView storyIndex={storyIndex} selectedStoryContext={context} />
<StoryListView storyIndex={storyIndex} />
</Panel>

<Panel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ const AbsolutePositionedKeyboardAwareView = ({
children,
}: Props) => {
const onLayoutHandler = ({ nativeEvent }: LayoutChangeEvent) => {
onLayout({
height: nativeEvent.layout.height,
width: nativeEvent.layout.width,
});
const {height: layoutHeight, width: layoutWidth} = nativeEvent.layout;
if (layoutHeight !== height || layoutWidth !== width) {
onLayout({
height: layoutHeight,
width: layoutWidth,
});
}
};

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ import styled from '@emotion/native';
import { addons, StoryKind } from '@storybook/addons';
import { StoryIndex, StoryIndexEntry } from '@storybook/client-api';
import Events from '@storybook/core-events';
import { StoryContext } from '@storybook/csf';
import React, { useMemo, useState } from 'react';
import { SectionList, StyleSheet, View } from 'react-native';
import {
SectionList,
SectionListRenderItem,
StyleSheet,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { GridIcon, StoryIcon } from '../Shared/icons';
import { Header, Name } from '../Shared/text';
import { ReactNativeFramework } from 'src/types/types-6.0';
import { useIsStorySelected, useIsStorySectionSelected } from '../../../hooks';

const SearchBar = styled.TextInput(
{
Expand Down Expand Up @@ -54,20 +58,22 @@ const StoryListContainer = styled.View(({ theme }) => ({

interface SectionProps {
title: string;
selected: boolean;
}

const SectionHeader = React.memo(({ title, selected }: SectionProps) => (
<HeaderContainer key={title}>
<GridIcon />
<Header selected={selected}>{title}</Header>
</HeaderContainer>
));
const SectionHeader = React.memo(({ title }: SectionProps) => {
const selected = useIsStorySectionSelected(title);
return (
<HeaderContainer key={title}>
<GridIcon />
<Header selected={selected}>{title}</Header>
</HeaderContainer>
);
});

interface ListItemProps {
storyId: string;
title: string;
kind: string;
selected: boolean;
onPress: () => void;
}

Expand All @@ -82,25 +88,27 @@ const ItemTouchable = styled.TouchableOpacity<{ selected: boolean }>(
);

const ListItem = React.memo(
({ kind, title, selected, onPress }: ListItemProps) => (
<ItemTouchable
key={title}
onPress={onPress}
activeOpacity={0.8}
testID={`Storybook.ListItem.${kind}.${title}`}
accessibilityLabel={`Storybook.ListItem.${title}`}
selected={selected}
>
<StoryIcon selected={selected} />
<Name selected={selected}>{title}</Name>
</ItemTouchable>
),
(prevProps, nextProps) => prevProps.selected === nextProps.selected
({ storyId, kind, title, onPress }: ListItemProps) => {
const selected = useIsStorySelected(storyId);
return (
<ItemTouchable
key={title}
onPress={onPress}
activeOpacity={0.8}
testID={`Storybook.ListItem.${kind}.${title}`}
accessibilityLabel={`Storybook.ListItem.${title}`}
selected={selected}
>
<StoryIcon selected={selected} />
<Name selected={selected}>{title}</Name>
</ItemTouchable>
);
},
(prevProps, nextProps) => prevProps.storyId === nextProps.storyId
);

interface Props {
storyIndex: StoryIndex;
selectedStoryContext: StoryContext<ReactNativeFramework>;
}

interface DataItem {
Expand Down Expand Up @@ -130,7 +138,11 @@ const styles = StyleSheet.create({

const tabBarHeight = 40;

const StoryListView = ({ selectedStoryContext, storyIndex }: Props) => {
function keyExtractor(item: any, index) {
return item.id + index;
}

const StoryListView = ({ storyIndex }: Props) => {
const insets = useSafeAreaInsets();
const originalData = useMemo(() => getStories(storyIndex), [storyIndex]);
const [data, setData] = useState<DataItem[]>(originalData);
Expand Down Expand Up @@ -167,7 +179,24 @@ const StoryListView = ({ selectedStoryContext, storyIndex }: Props) => {
channel.emit(Events.SET_CURRENT_STORY, { storyId });
};

const safeStyle = { flex: 1, marginTop: insets.top, paddingBottom: insets.bottom + tabBarHeight };
const safeStyle = React.useMemo(() => {
return { flex: 1, marginTop: insets.top, paddingBottom: insets.bottom + tabBarHeight };
}, [insets]);

const renderItem: SectionListRenderItem<StoryIndexEntry, DataItem> = React.useCallback(({item}) => {
return (
<ListItem
storyId={item.id}
title={item.name}
kind={item.title}
onPress={() => changeStory(item.id)}
/>
);
}, []);

const renderSectionHeader = React.useCallback(({ section: { title } }) => (
<SectionHeader title={title} />
), []);

return (
<StoryListContainer>
Expand All @@ -184,21 +213,9 @@ const StoryListView = ({ selectedStoryContext, storyIndex }: Props) => {
// contentInset={{ bottom: insets.bottom, top: 0 }}
style={styles.sectionList}
testID="Storybook.ListView"
renderItem={({ item }) => (
<ListItem
title={item.name}
kind={item.title}
selected={selectedStoryContext && item.id === selectedStoryContext.id}
onPress={() => changeStory(item.id)}
/>
)}
renderSectionHeader={({ section: { title } }) => (
<SectionHeader
title={title}
selected={selectedStoryContext && title === selectedStoryContext.title}
/>
)}
keyExtractor={(item, index) => item.id + index}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
keyExtractor={keyExtractor}
sections={data}
stickySectionHeadersEnabled={false}
/>
Expand All @@ -207,4 +224,4 @@ const StoryListView = ({ selectedStoryContext, storyIndex }: Props) => {
);
};

export default StoryListView;
export default React.memo(StoryListView);
12 changes: 4 additions & 8 deletions app/react-native/src/preview/components/StoryView/StoryView.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import React from 'react';

import { Text, View, StyleSheet } from 'react-native';
import type { StoryContext } from '@storybook/csf';
import { ReactNativeFramework } from 'src/types/types-6.0';

interface Props {
context?: StoryContext<ReactNativeFramework>;
}
import { useStoryContext } from '../../../hooks';

const styles = StyleSheet.create({
container: { flex: 1 },
Expand All @@ -19,7 +14,8 @@ const styles = StyleSheet.create({
},
});

const StoryView = ({ context }: Props) => {
const StoryView = () => {
const context = useStoryContext();
const id = context?.id;

if (context && context.unboundStoryFn) {
Expand All @@ -39,4 +35,4 @@ const StoryView = ({ context }: Props) => {
);
};

export default StoryView;
export default React.memo(StoryView);
Loading

0 comments on commit 3ec4216

Please sign in to comment.