refactor: use memoization and native NextJS router queries to reduce re-renders (#85)

Co-authored-by: Eric Fennis <eric.fennis@gmail.com>
This commit is contained in:
Frank Riccobono
2020-10-07 11:47:41 -04:00
committed by GitHub
parent dad9648e20
commit a55620d6ba
11 changed files with 199 additions and 88 deletions

3
packages/site/.babelrc Normal file
View File

@@ -0,0 +1,3 @@
{
"presets": ["next/babel"]
}

View File

@@ -0,0 +1,7 @@
module.exports = {
testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/'],
setupFilesAfterEnv: ['<rootDir>/setupTests.js'],
transform: {
'^.+\\.(js|jsx|ts|tsx)$': '<rootDir>/../../node_modules/babel-jest',
},
};

View File

@@ -7,7 +7,8 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"export": "next export -o build", "export": "next export -o build",
"deploy": "yarn build && yarn export" "deploy": "yarn build && yarn export",
"test": "jest"
}, },
"dependencies": { "dependencies": {
"@chakra-ui/core": "^0.8.0", "@chakra-ui/core": "^0.8.0",
@@ -18,15 +19,20 @@
"fuse.js": "^6.0.4", "fuse.js": "^6.0.4",
"jszip": "^3.4.0", "jszip": "^3.4.0",
"next": "^9.4.4", "next": "^9.4.4",
"query-string": "^6.13.0",
"react": "^16.13.1", "react": "^16.13.1",
"react-dom": "^16.13.1", "react-dom": "^16.13.1"
"use-query-params": "^1.1.3"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/dom": "^7.24.4",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.0.4",
"@testing-library/react-hooks": "^3.4.2",
"@types/node": "^14.0.11", "@types/node": "^14.0.11",
"@types/react": "^16.9.35", "@types/react": "^16.9.35",
"@types/react-dom": "^16.9.8", "@types/react-dom": "^16.9.8",
"babel-jest": "^26.5.2",
"jest": "^26.5.2",
"react-test-renderer": "^16.13.1",
"typescript": "^3.9.5" "typescript": "^3.9.5"
} }
} }

View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom/extend-expect";

View File

