Skip to content

Commit

Permalink
fix: polish SWR global state (#141)
Browse files Browse the repository at this point in the history
* deps: upgrade SWR to 1.0.0

* fix: replace `useSWRInfinite` imports

* fix: replace `revalidateOnMount` with `revalidateIfStale`

* feat: create generic `useFetch` SWR hook

* feat: mutate global highlights data

* fix(hooks/fetch): update `Mutated` type definition

* fix(api/highlights): sort imports
  • Loading branch information
nicholaschiang committed Sep 1, 2021
1 parent 174c2fb commit 992f39f
Show file tree
Hide file tree
Showing 16 changed files with 205 additions and 166 deletions.
Binary file removed .yarn/cache/swr-npm-0.5.6-6a1f3bdae2-3522cc3de9.zip
Binary file not shown.
Binary file not shown.
25 changes: 18 additions & 7 deletions components/article.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import cn from 'classnames';
import HighlightIcon from 'components/icons/highlight';
import TweetIcon from 'components/icons/tweet';

import { Highlight } from 'lib/model/highlight';
import { Highlight, HighlightWithMessage } from 'lib/model/highlight';
import { Message } from 'lib/model/message';
import { fetcher } from 'lib/fetch';
import fromNode from 'lib/xpath';
import highlightHTML from 'lib/highlight';
import numid from 'lib/utils/numid';
import useFetch from 'lib/hooks/fetch';
import { useUser } from 'lib/context/user';

interface Position {
Expand All @@ -29,6 +30,10 @@ export default function Article({ message }: ArticleProps): JSX.Element {
const { data } = useSWR<Highlight[]>(
message ? `/api/messages/${message.id}/highlights` : null
);
const { mutateAll, mutateSingle } = useFetch<HighlightWithMessage>(
'highlight',
'/api/highlights'
);

const [highlight, setHighlight] = useState<Highlight>();
const [position, setPosition] = useState<Position>();
Expand Down Expand Up @@ -101,23 +106,29 @@ export default function Article({ message }: ArticleProps): JSX.Element {
window.analytics?.track('Highlight Created');
const url = `/api/messages/${message.id}/highlights`;
const add = (p?: Highlight[]) => (p ? [...p, highlight] : [highlight]);
await mutate(url, add, false);
await Promise.all([
mutate(url, add, false),
mutateSingle({ ...highlight, message }, false),
]);
await fetcher(url, 'post', highlight);
await mutate(url);
await Promise.all([mutateAll(), mutate(url)]);
} else {
window.analytics?.track('Highlight Deleted');
const url = `/api/messages/${message.id}/highlights`;
const deleted = { ...highlight, deleted: true };
const remove = (p?: Highlight[]) => {
const idx = p?.findIndex((h) => h.id === highlight.id);
if (!p || !idx || idx < 0) return p;
const deleted = { ...highlight, deleted: true };
return [...p.slice(0, idx), deleted, ...p.slice(idx + 1)];
};
await mutate(url, remove, false);
await Promise.all([
mutate(url, remove, false),
mutateSingle({ ...deleted, message }, false),
]);
await fetcher(`/api/highlights/${highlight.id}`, 'delete');
await mutate(url);
await Promise.all([mutateAll(), mutate(url)]);
}
}, [message, highlight, data]);
}, [message, highlight, data, mutateAll, mutateSingle]);
const [tweet, setTweet] = useState<string>('');
useEffect(() => {
setTweet((prev) =>
Expand Down
8 changes: 6 additions & 2 deletions components/feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import Section from 'components/section';
import { Message } from 'lib/model/message';
import { MessagesQuery } from 'lib/model/query';
import { isSameDay } from 'lib/utils';
import useMessages from 'lib/hooks/messages';
import useFetch from 'lib/hooks/fetch';
import useNow from 'lib/hooks/now';

interface FeedSectionProps {
Expand All @@ -28,7 +28,11 @@ function FeedSection({ date, messages }: FeedSectionProps): JSX.Element {
}

export default function Feed(query: MessagesQuery): JSX.Element {
const { data, setSize, hasMore, href } = useMessages(query);
const { data, setSize, hasMore, href } = useFetch<Message>(
'message',
'/api/messages',
query
);
const sections = useMemo(() => {
const newSections: FeedSectionProps[] = [];
data?.flat().forEach((message) => {
Expand Down
112 changes: 112 additions & 0 deletions lib/hooks/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
} from 'react';
import useSWRInfinite, {
SWRInfiniteConfiguration,
SWRInfiniteResponse,
} from 'swr/infinite';
import { captureException } from '@sentry/nextjs';
import { mutate as globalMutate } from 'swr';

import { Callback } from 'lib/model/callback';
import { HITS_PER_PAGE } from 'lib/model/query';
import { isHighlightWithMessage } from 'lib/model/highlight';
import { isMessage } from 'lib/model/message';

export type Type = 'highlight' | 'message' | 'account';
export type Mutated = { [type in Type]: boolean };
export type Fetch<T> = Omit<SWRInfiniteResponse<T[]>, 'mutate'> & {
mutated: (mute: boolean) => void;
href: string;
hasMore: boolean;
} & { mutateAll: SWRInfiniteResponse<T[]>['mutate'] } & {
mutateSingle: (
resource: T,
revalidate: boolean
) => ReturnType<SWRInfiniteResponse<T[]>['mutate']>;
};

export const MutatedContext = createContext({
mutated: { highlight: false, message: false, account: false },
setMutated: (() => {}) as Callback<Mutated>,
});

// Fetch wraps `useSWRInfinite` and keeps track of which resources are being
// fetched (`highlight`). It can then be reused to mutate a single resource and
// unpause revalidations once that mutation has been updated server-side.
export default function useFetch<T extends { id: string | number }>(
type: Type = 'message',
url: string = '/api/messages',
query: Record<string, string> = {},
options: SWRInfiniteConfiguration = {}
): Fetch<T> {
const href = useMemo(() => {
const params = new URLSearchParams(query);
const queryString = params.toString();
return queryString ? `${url}?${queryString}` : url;
}, [query, url]);
const getKey = useCallback(
(pageIdx: number, prev: T[] | null) => {
if (prev && !prev.length) return null;
if (!prev || pageIdx === 0) return href;
return `${href}${href.includes('?') ? '&' : '?'}page=${pageIdx}`;
},
[href]
);
const { mutated, setMutated } = useContext(MutatedContext);
const { data, mutate, ...rest } = useSWRInfinite<T[]>(getKey, {
revalidateIfStale: !mutated[type],
revalidateOnFocus: !mutated[type],
revalidateOnReconnect: !mutated[type],
...options,
});
useEffect(() => {
data?.flat().forEach((resource) => {
void globalMutate(`${url}/${resource.id}`, resource, false);
});
}, [data, url]);
return {
...rest,
data,
href,
hasMore:
!data || data[data.length - 1].length === HITS_PER_PAGE || mutated[type],
mutateAll(...args: Parameters<typeof mutate>): ReturnType<typeof mutate> {
const revalidate = typeof args[1] === 'boolean' ? args[1] : true;
setMutated((prev) => ({ ...prev, [type]: !revalidate }));
return mutate(...args);
},
mutateSingle(resource: T, revalidate: boolean): ReturnType<typeof mutate> {
setMutated((prev) => ({ ...prev, [type]: !revalidate }));
return mutate(
(response?: T[][]) =>
response?.map((res: T[]) => {
const idx = res.findIndex((m) => m.id === resource.id);
// TODO: Insert this new resource into the correct sort position.
if (idx < 0) return [resource, ...res];
try {
if (isMessage(resource) && resource.archived)
return [...res.slice(0, idx), ...res.slice(idx + 1)];
} catch (e) {
captureException(e);
}
try {
if (isHighlightWithMessage(resource) && resource.deleted)
return [...res.slice(0, idx), ...res.slice(idx + 1)];
} catch (e) {
captureException(e);
}
return [...res.slice(0, idx), resource, ...res.slice(idx + 1)];
}),
revalidate
);
},
mutated(mute: boolean): void {
setMutated((prev) => ({ ...prev, [type]: mute }));
},
};
}
97 changes: 0 additions & 97 deletions lib/hooks/messages.ts

This file was deleted.

22 changes: 22 additions & 0 deletions lib/model/highlight.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Message, isMessage } from 'lib/model/message';
import { isJSON } from 'lib/model/json';

export interface Highlight {
Expand Down Expand Up @@ -26,3 +27,24 @@ export function isHighlight(highlight: unknown): highlight is Highlight {
(highlight.deleted === undefined || typeof highlight.deleted === 'boolean')
);
}

export type HighlightWithMessage = Omit<Highlight, 'message'> & {
message: Message;
};

export function isHighlightWithMessage(
highlight: unknown
): highlight is HighlightWithMessage {
if (!isJSON(highlight)) return false;
return (
isMessage(highlight.message) &&
typeof highlight.user === 'number' &&
typeof highlight.id === 'number' &&
typeof highlight.start === 'string' &&
typeof highlight.startOffset === 'number' &&
typeof highlight.end === 'string' &&
typeof highlight.endOffset === 'number' &&
typeof highlight.text === 'string' &&
(highlight.deleted === undefined || typeof highlight.deleted === 'boolean')
);
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"react-infinite-scroll-component": "^6.1.0",
"reading-time": "^1.3.0",
"rfdc": "^1.3.0",
"swr": "^0.5.6",
"swr": "^1.0.0",
"utf8": "^3.0.0",
"winston": "^3.3.3"
},
Expand Down
12 changes: 8 additions & 4 deletions pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import { dequal } from 'dequal';
import NProgress from 'components/nprogress';
import Segment from 'components/segment';

import { Mutated, MutatedContext } from 'lib/hooks/fetch';
import { Theme, ThemeContext } from 'lib/context/theme';
import { APIError } from 'lib/model/error';
import { CallbackParam } from 'lib/model/callback';
import { MessagesMutatedContext } from 'lib/hooks/messages';
import { User } from 'lib/model/user';
import { UserContext } from 'lib/context/user';
import { fetcher } from 'lib/fetch';
Expand Down Expand Up @@ -137,17 +137,21 @@ export default function App({ Component, pageProps }: AppProps): JSX.Element {
localStorage.setItem('theme', theme);
}, [theme]);

const [mutated, setMutated] = useState<boolean>(false);
const [mutated, setMutated] = useState<Mutated>({
highlight: false,
message: false,
account: false,
});

return (
<UserContext.Provider value={{ user, setUser, setUserMutated, loggedIn }}>
<ThemeContext.Provider value={{ theme, setTheme }}>
<SWRConfig value={{ fetcher }}>
<Segment />
<NProgress />
<MessagesMutatedContext.Provider value={{ mutated, setMutated }}>
<MutatedContext.Provider value={{ mutated, setMutated }}>
<Component {...pageProps} />
</MessagesMutatedContext.Provider>
</MutatedContext.Provider>
</SWRConfig>
<style jsx global>{`
::selection {
Expand Down
6 changes: 1 addition & 5 deletions pages/api/highlights/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { NextApiRequest as Req, NextApiResponse as Res } from 'next';
import { withSentry } from '@sentry/nextjs';

import { APIErrorJSON } from 'lib/model/error';
import { Highlight } from 'lib/model/highlight';
import { Message } from 'lib/model/message';
import { HighlightWithMessage } from 'lib/model/highlight';
import { handle } from 'lib/api/error';
import handleSupabaseError from 'lib/api/db/error';
import logger from 'lib/api/logger';
Expand All @@ -13,9 +12,6 @@ import verifyAuth from 'lib/api/verify/auth';

export const HITS_PER_PAGE = 10;
export type HighlightsQuery = { page?: string };
export type HighlightWithMessage = Omit<Highlight, 'message'> & {
message: Message;
};

async function highlightsAPI(
req: Req,
Expand Down

0 comments on commit 992f39f

Please sign in to comment.