Skip to content

Commit

Permalink
feat: only fetch data since last fetch, closes #122
Browse files Browse the repository at this point in the history
- add endpoint and graphQL types
- create hook `useEntitesUpdatedSince` which updates cache, pages query with `cache-only`
- store `updatedSince` in *localStorage* for now
- used on `NotesPage` and `TagsPage`
- missing client typescript types for graphQL types, blocked on #159
- filter notes with `deletedAt` in `useFilteredNotes` since deleted notes sometimes get sent to client now (to indicate deletion)
- `useDataState` treats loading state like background loads, as happens with `refetch`
  • Loading branch information
neopostmodern committed Aug 7, 2022
1 parent a846b33 commit 882fa52
Show file tree
Hide file tree
Showing 10 changed files with 246 additions and 22 deletions.
2 changes: 2 additions & 0 deletions client/src/renderer/components/AdvancedSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useApolloClient } from '@apollo/client';
import { Typography } from '@mui/material';
import { ENTITIES_UPDATED_SINCE_STORAGE_KEY } from '../hooks/useEntitiesUpdatedSince';
import { clearApolloCache } from '../utils/cache';
import SettingsEntry from './SettingsEntry';

Expand All @@ -17,6 +18,7 @@ const AdvancedSettings = () => {
actionTitle="Clear cache"
actionHandler={() => {
clearApolloCache();
localStorage.removeItem(ENTITIES_UPDATED_SINCE_STORAGE_KEY);
window.location.reload();
}}
/>
Expand Down
9 changes: 5 additions & 4 deletions client/src/renderer/components/NetworkOperationsIndicator.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { MutationResult } from '@apollo/client';
import { Box, Typography } from '@mui/material';
import { PropsWithChildren, useEffect, useState } from 'react';
import { DataState, PolicedData } from '../utils/useDataState';
import { DataState, LazyPolicedData } from '../utils/useDataState';
import ErrorSnackbar from './ErrorSnackbar';

enum NetworkPhase {
Expand All @@ -28,7 +28,7 @@ const NetworkOperationsIndicator = ({
query,
mutation,
}: {
query?: PolicedData<any>;
query?: LazyPolicedData<any>;
mutation?: MutationResult;
}) => {
const [backgroundLoadingState, setBackgroundLoadingState] =
Expand All @@ -37,6 +37,7 @@ const NetworkOperationsIndicator = ({
NetworkPhase.IDLE
);

const isLoading = query?.state === DataState.LOADING;
const isLoadingBackground =
query?.state === DataState.DATA && query?.loadingBackground;
const isSaving = mutation?.loading;
Expand All @@ -47,7 +48,7 @@ const NetworkOperationsIndicator = ({
if (savingState === NetworkPhase.IDLE) {
setSavingState(NetworkPhase.IN_PROGRESS);
}
} else if (isLoadingBackground) {
} else if (isLoading || isLoadingBackground) {
if (backgroundLoadingState === NetworkPhase.IDLE) {
setBackgroundLoadingState(NetworkPhase.IN_PROGRESS);
}
Expand All @@ -65,7 +66,7 @@ const NetworkOperationsIndicator = ({
}, DISPLAY_BACKGROUND_LOAD_NOTIFICATION_LENGTH);
}
}
}, [savingState, setSavingState, isSaving, isLoadingBackground]);
}, [savingState, setSavingState, isSaving, isLoadingBackground, isLoading]);

