From 882fa52244956c2c908191390079a30d5b0e9e15 Mon Sep 17 00:00:00 2001 From: neopostmodern Date: Sun, 7 Aug 2022 23:59:42 +0200 Subject: [PATCH] feat: only fetch data since last fetch, closes #122 - 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` --- .../renderer/components/AdvancedSettings.tsx | 2 + .../components/NetworkOperationsIndicator.tsx | 9 +- .../containers/NotesPage/NotesPage.tsx | 42 ++++- client/src/renderer/containers/TagsPage.tsx | 12 +- .../renderer/hooks/useEntitiesUpdatedSince.ts | 143 ++++++++++++++++++ client/src/renderer/hooks/useFilteredNotes.ts | 2 +- client/src/renderer/utils/useDataState.ts | 27 +++- schema.graphql | 7 + server/lib/resolvers.js | 18 +++ server/lib/schema.js | 6 + 10 files changed, 246 insertions(+), 22 deletions(-) create mode 100644 client/src/renderer/hooks/useEntitiesUpdatedSince.ts diff --git a/client/src/renderer/components/AdvancedSettings.tsx b/client/src/renderer/components/AdvancedSettings.tsx index b3f8556..ebee59c 100644 --- a/client/src/renderer/components/AdvancedSettings.tsx +++ b/client/src/renderer/components/AdvancedSettings.tsx @@ -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'; @@ -17,6 +18,7 @@ const AdvancedSettings = () => { actionTitle="Clear cache" actionHandler={() => { clearApolloCache(); + localStorage.removeItem(ENTITIES_UPDATED_SINCE_STORAGE_KEY); window.location.reload(); }} /> diff --git a/client/src/renderer/components/NetworkOperationsIndicator.tsx b/client/src/renderer/components/NetworkOperationsIndicator.tsx index 0304e27..db6ff4e 100644 --- a/client/src/renderer/components/NetworkOperationsIndicator.tsx +++ b/client/src/renderer/components/NetworkOperationsIndicator.tsx @@ -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 { @@ -28,7 +28,7 @@ const NetworkOperationsIndicator = ({ query, mutation, }: { - query?: PolicedData; + query?: LazyPolicedData; mutation?: MutationResult; }) => { const [backgroundLoadingState, setBackgroundLoadingState] = @@ -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; @@ -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); } @@ -65,7 +66,7 @@ const NetworkOperationsIndicator = ({ }, DISPLAY_BACKGROUND_LOAD_NOTIFICATION_LENGTH); } } - }, [savingState, setSavingState, isSaving, isLoadingBackground]); + }, [savingState, setSavingState, isSaving, isLoadingBackground, isLoading]); let message; if (isError) { diff --git a/client/src/renderer/containers/NotesPage/NotesPage.tsx b/client/src/renderer/containers/NotesPage/NotesPage.tsx index 0f32f20..1daa519 100644 --- a/client/src/renderer/containers/NotesPage/NotesPage.tsx +++ b/client/src/renderer/containers/NotesPage/NotesPage.tsx @@ -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'; @@ -11,7 +12,9 @@ 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'; @@ -19,11 +22,15 @@ 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` @@ -59,7 +66,7 @@ const NotesPage: React.FC = () => { const moreElement = useRef(null); const notesQuery = useDataState( useQuery(NOTES_QUERY, { - fetchPolicy: gracefulNetworkPolicy(), + fetchPolicy: 'cache-only', }) ); const filteredNotesQueryWrapper = useFilteredNotes( @@ -68,6 +75,8 @@ const NotesPage: React.FC = () => { archiveState ); + const entitiesUpdatedSince = useEntitiesUpdatedSince(); + useEffect(() => { const handleScrollEvent = () => { if (!moreElement.current) { @@ -128,9 +137,27 @@ const NotesPage: React.FC = () => { let primaryActions = null; if (filteredNotesQueryWrapper.state === DataState.ERROR) { - content.push( - - ); + if ( + filteredNotesQueryWrapper.error.extraInfo === OFFLINE_CACHE_MISS && + (entitiesUpdatedSince.state === DataState.UNCALLED || + entitiesUpdatedSince.state === DataState.LOADING) + ) { + content.push( + + + + + + Loading notes for the first time, this might take a while... + + + + ); + } else { + content.push( + + ); + } } else if (filteredNotesQueryWrapper.state === DataState.DATA) { if (filteredNotesQueryWrapper.loadingBackground) { content.push( @@ -151,7 +178,10 @@ const NotesPage: React.FC = () => { ); content.push( - + ); content.push( diff --git a/client/src/renderer/containers/TagsPage.tsx b/client/src/renderer/containers/TagsPage.tsx index 940cf4d..f2a3565 100644 --- a/client/src/renderer/containers/TagsPage.tsx +++ b/client/src/renderer/containers/TagsPage.tsx @@ -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'; @@ -115,13 +115,19 @@ const TagsPage: React.FC<{}> = () => { (state) => state.userInterface.tagsLayout ); const tagsQuery = useDataState( - useQuery(TAGS_QUERY, { fetchPolicy: gracefulNetworkPolicy() }) + useQuery(TAGS_QUERY, { fetchPolicy: 'cache-only' }) ); const [updateTag] = useMutation( 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(); const [droppableColor, setDroppableColor] = useState(); const colorTagGroups = useMemo(() => { @@ -234,7 +240,7 @@ const TagsPage: React.FC<{}> = () => { } wide={layout === TagsLayout.COLOR_COLUMN_LAYOUT} > - + {tagsQuery.state === DataState.DATA && renderTags(tagsQuery.data.tags)} ); diff --git a/client/src/renderer/hooks/useEntitiesUpdatedSince.ts b/client/src/renderer/hooks/useEntitiesUpdatedSince.ts new file mode 100644 index 0000000..822214f --- /dev/null +++ b/client/src/renderer/hooks/useEntitiesUpdatedSince.ts @@ -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({ + 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({ + 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; diff --git a/client/src/renderer/hooks/useFilteredNotes.ts b/client/src/renderer/hooks/useFilteredNotes.ts index 414a014..7f15400 100644 --- a/client/src/renderer/hooks/useFilteredNotes.ts +++ b/client/src/renderer/hooks/useFilteredNotes.ts @@ -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) => diff --git a/client/src/renderer/utils/useDataState.ts b/client/src/renderer/utils/useDataState.ts index 1e07d38..d246889 100644 --- a/client/src/renderer/utils/useDataState.ts +++ b/client/src/renderer/utils/useDataState.ts @@ -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'; @@ -24,7 +29,7 @@ export type PolicedData = | { state: DataState.DATA; data: QueryData; loadingBackground: boolean } | { state: DataState.ERROR; error: ApolloError }; -type LazyPolicedData = +export type LazyPolicedData = | PolicedData | { state: DataState.UNCALLED }; @@ -79,13 +84,19 @@ function useDataState( ]; } let dataState: PolicedData; - 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, diff --git a/schema.graphql b/schema.graphql index 79c7fda..573ac48 100644 --- a/schema.graphql +++ b/schema.graphql @@ -11,6 +11,12 @@ type Credentials { scalar Date +type EntitiesUpdatedSince { + notes: [Note!]! + tags: [Tag!]! + timestamp: Date! +} + interface INote { _id: ID! archivedAt: Date @@ -88,6 +94,7 @@ enum NoteType { type Query { currentUser: User + entitiesUpdatedSince(updatedSince: Date!): EntitiesUpdatedSince! link(linkId: ID): Link! links(limit: Int, offset: Int): [Link!]! notes(limit: Int, offset: Int): [Note!]! diff --git a/server/lib/resolvers.js b/server/lib/resolvers.js index d148fd5..a9cf330 100644 --- a/server/lib/resolvers.js +++ b/server/lib/resolvers.js @@ -83,6 +83,24 @@ const rootResolvers = { .exec() .then(typeEnumFixer) }, + async entitiesUpdatedSince(root, { updatedSince }, context) { + if (!context.user) { + throw new Error('Need to be logged in to fetch links.') + } + const updatedSinceAsDate = new Date(updatedSince) + const filter = { + user: context.user, + updatedAt: { $gt: updatedSinceAsDate }, + } + return { + notes: await Note.find(filter) + .sort({ createdAt: -1 }) + .exec() + .then(typeEnumFixer), + tags: Tag.find(filter).exec(), + timestamp: new Date(), + } + }, async link(root, { linkId }, context) { if (!context.user) { throw new Error('Need to be logged in to fetch a link.') diff --git a/server/lib/schema.js b/server/lib/schema.js index 80f140b..c5da7de 100644 --- a/server/lib/schema.js +++ b/server/lib/schema.js @@ -126,6 +126,11 @@ export default gql` } union Note = Link | Text + type EntitiesUpdatedSince { + notes: [Note!]! + tags: [Tag!]! + timestamp: Date! + } type Query { # Return the currently logged in user, or null if nobody is logged in @@ -142,6 +147,7 @@ export default gql` ): [Link!]! notes(offset: Int, limit: Int): [Note!]! + entitiesUpdatedSince(updatedSince: Date!): EntitiesUpdatedSince! link(linkId: ID): Link! text(textId: ID): Text!