From 972f215f0c2703a3c9d35b33a5bbc5c1a87a219b Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Sun, 14 Aug 2022 22:09:25 -0700 Subject: [PATCH] Rewrite `useQueryAndSortParams` machinery to be faster/simpler/better (#758) * Rewrite useQueryAndSortParams machinery to be faster/simpler/better * Politely debounce Algolia querying * Tidy some stuff up * Style changes suggested by James --- web/components/contract-search.tsx | 88 +++++++++----- web/components/contract/contracts-grid.tsx | 7 +- web/components/editor/market-modal.tsx | 1 - web/hooks/use-sort-and-query-params.tsx | 133 +++++++-------------- web/pages/contract-search-firestore.tsx | 14 +-- web/pages/group/[...slugs]/index.tsx | 8 +- web/pages/home.tsx | 9 +- web/pages/tag/[tag].tsx | 7 +- 8 files changed, 117 insertions(+), 150 deletions(-) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 98debc9f..54b30f3f 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -4,11 +4,7 @@ import { SearchOptions } from '@algolia/client-search' import { Contract } from 'common/contract' import { User } from 'common/user' -import { - QuerySortOptions, - Sort, - useQueryAndSortParams, -} from '../hooks/use-sort-and-query-params' +import { Sort, useQuery, useSort } from '../hooks/use-sort-and-query-params' import { ContractHighlightOptions, ContractsGrid, @@ -24,11 +20,25 @@ import ContractSearchFirestore from 'web/pages/contract-search-firestore' import { useMemberGroups } from 'web/hooks/use-group' import { NEW_USER_GROUP_SLUGS } from 'common/group' import { PillButton } from './buttons/pill-button' -import { sortBy } from 'lodash' +import { debounce, sortBy } from 'lodash' import { DEFAULT_CATEGORY_GROUPS } from 'common/categories' import { Col } from './layout/col' +import { safeLocalStorage } from 'web/lib/util/local' import clsx from 'clsx' +// TODO: this obviously doesn't work with SSR, common sense would suggest +// that we should save things like this in cookies so the server has them + +const MARKETS_SORT = 'markets_sort' + +function setSavedSort(s: Sort) { + safeLocalStorage()?.setItem(MARKETS_SORT, s) +} + +function getSavedSort() { + return safeLocalStorage()?.getItem(MARKETS_SORT) as Sort | null | undefined +} + const searchClient = algoliasearch( 'GJQPAYENIF', '75c28fc084a80e1129d427d470cf41a3' @@ -47,7 +57,6 @@ const sortOptions = [ { label: 'Close date', value: 'close-date' }, { label: 'Resolve date', value: 'resolve-date' }, ] -export const DEFAULT_SORT = 'score' type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' @@ -68,7 +77,8 @@ type AdditionalFilter = { export function ContractSearch(props: { user?: User | null - querySortOptions?: { defaultFilter?: filter } & QuerySortOptions + defaultSort?: Sort + defaultFilter?: filter additionalFilter?: AdditionalFilter highlightOptions?: ContractHighlightOptions onContractClick?: (contract: Contract) => void @@ -79,10 +89,13 @@ export function ContractSearch(props: { hideQuickBet?: boolean } headerClassName?: string + useQuerySortLocalStorage?: boolean + useQuerySortUrlParams?: boolean }) { const { user, - querySortOptions, + defaultSort, + defaultFilter, additionalFilter, onContractClick, overrideGridClassName, @@ -90,6 +103,8 @@ export function ContractSearch(props: { cardHideOptions, highlightOptions, headerClassName, + useQuerySortLocalStorage, + useQuerySortUrlParams, } = props const [numPages, setNumPages] = useState(1) @@ -132,31 +147,33 @@ export function ContractSearch(props: { } } + const onSearchParametersChanged = useRef( + debounce((params) => { + searchParameters.current = params + performQuery(true) + }, 100) + ).current + const contracts = pages .flat() .filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id)) if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { - return ( - - ) + return } return ( { - searchParameters.current = params - performQuery(true) - }} + onSearchParametersChanged={onSearchParametersChanged} /> void - querySortOptions?: { defaultFilter?: filter } & QuerySortOptions + useQuerySortLocalStorage?: boolean + useQuerySortUrlParams?: boolean user?: User | null }) { const { className, + defaultSort, + defaultFilter, additionalFilter, hideOrderSelector, onSearchParametersChanged, - querySortOptions, + useQuerySortLocalStorage, + useQuerySortUrlParams, user, } = props - const { query, setQuery, sort, setSort } = - useQueryAndSortParams(querySortOptions) + const savedSort = useQuerySortLocalStorage ? getSavedSort() : null + const initialSort = savedSort ?? defaultSort ?? 'score' + const querySortOpts = { useUrl: !!useQuerySortUrlParams } + const [sort, setSort] = useSort(initialSort, querySortOpts) + const [query, setQuery] = useQuery('', querySortOpts) + const [filter, setFilter] = useState(defaultFilter ?? 'open') + const [pillFilter, setPillFilter] = useState(undefined) + + useEffect(() => { + if (useQuerySortLocalStorage) { + setSavedSort(sort) + } + }, [sort]) const follows = useFollows(user?.id) const memberGroups = (useMemberGroups(user?.id) ?? []).filter( @@ -208,12 +242,6 @@ function ContractSearchControls(props: { const pillGroups: { name: string; slug: string }[] = memberPillGroups.length > 0 ? memberPillGroups : DEFAULT_CATEGORY_GROUPS - const [filter, setFilter] = useState( - querySortOptions?.defaultFilter ?? 'open' - ) - - const [pillFilter, setPillFilter] = useState(undefined) - const additionalFilters = [ additionalFilter?.creatorId ? `creatorId:${additionalFilter.creatorId}` diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index f62c3c85..05c66d56 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -106,11 +106,8 @@ export function CreatorContractsList(props: { return ( c.id), highlightClassName: diff --git a/web/hooks/use-sort-and-query-params.tsx b/web/hooks/use-sort-and-query-params.tsx index e917e4af..0a2834d0 100644 --- a/web/hooks/use-sort-and-query-params.tsx +++ b/web/hooks/use-sort-and-query-params.tsx @@ -1,9 +1,5 @@ -import { debounce } from 'lodash' -import { useRouter } from 'next/router' -import { useEffect, useMemo, useState } from 'react' -import { DEFAULT_SORT } from 'web/components/contract-search' - -const MARKETS_SORT = 'markets_sort' +import { useState } from 'react' +import { NextRouter, useRouter } from 'next/router' export type Sort = | 'newest' @@ -15,92 +11,55 @@ export type Sort = | 'last-updated' | 'score' -export function getSavedSort() { - // TODO: this obviously doesn't work with SSR, common sense would suggest - // that we should save things like this in cookies so the server has them - if (typeof window !== 'undefined') { - return localStorage.getItem(MARKETS_SORT) as Sort | null - } else { - return null +type UpdatedQueryParams = { [k: string]: string } +type QuerySortOpts = { useUrl: boolean } + +function withURLParams(location: Location, params: UpdatedQueryParams) { + const newParams = new URLSearchParams(location.search) + for (const [k, v] of Object.entries(params)) { + if (!v) { + newParams.delete(k) + } else { + newParams.set(k, v) + } } + const newUrl = new URL(location.href) + newUrl.search = newParams.toString() + return newUrl } -export interface QuerySortOptions { - defaultSort?: Sort - shouldLoadFromStorage?: boolean - /** Use normal react state instead of url query string */ - disableQueryString?: boolean +function updateURL(params: UpdatedQueryParams) { + // see relevant discussion here https://github.com/vercel/next.js/discussions/18072 + const url = withURLParams(window.location, params).toString() + const updatedState = { ...window.history.state, as: url, url } + window.history.replaceState(updatedState, '', url) } -export function useQueryAndSortParams({ - defaultSort = DEFAULT_SORT, - shouldLoadFromStorage = true, - disableQueryString, -}: QuerySortOptions = {}) { +function getStringURLParam(router: NextRouter, k: string) { + const v = router.query[k] + return typeof v === 'string' ? v : null +} + +export function useQuery(defaultQuery: string, opts?: QuerySortOpts) { + const useUrl = opts?.useUrl ?? false const router = useRouter() - - const { s: sort, q: query } = router.query as { - q?: string - s?: Sort - } - - const setSort = (sort: Sort | undefined) => { - router.replace({ query: { ...router.query, s: sort } }, undefined, { - shallow: true, - }) - if (shouldLoadFromStorage) { - localStorage.setItem(MARKETS_SORT, sort || '') - } - } - - const [queryState, setQueryState] = useState(query) - - useEffect(() => { - setQueryState(query) - }, [query]) - - // Debounce router query update. - const pushQuery = useMemo( - () => - debounce((query: string | undefined) => { - const queryObj = { ...router.query, q: query } - if (!query) delete queryObj.q - router.replace({ query: queryObj }, undefined, { - shallow: true, - }) - }, 100), - [router] - ) - - const setQuery = (query: string | undefined) => { - setQueryState(query) - if (!disableQueryString) { - pushQuery(query) - } - } - - useEffect(() => { - // If there's no sort option, then set the one from localstorage - if (router.isReady && !sort && shouldLoadFromStorage) { - const localSort = localStorage.getItem(MARKETS_SORT) as Sort - if (localSort && localSort !== defaultSort) { - // Use replace to not break navigating back. - router.replace( - { query: { ...router.query, s: localSort } }, - undefined, - { shallow: true } - ) - } - } - }) - - // use normal state if querydisableQueryString - const [sortState, setSortState] = useState(defaultSort) - - return { - sort: disableQueryString ? sortState : sort ?? defaultSort, - query: queryState ?? '', - setSort: disableQueryString ? setSortState : setSort, - setQuery, + const initialQuery = useUrl ? getStringURLParam(router, 'q') : null + const [query, setQuery] = useState(initialQuery ?? defaultQuery) + if (!useUrl) { + return [query, setQuery] as const + } else { + return [query, (q: string) => (setQuery(q), updateURL({ q }))] as const + } +} + +export function useSort(defaultSort: Sort, opts?: QuerySortOpts) { + const useUrl = opts?.useUrl ?? false + const router = useRouter() + const initialSort = useUrl ? (getStringURLParam(router, 's') as Sort) : null + const [sort, setSort] = useState(initialSort ?? defaultSort) + if (!useUrl) { + return [sort, setSort] as const + } else { + return [sort, (s: Sort) => (setSort(s), updateURL({ s }))] as const } } diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index f56c82d1..ec480269 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -3,16 +3,11 @@ import { searchInAny } from 'common/util/parse' import { sortBy } from 'lodash' import { ContractsGrid } from 'web/components/contract/contracts-grid' import { useContracts } from 'web/hooks/use-contracts' -import { - QuerySortOptions, - Sort, - useQueryAndSortParams, -} from 'web/hooks/use-sort-and-query-params' +import { Sort, useQuery, useSort } from 'web/hooks/use-sort-and-query-params' const MAX_CONTRACTS_RENDERED = 100 export default function ContractSearchFirestore(props: { - querySortOptions?: QuerySortOptions additionalFilter?: { creatorId?: string tag?: string @@ -21,10 +16,9 @@ export default function ContractSearchFirestore(props: { } }) { const contracts = useContracts() - const { querySortOptions, additionalFilter } = props - - const { query, setQuery, sort, setSort } = - useQueryAndSortParams(querySortOptions) + const { additionalFilter } = props + const [query, setQuery] = useQuery('', { useUrl: true }) + const [sort, setSort] = useSort('score', { useUrl: true }) let matches = (contracts ?? []).filter((c) => searchInAny( diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index cd4b7344..c5255974 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -31,7 +31,6 @@ import { CreateQuestionButton } from 'web/components/create-question-button' import React, { useState } from 'react' import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' -import { getSavedSort } from 'web/hooks/use-sort-and-query-params' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { toast } from 'react-hot-toast' import { useCommentsOnGroup } from 'web/hooks/use-comments' @@ -196,11 +195,8 @@ export default function GroupPage(props: { const questionsTab = ( ) diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 839a08f3..b11c0cf9 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -4,8 +4,7 @@ import { PlusSmIcon } from '@heroicons/react/solid' import { Page } from 'web/components/page' import { Col } from 'web/components/layout/col' -import { getSavedSort } from 'web/hooks/use-sort-and-query-params' -import { ContractSearch, DEFAULT_SORT } from 'web/components/contract-search' +import { ContractSearch } from 'web/components/contract-search' import { Contract } from 'common/contract' import { User } from 'common/user' import { ContractPageContent } from './[username]/[contractSlug]' @@ -35,10 +34,8 @@ const Home = (props: { auth: { user: User } }) => { { // Show contract without navigating to contract page. setContract(c) diff --git a/web/pages/tag/[tag].tsx b/web/pages/tag/[tag].tsx index c1dce29e..f2554f49 100644 --- a/web/pages/tag/[tag].tsx +++ b/web/pages/tag/[tag].tsx @@ -15,11 +15,8 @@ export default function TagPage() { <ContractSearch user={user} - querySortOptions={{ - defaultSort: 'newest', - defaultFilter: 'all', - shouldLoadFromStorage: true, - }} + defaultSort="newest" + defaultFilter="all" additionalFilter={{ tag }} /> </Page>