Clean up and unify persistent state stores

This commit is contained in:
Marshall Polaris 2022-08-29 01:32:29 -07:00
parent f643636822
commit 01075a7e1f
5 changed files with 174 additions and 228 deletions

View File

@ -1,15 +1,9 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import algoliasearch from 'algoliasearch/lite' import algoliasearch from 'algoliasearch/lite'
import { SearchOptions } from '@algolia/client-search' import { SearchOptions } from '@algolia/client-search'
import { useRouter } from 'next/router'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { User } from 'common/user' import { User } from 'common/user'
import {
SORTS,
Sort,
useQuery,
useSort,
} from '../hooks/use-sort-and-query-params'
import { import {
ContractHighlightOptions, ContractHighlightOptions,
ContractsGrid, ContractsGrid,
@ -20,12 +14,12 @@ import { useEffect, useLayoutEffect, useRef, useMemo } from 'react'
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { useFollows } from 'web/hooks/use-follows' import { useFollows } from 'web/hooks/use-follows'
import { import {
getKey, storageStore,
saveState, historyStore,
loadState, urlParamsStore,
usePersistentState, usePersistentState,
} from 'web/hooks/use-persistent-state' } from 'web/hooks/use-persistent-state'
import { safeLocalStorage, safeSessionStorage } from 'web/lib/util/local' import { safeSessionStorage } from 'web/lib/util/local'
import { track, trackCallback } from 'web/lib/service/analytics' import { track, trackCallback } from 'web/lib/service/analytics'
import ContractSearchFirestore from 'web/pages/contract-search-firestore' import ContractSearchFirestore from 'web/pages/contract-search-firestore'
import { useMemberGroups } from 'web/hooks/use-group' import { useMemberGroups } from 'web/hooks/use-group'
@ -44,6 +38,19 @@ const searchClient = algoliasearch(
const indexPrefix = ENV === 'DEV' ? 'dev-' : '' const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex' const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
const SORTS = [
{ label: 'Newest', value: 'newest' },
{ label: 'Trending', value: 'score' },
{ label: 'Most traded', value: 'most-traded' },
{ label: '24h volume', value: '24-hour-vol' },
{ label: 'Last updated', value: 'last-updated' },
{ label: 'Subsidy', value: 'liquidity' },
{ label: 'Close date', value: 'close-date' },
{ label: 'Resolve date', value: 'resolve-date' },
] as const
export type Sort = typeof SORTS[number]['value']
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all' type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
type SearchParameters = { type SearchParameters = {
@ -97,29 +104,25 @@ export function ContractSearch(props: {
noControls, noControls,
} = props } = props
const store = safeSessionStorage()
const persistAs = (name: string) => {
return persistPrefix ? { prefix: persistPrefix, name, store } : undefined
}
const [state, setState] = usePersistentState( const [state, setState] = usePersistentState(
{ {
numPages: 1, numPages: 1,
pages: [] as Contract[][], pages: [] as Contract[][],
showTime: null as ShowTime | null, showTime: null as ShowTime | null,
}, },
persistAs('state') { key: `${persistPrefix}-search`, store: historyStore() }
) )
const searchParameters = useRef<SearchParameters | null>(null) const searchParams = useRef<SearchParameters | null>(null)
const searchParamsStore = historyStore<SearchParameters>()
const requestId = useRef(0) const requestId = useRef(0)
useLayoutEffect(() => { useLayoutEffect(() => {
if (persistPrefix && store) { if (persistPrefix) {
const parameters = loadState(getKey(persistPrefix, 'parameters'), store) const params = searchParamsStore.get(`${persistPrefix}-params`)
if (parameters !== undefined) { if (params !== undefined) {
console.log('Restoring search parameters: ', parameters) console.log('Restoring search parameters: ', params)
searchParameters.current = parameters as SearchParameters searchParams.current = params
} }
} }
}, []) }, [])
@ -131,11 +134,10 @@ export function ContractSearch(props: {
const performQuery = async (freshQuery?: boolean) => { const performQuery = async (freshQuery?: boolean) => {
console.log('Performing query.') console.log('Performing query.')
if (searchParameters.current == null) { if (searchParams.current == null) {
return return
} }
const { query, sort, openClosedFilter, facetFilters } = const { query, sort, openClosedFilter, facetFilters } = searchParams.current
searchParameters.current
const id = ++requestId.current const id = ++requestId.current
const requestedPage = freshQuery ? 0 : state.pages.length const requestedPage = freshQuery ? 0 : state.pages.length
if (freshQuery || requestedPage < state.numPages) { if (freshQuery || requestedPage < state.numPages) {
@ -159,10 +161,8 @@ export function ContractSearch(props: {
const newPage = results.hits as any as Contract[] const newPage = results.hits as any as Contract[]
const showTime = const showTime =
sort === 'close-date' || sort === 'resolve-date' ? sort : null sort === 'close-date' || sort === 'resolve-date' ? sort : null
setState((curr) => { const pages = freshQuery ? [newPage] : [...state.pages, newPage]
const pages = freshQuery ? [newPage] : [...curr.pages, newPage] setState({ numPages: results.nbPages, pages, showTime })
return { numPages: results.nbPages, pages, showTime }
})
if (freshQuery && isWholePage) window.scrollTo(0, 0) if (freshQuery && isWholePage) window.scrollTo(0, 0)
} }
} }
@ -170,12 +170,12 @@ export function ContractSearch(props: {
const onSearchParametersChanged = useRef( const onSearchParametersChanged = useRef(
debounce((params) => { debounce((params) => {
if (!isEqual(searchParameters.current, params)) { if (!isEqual(searchParams.current, params)) {
console.log('Old vs new:', searchParameters.current, params) console.log('Old vs new:', searchParams.current, params)
if (persistPrefix && store) { if (persistPrefix) {
saveState(getKey(persistPrefix, 'parameters'), params, store) searchParamsStore.set(`${persistPrefix}-params`, params)
} }
searchParameters.current = params searchParams.current = params
performQuery(true) performQuery(true)
} }
}, 100) }, 100)
@ -244,28 +244,21 @@ function ContractSearchControls(props: {
noControls, noControls,
} = props } = props
const localStore = safeLocalStorage() const router = useRouter()
const sessionStore = safeSessionStorage() const [query, setQuery] = usePersistentState('', {
const persistAs = (name: string, store?: Storage) => { key: 'q',
return persistPrefix ? { prefix: persistPrefix, name, store } : undefined store: urlParamsStore(router),
}
const initialSort = defaultSort ?? 'score'
const [sort, setSort] = useSort(initialSort, {
useUrl: !!useQuerySortUrlParams,
persist: persistAs('sort', localStore),
}) })
const [query, setQuery] = useQuery('', { const [state, setState] = usePersistentState(
useUrl: !!useQuerySortUrlParams, {
persist: persistAs('query', sessionStore), sort: defaultSort ?? 'score',
}) filter: defaultFilter ?? 'open',
const [filter, setFilter] = usePersistentState<filter>( pillFilter: null as string | null,
defaultFilter ?? 'open', },
persistAs('filter', sessionStore) {
) key: `${persistPrefix}-params`,
const [pillFilter, setPillFilter] = usePersistentState<string | null>( store: storageStore(safeSessionStorage()),
null, }
persistAs('pillFilter', sessionStore)
) )
const follows = useFollows(user?.id) const follows = useFollows(user?.id)
@ -300,14 +293,16 @@ function ContractSearchControls(props: {
...additionalFilters, ...additionalFilters,
additionalFilter ? '' : 'visibility:public', additionalFilter ? '' : 'visibility:public',
filter === 'open' ? 'isResolved:false' : '', state.filter === 'open' ? 'isResolved:false' : '',
filter === 'closed' ? 'isResolved:false' : '', state.filter === 'closed' ? 'isResolved:false' : '',
filter === 'resolved' ? 'isResolved:true' : '', state.filter === 'resolved' ? 'isResolved:true' : '',
pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' state.pillFilter &&
? `groupLinks.slug:${pillFilter}` state.pillFilter !== 'personal' &&
state.pillFilter !== 'your-bets'
? `groupLinks.slug:${state.pillFilter}`
: '', : '',
pillFilter === 'personal' state.pillFilter === 'personal'
? // Show contracts in groups that the user is a member of ? // Show contracts in groups that the user is a member of
memberGroupSlugs memberGroupSlugs
.map((slug) => `groupLinks.slug:${slug}`) .map((slug) => `groupLinks.slug:${slug}`)
@ -319,18 +314,24 @@ function ContractSearchControls(props: {
) )
: '', : '',
// Subtract contracts you bet on from For you. // Subtract contracts you bet on from For you.
pillFilter === 'personal' && user ? `uniqueBettorIds:-${user.id}` : '', state.pillFilter === 'personal' && user
pillFilter === 'your-bets' && user ? `uniqueBettorIds:-${user.id}`
: '',
state.pillFilter === 'your-bets' && user
? // Show contracts bet on by the user ? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}` `uniqueBettorIds:${user.id}`
: '', : '',
].filter((f) => f) ].filter((f) => f)
const openClosedFilter = const openClosedFilter =
filter === 'open' ? 'open' : filter === 'closed' ? 'closed' : undefined state.filter === 'open'
? 'open'
: state.filter === 'closed'
? 'closed'
: undefined
const selectPill = (pill: string | null) => () => { const selectPill = (pill: string | null) => () => {
setPillFilter(pill) setState({ ...state, pillFilter: pill })
track('select search category', { category: pill ?? 'all' }) track('select search category', { category: pill ?? 'all' })
} }
@ -340,25 +341,25 @@ function ContractSearchControls(props: {
} }
const selectFilter = (newFilter: filter) => { const selectFilter = (newFilter: filter) => {
if (newFilter === filter) return if (newFilter === state.filter) return
setFilter(newFilter) setState({ ...state, filter: newFilter })
track('select search filter', { filter: newFilter }) track('select search filter', { filter: newFilter })
} }
const selectSort = (newSort: Sort) => { const selectSort = (newSort: Sort) => {
if (newSort === sort) return if (newSort === state.sort) return
setSort(newSort) setState({ ...state, sort: newSort })
track('select search sort', { sort: newSort }) track('select search sort', { sort: newSort })
} }
useEffect(() => { useEffect(() => {
onSearchParametersChanged({ onSearchParametersChanged({
query: query, query: query,
sort: sort, sort: state.sort,
openClosedFilter: openClosedFilter, openClosedFilter: openClosedFilter,
facetFilters: facetFilters, facetFilters: facetFilters,
}) })
}, [query, sort, openClosedFilter, JSON.stringify(facetFilters)]) }, [query, state.sort, openClosedFilter, JSON.stringify(facetFilters)])
if (noControls) { if (noControls) {
return <></> return <></>
@ -373,14 +374,14 @@ function ContractSearchControls(props: {
type="text" type="text"
value={query} value={query}
onChange={(e) => updateQuery(e.target.value)} onChange={(e) => updateQuery(e.target.value)}
onBlur={trackCallback('search', { query })} onBlur={trackCallback('search', { query: query })}
placeholder={'Search'} placeholder={'Search'}
className="input input-bordered w-full" className="input input-bordered w-full"
/> />
{!query && ( {!query && (
<select <select
className="select select-bordered" className="select select-bordered"
value={filter} value={state.filter}
onChange={(e) => selectFilter(e.target.value as filter)} onChange={(e) => selectFilter(e.target.value as filter)}
> >
<option value="open">Open</option> <option value="open">Open</option>
@ -392,7 +393,7 @@ function ContractSearchControls(props: {
{!hideOrderSelector && !query && ( {!hideOrderSelector && !query && (
<select <select
className="select select-bordered" className="select select-bordered"
value={sort} value={state.sort}
onChange={(e) => selectSort(e.target.value as Sort)} onChange={(e) => selectSort(e.target.value as Sort)}
> >
{SORTS.map((option) => ( {SORTS.map((option) => (
@ -408,14 +409,14 @@ function ContractSearchControls(props: {
<Row className="scrollbar-hide items-start gap-2 overflow-x-auto"> <Row className="scrollbar-hide items-start gap-2 overflow-x-auto">
<PillButton <PillButton
key={'all'} key={'all'}
selected={pillFilter === undefined} selected={state.pillFilter === undefined}
onSelect={selectPill(null)} onSelect={selectPill(null)}
> >
All All
</PillButton> </PillButton>
<PillButton <PillButton
key={'personal'} key={'personal'}
selected={pillFilter === 'personal'} selected={state.pillFilter === 'personal'}
onSelect={selectPill('personal')} onSelect={selectPill('personal')}
> >
{user ? 'For you' : 'Featured'} {user ? 'For you' : 'Featured'}
@ -424,7 +425,7 @@ function ContractSearchControls(props: {
{user && ( {user && (
<PillButton <PillButton
key={'your-bets'} key={'your-bets'}
selected={pillFilter === 'your-bets'} selected={state.pillFilter === 'your-bets'}
onSelect={selectPill('your-bets')} onSelect={selectPill('your-bets')}
> >
Your bets Your bets
@ -435,7 +436,7 @@ function ContractSearchControls(props: {
return ( return (
<PillButton <PillButton
key={slug} key={slug}
selected={pillFilter === slug} selected={state.pillFilter === slug}
onSelect={selectPill(slug)} onSelect={selectPill(slug)}
> >
{name} {name}

View File

@ -1,88 +1,105 @@
import { useLayoutEffect, useEffect, useState } from 'react' import { useEffect } from 'react'
import { useStateCheckEquality } from './use-state-check-equality' import { useStateCheckEquality } from './use-state-check-equality'
import { NextRouter } from 'next/router'
export type PersistenceOptions = { export type PersistenceOptions<T> = { key: string; store: PersistentStore<T> }
store?: Storage
prefix: string export interface PersistentStore<T> {
name: string get: (k: string) => T | undefined
set: (k: string, v: T | undefined) => void
} }
export const getKey = (prefix: string, name: string) => `${prefix}-${name}` const withURLParam = (location: Location, k: string, v?: string) => {
const newParams = new URLSearchParams(location.search)
export const saveState = (key: string, val: unknown, store: Storage) => { if (!v) {
if (val === undefined) { newParams.delete(k)
store.removeItem(key)
} else { } else {
store.setItem(key, JSON.stringify(val)) newParams.set(k, v)
} }
const newUrl = new URL(location.href)
newUrl.search = newParams.toString()
return newUrl
} }
export const loadState = (key: string, store: Storage) => { export const storageStore = <T>(storage?: Storage): PersistentStore<T> => ({
const saved = store.getItem(key) get: (k: string) => {
if (typeof saved === 'string') { if (!storage) {
try { return undefined
return JSON.parse(saved) as unknown
} catch (e) {
console.error(e)
} }
} else { const saved = storage.getItem(k)
return undefined if (typeof saved === 'string') {
} try {
} return JSON.parse(saved) as T
} catch (e) {
console.error(e)
}
} else {
return undefined
}
},
set: (k: string, v: T | undefined) => {
if (storage) {
if (v === undefined) {
storage.removeItem(k)
} else {
storage.setItem(k, JSON.stringify(v))
}
}
},
})
const STATE_KEY = '__manifold' export const urlParamsStore = (router: NextRouter) => ({
get: (k: string) => {
const v = router.query[k]
return typeof v === 'string' ? v : undefined
},
set: (k: string, v: string | undefined) => {
if (typeof window !== 'undefined') {
// see relevant discussion here https://github.com/vercel/next.js/discussions/18072
const url = withURLParam(window.location, k, v).toString()
const updatedState = { ...window.history.state, as: url, url }
window.history.replaceState(updatedState, '', url)
}
},
})
const getHistoryState = <T>(k: string) => { export const historyStore = <T>(prefix = '__manifold'): PersistentStore<T> => ({
if (typeof window !== 'undefined') { get: (k: string) => {
return window.history.state?.options?.[STATE_KEY]?.[k] as T | undefined if (typeof window !== 'undefined') {
} else { return window.history.state?.options?.[prefix]?.[k] as T | undefined
return undefined } else {
} return undefined
} }
},
const setHistoryState = (k: string, v: any) => { set: (k: string, v: T | undefined) => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const state = window.history.state ?? {} const state = window.history.state ?? {}
const options = state.options ?? {} const options = state.options ?? {}
const inner = options[STATE_KEY] ?? {} const inner = options[prefix] ?? {}
window.history.replaceState( window.history.replaceState(
{ ...state, options: { ...options, [STATE_KEY]: { ...inner, [k]: v } } }, {
'' ...state,
) options: { ...options, [prefix]: { ...inner, [k]: v } },
} },
} ''
)
export const useHistoryState = <T>(key: string, initialValue: T) => { }
const [state, setState] = useState(getHistoryState<T>(key) ?? initialValue) },
const setter = (val: T) => { })
console.log('Setting state: ', val)
setHistoryState(key, val)
setState(val)
}
return [state, setter] as const
}
export const usePersistentState = <T>( export const usePersistentState = <T>(
initial: T, initial: T,
persist?: PersistenceOptions persist?: PersistenceOptions<T>
) => { ) => {
const store = persist?.store const store = persist?.store
const key = persist ? getKey(persist.prefix, persist.name) : null const key = persist?.key
useLayoutEffect(() => { const savedValue = key != null && store != null ? store.get(key) : undefined
if (key != null && store != null) { const [state, setState] = useStateCheckEquality(savedValue ?? initial)
const saved = loadState(key, store) as T
console.log('Loading state for: ', key, saved)
if (saved !== undefined) {
setState(saved)
}
}
}, [])
const [state, setState] = useStateCheckEquality(initial)
useEffect(() => { useEffect(() => {
if (key != null && store != null) { if (key != null && store != null) {
console.log('Saving state for: ', key, state) console.log('Saving state for: ', key, state)
saveState(key, state, store) store.set(key, state)
} }
}, [key, state, store]) }, [key, state])
return [state, setState] as const return [state, setState] as const
} }

View File

@ -1,78 +0,0 @@
import { useState } from 'react'
import {
usePersistentState,
PersistenceOptions,
} from 'web/hooks/use-persistent-state'
import { NextRouter, useRouter } from 'next/router'
export const SORTS = [
{ label: 'Newest', value: 'newest' },
{ label: 'Trending', value: 'score' },
{ label: 'Most traded', value: 'most-traded' },
{ label: '24h volume', value: '24-hour-vol' },
{ label: 'Last updated', value: 'last-updated' },
{ label: 'Subsidy', value: 'liquidity' },
{ label: 'Close date', value: 'close-date' },
{ label: 'Resolve date', value: 'resolve-date' },
] as const
export type Sort = typeof SORTS[number]['value']
type UpdatedQueryParams = { [k: string]: string }
type QuerySortOpts = { useUrl: boolean; persist?: PersistenceOptions }
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
}
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)
}
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 initialQuery = useUrl ? getStringURLParam(router, 'q') : null
const [query, setQuery] = usePersistentState(
initialQuery ?? defaultQuery,
opts?.persist
)
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] = usePersistentState(
initialSort ?? defaultSort,
opts?.persist
)
if (!useUrl) {
return [sort, setSort] as const
} else {
return [sort, (s: Sort) => (setSort(s), updateURL({ s }))] as const
}
}

View File

@ -1,9 +1,13 @@
import { useRouter } from 'next/router'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { searchInAny } from 'common/util/parse' import { searchInAny } from 'common/util/parse'
import { sortBy } from 'lodash' import { sortBy } from 'lodash'
import { ContractsGrid } from 'web/components/contract/contracts-grid' import { ContractsGrid } from 'web/components/contract/contracts-grid'
import { useContracts } from 'web/hooks/use-contracts' import { useContracts } from 'web/hooks/use-contracts'
import { Sort, useQuery, useSort } from 'web/hooks/use-sort-and-query-params' import {
usePersistentState,
urlParamsStore,
} from 'web/hooks/use-persistent-state'
const MAX_CONTRACTS_RENDERED = 100 const MAX_CONTRACTS_RENDERED = 100
@ -17,8 +21,10 @@ export default function ContractSearchFirestore(props: {
}) { }) {
const contracts = useContracts() const contracts = useContracts()
const { additionalFilter } = props const { additionalFilter } = props
const [query, setQuery] = useQuery('', { useUrl: true }) const router = useRouter()
const [sort, setSort] = useSort('score', { useUrl: true }) const store = urlParamsStore(router)
const [query, setQuery] = usePersistentState('', { key: 'q', store })
const [sort, setSort] = usePersistentState('score', { key: 'sort', store })
let matches = (contracts ?? []).filter((c) => let matches = (contracts ?? []).filter((c) =>
searchInAny( searchInAny(
@ -91,7 +97,7 @@ export default function ContractSearchFirestore(props: {
<select <select
className="select select-bordered" className="select select-bordered"
value={sort} value={sort}
onChange={(e) => setSort(e.target.value as Sort)} onChange={(e) => setSort(e.target.value)}
> >
<option value="score">Trending</option> <option value="score">Trending</option>
<option value="newest">Newest</option> <option value="newest">Newest</option>

View File

@ -12,7 +12,7 @@ import { track } from 'web/lib/service/analytics'
import { authenticateOnServer } from 'web/lib/firebase/server-auth' import { authenticateOnServer } from 'web/lib/firebase/server-auth'
import { useSaveReferral } from 'web/hooks/use-save-referral' import { useSaveReferral } from 'web/hooks/use-save-referral'
import { GetServerSideProps } from 'next' import { GetServerSideProps } from 'next'
import { Sort } from 'web/hooks/use-sort-and-query-params' import { Sort } from 'web/components/contract-search'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { Spacer } from 'web/components/layout/spacer' import { Spacer } from 'web/components/layout/spacer'
import { useMemberGroups } from 'web/hooks/use-group' import { useMemberGroups } from 'web/hooks/use-group'