@@ -1,11 +1,17 @@
import { Box, Divider, Flex, Text, Link, Icon, useColorMode } from "@chakra-ui/core"; import { Box, Divider, Flex, Text, Link, Icon, useColorMode } from "@chakra-ui/core";
import { StringParam, useQueryParam } from "use-query-params";
import { useKeyBindings } from "../lib/key"; import { useKeyBindings } from "../lib/key";
import {useRouter} from "next/router";
const Layout = ({ children }) => { const Layout = ({ children }) => {
const [, setQuery] = useQueryParam("query", StringParam); const router = useRouter();
const { colorMode, toggleColorMode } = useColorMode(); const { colorMode, toggleColorMode } = useColorMode();
function setQuery(query){
router.push({
pathname: '/',
query: { query: query }
}).then();
}
useKeyBindings({ useKeyBindings({
Escape: { Escape: {
fn: () => setQuery(""), fn: () => setQuery(""),

View File

@@ -1,21 +1,31 @@
import Fuse from "fuse.js"; import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from "react"; import { useDebounce } from './useDebounce';
function useSearch(icons, query) { function useSearch(icons: Object, query: string | string[]) {
const fuse = new Fuse(Object.values(icons), { let iconList = useMemo(() => Object.values(icons), [icons]);
threshold: 0.2, const [results, setResults] = useState(iconList);
keys: ["name", "tags"], // query can be an array because this is a valid query string ?query=xyz&query=abc
}); const debouncedQuery = useDebounce(
typeof query === 'string' ? query.trim() : typeof query === 'undefined' ? '' : query[0].trim(),
300
);
const [results, setResults] = useState(Object.values(icons)); async function doSearch() {
if (debouncedQuery) {
const Fuse = (await import('fuse.js')).default;
const fuse = new Fuse(iconList, {
threshold: 0.2,
keys: ['name', 'tags'],
});
return fuse.search(debouncedQuery);
} else {
return iconList;
}
}
useEffect(() => { useEffect(() => {
if (query.trim()) { doSearch().then(setResults);
setResults(fuse.search(query.trim())); }, [debouncedQuery]);
} else {
setResults(Object.values(icons));
}
}, [query]);
return results; return results;
} }

View File

@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value]);
return debouncedValue;
}

View File

@@ -1,30 +1,6 @@
import { CSSReset, ThemeProvider, ColorModeProvider } from "@chakra-ui/core"; import { CSSReset, ThemeProvider, ColorModeProvider } from '@chakra-ui/core';
import { useRouter } from "next/router"; import customTheme from '../lib/theme';
import { QueryParamProvider } from "use-query-params"; import Head from 'next/head';
import customTheme from "../lib/theme";
import Head from "next/head";
const QueryProvider = ({ children }) => {
const router = useRouter();
const history = {
push: ({ search }: Location) =>
router.push({ search, pathname: router.pathname }),
replace: ({ search }: Location) =>
router.replace({ search, pathname: router.pathname }),
};
const location = {
search: router.asPath.replace(/[^?]+/u, ""),
} as Location;
return (
<QueryParamProvider history={history} location={location}>
{children}
</QueryParamProvider>
);
};
const App = ({ Component, pageProps }) => { const App = ({ Component, pageProps }) => {
return ( return (
@@ -32,14 +8,12 @@ const App = ({ Component, pageProps }) => {
<Head> <Head>
<title>Featherity</title> <title>Featherity</title>
</Head> </Head>
<QueryProvider> <ThemeProvider theme={customTheme}>
<ThemeProvider theme={customTheme}> <ColorModeProvider>
<ColorModeProvider> <CSSReset />
<CSSReset /> <Component {...pageProps} />
<Component {...pageProps} /> </ColorModeProvider>
</ColorModeProvider> </ThemeProvider>
</ThemeProvider>
</QueryProvider>
</> </>
); );
}; };

View File

@@ -9,15 +9,16 @@ import {
Stack, Stack,
Text, Text,
useToast, useToast,
} from "@chakra-ui/core"; } from '@chakra-ui/core';
import copy from "copy-to-clipboard"; import copy from 'copy-to-clipboard';
import download from "downloadjs"; import download from 'downloadjs';
import JSZip from "jszip"; import JSZip from 'jszip';
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react';
import { StringParam, useQueryParam } from "use-query-params"; import Layout from '../components/Layout';
import Layout from "../components/Layout"; import { getAllData } from '../lib/icons';
import { getAllData } from "../lib/icons"; import useSearch from '../lib/search';
import useSearch from "../lib/search"; import { useRouter } from 'next/router';
import { useDebounce } from '../lib/useDebounce';
function generateZip(icons) { function generateZip(icons) {
const zip = new JSZip(); const zip = new JSZip();
@@ -25,25 +26,39 @@ function generateZip(icons) {
// @ts-ignore // @ts-ignore
zip.file(`${icon.name}.svg`, icon.src) zip.file(`${icon.name}.svg`, icon.src)
); );
return zip.generateAsync({ type: "blob" }); return zip.generateAsync({ type: 'blob' });
} }
const IndexPage = ({ data }) => { const IndexPage = ({ data }) => {
const [query, setQuery] = useQueryParam("query", StringParam); const router = useRouter();
const results = useSearch(data, query || ""); const { query } = router.query;
const [queryText, setQueryText] = useState(query || '');
const toast = useToast(); const toast = useToast();
const debouncedQuery = useDebounce(queryText, 1000);
const results = useSearch(data, queryText);
useEffect(() => {
setQueryText(query);
}, [query]);
useEffect(() => {
router.push({
pathname: '/',
query: { query: debouncedQuery },
});
}, [debouncedQuery]);
const inputElement = useRef(null); const inputElement = useRef(null);
function handleKeyDown(event) { function handleKeyDown(event) {
if (event.key === "/" && inputElement.current !== document.activeElement) { if (event.key === '/' && inputElement.current !== document.activeElement) {
event.preventDefault(); event.preventDefault();
inputElement.current.focus(); inputElement.current.focus();
} }
} }
useEffect(() => { useEffect(() => {
window.addEventListener("keydown", handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, []); }, []);
return ( return (
@@ -56,7 +71,7 @@ const IndexPage = ({ data }) => {
<Button <Button
onClick={async () => { onClick={async () => {
const zip = await generateZip(data); const zip = await generateZip(data);
download(zip, "feather.zip"); download(zip, 'feather.zip');
}} }}
> >
Download all Download all
@@ -67,19 +82,14 @@ const IndexPage = ({ data }) => {
<InputLeftElement children={<Icon name="search" />} /> <InputLeftElement children={<Icon name="search" />} />
<Input <Input
ref={inputElement} ref={inputElement}
placeholder={`Search ${ placeholder={`Search ${Object.keys(data).length} icons (Press "/" to focus)`}
Object.keys(data).length value={queryText}
} icons (Press "/" to focus)`} onChange={(event) => setQueryText(event.target.value)}
value={query}
onChange={(event) => setQuery(event.target.value)}
marginBottom={5} marginBottom={5}
/> />
</InputGroup> </InputGroup>
{results.length > 0 ? ( {results.length > 0 ? (
<Grid <Grid templateColumns={`repeat(auto-fill, minmax(160px, 1fr))`} gap={5}>
templateColumns={`repeat(auto-fill, minmax(160px, 1fr))`}
gap={5}
>
{results.map((icon) => { {results.map((icon) => {
// @ts-ignore // @ts-ignore
const actualIcon = icon.item ? icon.item : icon; const actualIcon = icon.item ? icon.item : icon;
@@ -93,17 +103,13 @@ const IndexPage = ({ data }) => {
if (event.shiftKey) { if (event.shiftKey) {
copy(actualIcon.src); copy(actualIcon.src);
toast({ toast({
title: "Copied!", title: 'Copied!',
description: `Icon "${actualIcon.name}" copied to clipboard.`, description: `Icon "${actualIcon.name}" copied to clipboard.`,
status: "success", status: 'success',
duration: 1500, duration: 1500,
}); });
} else { } else {
download( download(actualIcon.src, `${actualIcon.name}.svg`, 'image/svg+xml');
actualIcon.src,
`${actualIcon.name}.svg`,
"image/svg+xml"
);
} }
}} }}
key={actualIcon.name} key={actualIcon.name}
@@ -122,7 +128,7 @@ const IndexPage = ({ data }) => {
fontSize="2xl" fontSize="2xl"
fontWeight="bold" fontWeight="bold"
textAlign="center" textAlign="center"
style={{ wordBreak: "break-word" }} style={{ wordBreak: 'break-word' }}
> >
No results found for "{query}" No results found for "{query}"
</Text> </Text>

View File

@@ -0,0 +1,30 @@
import { act, fireEvent, screen } from '@testing-library/react';
import Index from '../pages/index';
import React from 'react';
import { render } from './test-utils';
import { getAllData } from '../lib/icons';
import App from '../pages/_app';
import { renderHook } from '@testing-library/react-hooks';
import useSearch from '../lib/search';
describe('App', () => {
it('renders without crashing', () => {
let allData = getAllData();
render(<App Component={Index} pageProps={{ data: allData }} />);
expect(
screen.getByText('Simply beautiful open source icons, community-sourced')
).toBeInTheDocument();
});
it('can search filter icons', async () => {
let allData = getAllData();
const { result: result1, waitForNextUpdate: wait1 } = renderHook(() => useSearch(allData, ''));
expect(result1.current).toHaveLength(allData.length);
const { result: result2, waitForNextUpdate: wait2 } = renderHook(() =>
useSearch(allData, 'airplay')
);
await wait2();
expect(result2.current).toHaveLength(2);
});
});

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { render as defaultRender } from '@testing-library/react';
import { RouterContext } from 'next/dist/next-server/lib/router-context';
import { NextRouter } from 'next/router';
export * from '@testing-library/react';
// --------------------------------------------------
// Override the default test render with our own
//
// You can override the router mock like this:
//
// const { baseElement } = render(<MyComponent />, {
// router: { pathname: '/my-custom-pathname' },
// });
// --------------------------------------------------
type DefaultParams = Parameters<typeof defaultRender>;
type RenderUI = DefaultParams[0];
type RenderOptions = DefaultParams[1] & { router?: Partial<NextRouter> };
export function render(ui: RenderUI, { wrapper, router, ...options }: RenderOptions = {}) {
if (!wrapper) {
wrapper = ({ children }) => (
<RouterContext.Provider value={{ ...mockRouter, ...router }}>
{children}
</RouterContext.Provider>
);
}
return defaultRender(ui, { wrapper, ...options });
}
const mockRouter: NextRouter = {
basePath: '',
pathname: '/',
route: '/',
asPath: '/',
query: {},
push: jest.fn(),
replace: jest.fn(),
reload: jest.fn(),
back: jest.fn(),
prefetch: jest.fn(),
beforePopState: jest.fn(),
events: {
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
},
isFallback: false,
};