let message;
if (isError) {
Expand Down
42 changes: 36 additions & 6 deletions client/src/renderer/containers/NotesPage/NotesPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { gql, useQuery } from '@apollo/client';
import { CircularProgress, Stack, Typography } from '@mui/material';
import Mousetrap from 'mousetrap';
import React, { useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
Expand All @@ -11,19 +12,25 @@ import {
increaseInfiniteScroll,
LinkLayout,
} from '../../actions/userInterface';
import Centered from '../../components/Centered';
import FatalApolloError from '../../components/FatalApolloError';
import Gap from '../../components/Gap';
import NetworkOperationsIndicator, {
NetworkIndicatorContainer,
} from '../../components/NetworkOperationsIndicator';
import NoteBatchEditingBar from '../../components/NoteBatchEditingBar';
import NotesList from '../../components/NotesList';
import NotesMenu from '../../components/NotesMenu';
import { NotesForList } from '../../generated/NotesForList';
import useEntitiesUpdatedSince from '../../hooks/useEntitiesUpdatedSince';
import useFilteredNotes from '../../hooks/useFilteredNotes';
import { RootState } from '../../reducers';
import { UserInterfaceStateType } from '../../reducers/userInterface';
import { BASE_NOTE_FRAGMENT } from '../../utils/sharedQueriesAndFragments';
import useDataState, { DataState } from '../../utils/useDataState';
import useDataState, {
DataState,
OFFLINE_CACHE_MISS,
} from '../../utils/useDataState';
import ComplexLayout from '../ComplexLayout';

export const NOTES_QUERY = gql`
Expand Down Expand Up @@ -59,7 +66,7 @@ const NotesPage: React.FC = () => {
const moreElement = useRef<HTMLDivElement | null>(null);
const notesQuery = useDataState(
useQuery<NotesForList>(NOTES_QUERY, {
fetchPolicy: gracefulNetworkPolicy(),
fetchPolicy: 'cache-only',
})
);
const filteredNotesQueryWrapper = useFilteredNotes(
Expand All @@ -68,6 +75,8 @@ const NotesPage: React.FC = () => {
archiveState
);

const entitiesUpdatedSince = useEntitiesUpdatedSince();

useEffect(() => {
const handleScrollEvent = () => {
if (!moreElement.current) {
Expand Down Expand Up @@ -128,9 +137,27 @@ const NotesPage: React.FC = () => {
let primaryActions = null;

if (filteredNotesQueryWrapper.state === DataState.ERROR) {
content.push(
<FatalApolloError key="error" query={filteredNotesQueryWrapper} />
);
if (
filteredNotesQueryWrapper.error.extraInfo === OFFLINE_CACHE_MISS &&
(entitiesUpdatedSince.state === DataState.UNCALLED ||
entitiesUpdatedSince.state === DataState.LOADING)
) {
content.push(
<Centered key="first-load">
<Stack alignItems="center">
<CircularProgress color="inherit" disableShrink />
<Gap vertical={1} />
<Typography variant="caption">
Loading notes for the first time, this might take a while...
</Typography>
</Stack>
</Centered>
);
} else {
content.push(
<FatalApolloError key="error" query={filteredNotesQueryWrapper} />
);
}
} else if (filteredNotesQueryWrapper.state === DataState.DATA) {
if (filteredNotesQueryWrapper.loadingBackground) {
content.push(
Expand All @@ -151,7 +178,10 @@ const NotesPage: React.FC = () => {
);

content.push(
<NetworkOperationsIndicator key="refresh-indicator" query={notesQuery} />
<NetworkOperationsIndicator
key="refresh-indicator"
query={entitiesUpdatedSince}
/>
);

content.push(
Expand Down
12 changes: 9 additions & 3 deletions client/src/renderer/containers/TagsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import NetworkOperationsIndicator from '../components/NetworkOperationsIndicator
import Tag from '../components/Tag';
import { TagsQuery, TagsQuery_tags } from '../generated/TagsQuery';
import { UpdateTag2, UpdateTag2Variables } from '../generated/UpdateTag2';
import useEntitiesUpdatedSince from '../hooks/useEntitiesUpdatedSince';
import { RootState } from '../reducers';
import { breakpointDesktop } from '../styles/constants';
import colorTools, { ColorCache } from '../utils/colorTools';
import gracefulNetworkPolicy from '../utils/gracefulNetworkPolicy';
import { stripTypename } from '../utils/graphQl';
import { BASE_TAG_FRAGMENT } from '../utils/sharedQueriesAndFragments';
import useDataState, { DataState } from '../utils/useDataState';
Expand Down Expand Up @@ -115,13 +115,19 @@ const TagsPage: React.FC<{}> = () => {
(state) => state.userInterface.tagsLayout
);
const tagsQuery = useDataState(
useQuery<TagsQuery>(TAGS_QUERY, { fetchPolicy: gracefulNetworkPolicy() })
useQuery<TagsQuery>(TAGS_QUERY, { fetchPolicy: 'cache-only' })
);

const [updateTag] = useMutation<UpdateTag2, UpdateTag2Variables>(
UPDATE_TAG_MUTATION
);

const entitiesUpdatedSince = useEntitiesUpdatedSince();
// todo: display something nice in case there are no tags in cache yet and
// useEntitiesUpdatedSince actually performs the first fetch
// see e.g. NotesPage, but less relevant here so pending for now
// should find a universal solution

const [draggedTag, setDraggedTag] = useState<TagsQuery_tags>();
const [droppableColor, setDroppableColor] = useState<string | null>();
const colorTagGroups = useMemo<ColorTagGroups>(() => {
Expand Down Expand Up @@ -234,7 +240,7 @@ const TagsPage: React.FC<{}> = () => {
}
wide={layout === TagsLayout.COLOR_COLUMN_LAYOUT}
>
<NetworkOperationsIndicator query={tagsQuery} />
<NetworkOperationsIndicator query={entitiesUpdatedSince} />
{tagsQuery.state === DataState.DATA && renderTags(tagsQuery.data.tags)}
</ComplexLayout>
);
Expand Down
143 changes: 143 additions & 0 deletions client/src/renderer/hooks/useEntitiesUpdatedSince.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { gql, useApolloClient, useLazyQuery } from '@apollo/client';
import { useEffect } from 'react';
import { NOTES_QUERY } from '../containers/NotesPage/NotesPage';
import { TAGS_QUERY } from '../containers/TagsPage';
import { NotesForList } from '../generated/NotesForList';
import { TagsQuery } from '../generated/TagsQuery';
import {
BASE_NOTE_FRAGMENT,
BASE_TAG_FRAGMENT,
} from '../utils/sharedQueriesAndFragments';
import useDataState, { DataState } from '../utils/useDataState';
import useIsOnline from './useIsOnline';

export const ENTITIES_UPDATED_SINCE_QUERY = gql`
query EntitiesUpdatedSince($updatedSince: Date!) {
entitiesUpdatedSince(updatedSince: $updatedSince) {
notes {
...BaseNote
}
tags {
...BaseTag
}
timestamp
}
}
${BASE_NOTE_FRAGMENT}
${BASE_TAG_FRAGMENT}
`;
export const ENTITIES_UPDATED_SINCE_STORAGE_KEY = 'updatedSince';
const getUpdatedSince = () =>
parseInt(localStorage.getItem(ENTITIES_UPDATED_SINCE_STORAGE_KEY) as any) ||
0;
const ENTITIES_UPDATED_SINCE_INTERVAL_MS = 60 * 1000;

// todo: types, blocked on graphQL/apollo type generation not working (2022-08-07)
const useEntitiesUpdatedSince = () => {
const apolloClient = useApolloClient();
const isOnline = useIsOnline();

const [fetchEntitiesUpdatedSince, entitiesUpdatedSince] = useDataState(
useLazyQuery(ENTITIES_UPDATED_SINCE_QUERY, {
onCompleted({ entitiesUpdatedSince }) {
const { cache } = apolloClient;
const lastUpdate = getUpdatedSince();

if (entitiesUpdatedSince.notes.length) {
const notesCacheValue = cache.readQuery<NotesForList>({
query: NOTES_QUERY,
});

let cachedNotes;

if (lastUpdate) {
if (!notesCacheValue) {
throw Error(
'[NotesPage: ENTITIES_UPDATED_SINCE_QUERY.onCompleted] Failed to read cache for notes.'
);
}

entitiesUpdatedSince.notes.forEach((note) => {
if (note.createdAt > lastUpdate) {
cachedNotes = [...notesCacheValue.notes, note];
}
});
} else {
cachedNotes = entitiesUpdatedSince.notes;
}

cache.writeQuery({
query: NOTES_QUERY,
data: { notes: cachedNotes },
});
}

if (entitiesUpdatedSince.tags.length) {
const tagsCacheValue = cache.readQuery<TagsQuery>({
query: TAGS_QUERY,
});

let cachedTags;

if (lastUpdate) {
if (!tagsCacheValue) {
throw Error(
'[NotesPage: ENTITIES_UPDATED_SINCE_QUERY.onCompleted] Failed to read cache for tags.'
);
}

entitiesUpdatedSince.notes.forEach((tag) => {
if (tag.createdAt > lastUpdate) {
cachedTags = [...tagsCacheValue.tags, tag];
}
});
} else {
cachedTags = entitiesUpdatedSince.tags;
}

cache.writeQuery({
query: TAGS_QUERY,
data: { tags: cachedTags },
});
}

localStorage.setItem(
ENTITIES_UPDATED_SINCE_STORAGE_KEY,
entitiesUpdatedSince.timestamp
);
},
})
);

useEffect(() => {
if (!isOnline) {
return;
}

fetchEntitiesUpdatedSince({
variables: {
updatedSince: getUpdatedSince(),
},
});
}, [fetchEntitiesUpdatedSince, isOnline]);

useEffect(() => {
if (entitiesUpdatedSince.state !== DataState.DATA || !isOnline) {
return;
}

const notesRefetchInterval = setInterval(() => {
entitiesUpdatedSince.refetch({
updatedSince: getUpdatedSince(),
});
}, ENTITIES_UPDATED_SINCE_INTERVAL_MS);

return () => {
clearInterval(notesRefetchInterval);
};
}, [entitiesUpdatedSince, isOnline]);

return entitiesUpdatedSince;
};

export default useEntitiesUpdatedSince;
2 changes: 1 addition & 1 deletion client/src/renderer/hooks/useFilteredNotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const filterNotes = (
searchQuery: string,
archiveState: ArchiveState
): FilteredNotes => {
let filteredNotes = notes;
let filteredNotes = notes.filter((note) => !note.deletedAt);
if (searchQuery.length !== 0) {
filteredNotes = filteredNotes.filter(
(note) =>
Expand Down
27 changes: 19 additions & 8 deletions client/src/renderer/utils/useDataState.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { ApolloError, QueryResult, QueryTuple } from '@apollo/client';
import {
ApolloError,
NetworkStatus,
QueryResult,
QueryTuple,
} from '@apollo/client';
import { pick } from 'lodash';
import { useMemo } from 'react';

Expand All @@ -24,7 +29,7 @@ export type PolicedData<QueryData> =
| { state: DataState.DATA; data: QueryData; loadingBackground: boolean }
| { state: DataState.ERROR; error: ApolloError };

type LazyPolicedData<QueryData> =
export type LazyPolicedData<QueryData> =
| PolicedData<QueryData>
| { state: DataState.UNCALLED };

Expand Down Expand Up @@ -79,13 +84,19 @@ function useDataState<QueryData, QueryVariables>(
];
}
let dataState: PolicedData<QueryData>;
if (error) {
dataState = { state: DataState.ERROR, error };
} else if (data) {
dataState = { state: DataState.DATA, data, loadingBackground: loading };
} else if (queryResult.loading) {
if (data) {
dataState = {
state: DataState.DATA,
data,
loadingBackground:
loading ||
additionalQueryFields.networkStatus === NetworkStatus.refetch,
};
} else if (loading) {
dataState = { state: DataState.LOADING };
} else if (!navigator.onLine) {
} else if (error) {
dataState = { state: DataState.ERROR, error };
} else if (queryResult.observable.options.fetchPolicy === 'cache-only') {
// see https://github.com/apollographql/apollo-client/issues/7505 (cache-only queries throw no errors when they can't be fulfilled)
dataState = {
state: DataState.ERROR,
Expand Down
Loading

0 comments on commit 882fa52

Please sign in to comment.