From b3c366e7c645262b15e6cb2b8ba8761e88cb16af Mon Sep 17 00:00:00 2001 From: Jacob Lowe Date: Wed, 28 Dec 2022 10:32:29 -0600 Subject: [PATCH 1/3] Observable cache, and fix for double use effect calls. (#1) * turn each query into an observable * hook up observable and to react client * trigger workflow * get tests working, and add awaited type * fix some of the types, with the data * add in cache policy * move types to root of client package * fix some test and properly resolve query promise * add in fetch first policy to the refetch * add in test for invalidation, and fix the issue with invalidation and lifecycle subscriptions * rename master to main --- .github/dependabot.yml | 22 +-- .github/workflows/github-pages.yml | 2 +- packages/client/package.json | 1 + packages/client/src/client/FinchClient.ts | 128 +++++++++++--- .../client/src/client/cache/Observable.ts | 39 +++++ .../client/src/client/cache/QueryCache.ts | 67 ++++---- packages/client/src/client/cache/index.ts | 2 +- packages/client/src/client/cache/types.ts | 20 --- packages/client/src/client/index.ts | 5 +- packages/client/src/client/types.ts | 31 ++++ packages/react/package.json | 4 +- packages/react/src/hooks/useQuery.test.ts | 68 +++++++- packages/react/src/hooks/useQuery.ts | 158 +++++++----------- packages/types/src/index.ts | 1 + packages/types/src/types.ts | 13 ++ packages/types/src/utils.ts | 10 ++ yarn.lock | 24 ++- 17 files changed, 393 insertions(+), 202 deletions(-) create mode 100644 packages/client/src/client/cache/Observable.ts delete mode 100644 packages/client/src/client/cache/types.ts create mode 100644 packages/client/src/client/types.ts create mode 100644 packages/types/src/utils.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4012b339..721d34f1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,7 +2,7 @@ version: 2 updates: - package-ecosystem: npm - directory: "/" + directory: '/' schedule: interval: weekly open-pull-requests-limit: 10 @@ -13,26 +13,26 @@ updates: versions: - 4.1.2 - 4.1.3 - - dependency-name: "@chakra-ui/react" + - dependency-name: '@chakra-ui/react' versions: - 1.5.0 - - dependency-name: "@docusaurus/preset-classic" + - dependency-name: '@docusaurus/preset-classic' versions: - 2.0.0-alpha.72 - - dependency-name: "@docusaurus/core" + - dependency-name: '@docusaurus/core' versions: - 2.0.0-alpha.72 - - dependency-name: "@types/chrome" + - dependency-name: '@types/chrome' versions: - 0.0.134 - - dependency-name: "@typescript-eslint/parser" + - dependency-name: '@typescript-eslint/parser' versions: - 4.15.2 - 4.17.0 - 4.18.0 - 4.19.0 - 4.20.0 - - dependency-name: "@typescript-eslint/eslint-plugin" + - dependency-name: '@typescript-eslint/eslint-plugin' versions: - 4.15.2 - 4.17.0 @@ -45,7 +45,7 @@ updates: - dependency-name: react versions: - 17.0.2 - - dependency-name: "@babel/preset-env" + - dependency-name: '@babel/preset-env' versions: - 7.12.11 - 7.12.16 @@ -55,7 +55,7 @@ updates: - 7.13.12 - 7.13.5 - 7.13.8 - - dependency-name: "@types/react" + - dependency-name: '@types/react' versions: - 17.0.3 - dependency-name: typescript @@ -68,9 +68,9 @@ updates: versions: - 8.1.0 - 8.1.1 - - dependency-name: "@types/jest" + - dependency-name: '@types/jest' versions: - 26.0.21 - - dependency-name: "@babel/preset-typescript" + - dependency-name: '@babel/preset-typescript' versions: - 7.12.17 diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 1f559bf9..4d286fd0 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -3,7 +3,7 @@ name: Deploy Github Pages on: push: branches: - - master + - main jobs: test: diff --git a/packages/client/package.json b/packages/client/package.json index 2fa672f3..de29a8dd 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -52,6 +52,7 @@ "dependencies": { "@finch-graphql/browser-polyfill": "^3.1.3", "@finch-graphql/types": "^3.1.3", + "eventemitter3": "^5.0.0", "graphql-tag": "^2.11.0", "uuid": "^8.3.2" }, diff --git a/packages/client/src/client/FinchClient.ts b/packages/client/src/client/FinchClient.ts index bfe34119..7f4925ff 100644 --- a/packages/client/src/client/FinchClient.ts +++ b/packages/client/src/client/FinchClient.ts @@ -1,6 +1,7 @@ import { DocumentNode, GraphQLFormattedError } from 'graphql'; import gql from 'graphql-tag'; -import { FinchCache, Listener } from './cache'; +import { Awaited, FinchCachePolicy } from '@finch-graphql/types'; +import { QueryCache } from './cache'; import { FinchDefaultPortName, FinchQueryOptions, @@ -10,9 +11,10 @@ import { isDocumentNode } from '../utils'; import { messageCreator, queryApi } from './client'; import { connectPort } from '@finch-graphql/browser-polyfill'; import { v4 } from 'uuid'; +import { FinchCacheStatus, FinchQueryObservable } from './types'; interface FinchClientOptions { - cache?: FinchCache; + cache?: QueryCache; id?: string; messageKey?: string; portName?: string; @@ -20,6 +22,7 @@ interface FinchClientOptions { messageTimeout?: number; autoStart?: boolean; maxPortTimeoutCount?: number; + cachePolicy?: FinchCachePolicy; } export enum FinchClientStatus { @@ -35,7 +38,6 @@ export enum FinchClientStatus { * are made and also any query caching. */ export class FinchClient { - private cache: FinchCache | undefined; private id: string | undefined; private messageKey: string | undefined; private port: browser.runtime.Port | chrome.runtime.Port | null; @@ -46,7 +48,13 @@ export class FinchClient { private portTimeoutCount = 0; private maxPortTimeoutCount = 10; private cancellableQueries: Set<() => void> = new Set(); + private subscriptions: WeakMap< + FinchQueryObservable, + () => void + > = new WeakMap(); + private cachePolicy: FinchCachePolicy; public status = FinchClientStatus.Idle; + public cache: QueryCache = new QueryCache(); /** * @@ -66,14 +74,18 @@ export class FinchClient { messageTimeout, autoStart = true, maxPortTimeoutCount = 10, + cachePolicy = FinchCachePolicy.CacheFirst, }: FinchClientOptions = {}) { - this.cache = cache; + if (cache) { + this.cache = cache; + } this.id = id; this.messageKey = messageKey; this.portName = portName || this.portName; this.useMessages = useMessages ?? false; this.messageTimeout = messageTimeout ?? this.messageTimeout; this.maxPortTimeoutCount = maxPortTimeoutCount; + this.cachePolicy = cachePolicy; if (autoStart) { this.start(); } @@ -251,20 +263,67 @@ export class FinchClient { options: FinchQueryOptions = {}, ) { const documentNode = isDocumentNode(query) ? query : gql(query); - const result = await this.queryApi( - documentNode, - variables, - { - id: this.id, - messageKey: this.messageKey, - ...options, - }, - ); + let cache = this.cache?.getCache(documentNode, variables) as + | FinchQueryObservable + | undefined; + const respondWithCache = + (options.cachePolicy ?? this.cachePolicy) === FinchCachePolicy.CacheFirst; - if (this.cache && result?.data) { - this.cache.setCache(documentNode, variables, result.data); + const snapshot = cache?.getSnapshot(); + if (!snapshot.data && !snapshot.errors) { + cache.update({ + ...snapshot, + cacheStatus: FinchCacheStatus.Fresh, + loading: true, + }); } - return result; + + const pendingFetch = new Promise(async resolve => { + let result: Awaited>; + try { + result = await this.queryApi( + documentNode, + variables, + { + id: this.id, + messageKey: this.messageKey, + ...options, + }, + ); + + if (this.cache) { + this.cache.setCache(documentNode, variables, { + data: result?.data ?? snapshot.data, + errors: result?.errors, + loading: false, + cacheStatus: FinchCacheStatus.Fresh, + }); + this.queryLifecycleManager(documentNode, variables); + + resolve(result); + return; + } + } catch (e) { + result = { + data: snapshot?.data, + errors: [e], + }; + if (this.cache) { + this.cache.setCache(documentNode, variables, { + data: snapshot.data, + errors: [e], + loading: false, + cacheStatus: FinchCacheStatus.Fresh, + }); + } + } + resolve(result); + }); + + if (respondWithCache && snapshot.data && !snapshot.errors) { + return snapshot; + } + return pendingFetch; } /** @@ -303,15 +362,44 @@ export class FinchClient { * @param listener A Function that is called when the cache is updated * @returns A function that unsubscribes from the query. */ - subscribe( - query: string | DocumentNode, + subscribe( + query: Query, variables: unknown, - listener: Listener, + listener: () => void, ) { if (!this.cache) { return () => {}; } const documentNode = isDocumentNode(query) ? query : gql(query); - return this.cache.subscribe(documentNode, variables, listener); + const cache = this.cache.getCache(documentNode, variables); + return cache.subscribe(listener); + } + + /** + * Query lifecycle manager manages the lifecycle of a query. This is mainly used + * revalidating queries when the cache is stale. + * @param query A Document node or string to query the api + * @param variables Variables for this query + * @param observable The observable to subscribe to + */ + private queryLifecycleManager< + Query extends {} = {}, + Variables extends GenericVariables = {} + >(query: DocumentNode, variables?: Variables, options?: FinchQueryOptions) { + const observable = this.cache?.getCache( + query, + variables, + ) as FinchQueryObservable; + const unsubscribe = this.subscriptions.get(observable); + if (unsubscribe) { + unsubscribe(); + } + const subscription = observable.subscribe(() => { + const snapshot = observable.getSnapshot(); + if (snapshot.cacheStatus === FinchCacheStatus.Stale) { + this.query(query, variables, { timeout: options?.timeout }); + } + }); + this.subscriptions.set(observable, subscription); } } diff --git a/packages/client/src/client/cache/Observable.ts b/packages/client/src/client/cache/Observable.ts new file mode 100644 index 00000000..dac3cda0 --- /dev/null +++ b/packages/client/src/client/cache/Observable.ts @@ -0,0 +1,39 @@ +import EventEmitter from 'eventemitter3'; + +export class Observable { + private value: T; + private emitter: EventEmitter; + + constructor(value: T) { + this.value = value; + this.emitter = new EventEmitter(); + } + + /** + * The subscribe method allows for subscribing to changes in the observable value + * @param function to be called when the observable changes + * @returns a function that can be called to unsubscribe + */ + public subscribe = (fn: () => void) => { + this.emitter.on('change', fn); + return () => { + this.emitter.off('change', fn); + }; + }; + + /** + * This method allows for the getting of a snapshot of the current value + */ + public getSnapshot = () => { + return this.value; + }; + + /** + * This method allows for the updating of the observable value + * @param value The new value to update the observable to + */ + public update = (value: T) => { + this.value = value; + this.emitter.emit('change'); + }; +} diff --git a/packages/client/src/client/cache/QueryCache.ts b/packages/client/src/client/cache/QueryCache.ts index d0b53641..74577d79 100644 --- a/packages/client/src/client/cache/QueryCache.ts +++ b/packages/client/src/client/cache/QueryCache.ts @@ -1,12 +1,13 @@ import { DocumentNode, print } from 'graphql'; -import { Listener, FinchCache } from './types'; - -type Cache = Map; - -interface ListenerMap { - [key: string]: Array>; -} +import { Observable } from './Observable'; +import { + FinchCache, + FinchQueryObservable, + FinchQueryResults, + FinchCacheStatus, +} from '../types'; +type Cache = Map>; interface QueryCacheOptions { hydrate?: Cache; } @@ -18,8 +19,7 @@ interface QueryCacheOptions { * @implements FinchCache */ export class QueryCache implements FinchCache { - cache: Cache; - listeners: ListenerMap; + store: Cache; /** * serializeQuery is a static method on the the QueryCache class that allow @@ -39,33 +39,13 @@ export class QueryCache implements FinchCache { * @param options.hydrate A Map with the serialized query cache. */ constructor(options: QueryCacheOptions = {}) { - this.cache = options.hydrate ?? new Map(); - this.listeners = {}; - } - - subscribe( - doc: DocumentNode, - variables: any, - listener: Listener, - ) { - const key = QueryCache.serializeQuery(doc, variables); - if (typeof this.listeners[key] === 'undefined') { - this.listeners[key] = []; - } - this.listeners[key].push(listener); - return () => { - const index = this.listeners[key].indexOf(listener); - this.listeners[key] = [ - ...this.listeners[key].slice(0, index), - ...this.listeners[key].slice(index + 1), - ]; - }; + this.store = options.hydrate ?? new Map(); } setCache( doc: DocumentNode, variables: any, - result: Query, + result: FinchQueryResults, ) { const key = QueryCache.serializeQuery(doc, variables); this.setCacheKey(key, result); @@ -73,14 +53,25 @@ export class QueryCache implements FinchCache { getCache(doc: DocumentNode, variables: any) { const key = QueryCache.serializeQuery(doc, variables); - return this.cache.get(key) as Query | undefined; + let cache = this.store.get(key) as FinchQueryObservable | undefined; + if (!cache) { + cache = new Observable>({ + data: undefined, + errors: undefined, + loading: true, + cacheStatus: FinchCacheStatus.Unknown, + }); + this.store.set(key, cache); + } + return cache; } - private setCacheKey(key: string, value: Query) { - const listeners = this.listeners[key] ?? []; - this.cache.set(key, value); - listeners.forEach(fn => { - fn(value); - }); + private setCacheKey( + key: string, + value: FinchQueryResults, + ) { + const cache = + this.store.get(key) ?? new Observable>(value); + cache.update(value); } } diff --git a/packages/client/src/client/cache/index.ts b/packages/client/src/client/cache/index.ts index 797f0921..32691c7d 100644 --- a/packages/client/src/client/cache/index.ts +++ b/packages/client/src/client/cache/index.ts @@ -1,2 +1,2 @@ export { QueryCache } from './QueryCache'; -export type { Listener, FinchCache } from './types'; +export { Observable } from './Observable'; diff --git a/packages/client/src/client/cache/types.ts b/packages/client/src/client/cache/types.ts deleted file mode 100644 index cc947baf..00000000 --- a/packages/client/src/client/cache/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { DocumentNode } from 'graphql'; - -export type Listener = (updateInfo: Query) => void; - -export interface FinchCache { - setCache( - doc: DocumentNode, - variables: any, - result: Query, - ): void; - getCache( - doc: DocumentNode, - variables: any, - ): Query | undefined; - subscribe( - doc: DocumentNode, - variables: any, - listener: Listener, - ); -} diff --git a/packages/client/src/client/index.ts b/packages/client/src/client/index.ts index dd10c574..e093874e 100644 --- a/packages/client/src/client/index.ts +++ b/packages/client/src/client/index.ts @@ -1,3 +1,6 @@ export { queryApi } from './client'; export { FinchClient } from './FinchClient'; -export * from './cache'; +export type { FinchCache, FinchQueryObservable } from './types'; +export { FinchCacheStatus } from './types'; +export { QueryCache } from './cache/QueryCache'; +export { Observable } from './cache/Observable'; diff --git a/packages/client/src/client/types.ts b/packages/client/src/client/types.ts new file mode 100644 index 00000000..584b3124 --- /dev/null +++ b/packages/client/src/client/types.ts @@ -0,0 +1,31 @@ +import { DocumentNode, GraphQLFormattedError } from 'graphql'; +import { Observable } from './cache/Observable'; + +export enum FinchCacheStatus { + Fresh = 'fresh', + Stale = 'stale', + Unknown = 'unknown', +} + +export interface FinchQueryResults { + data?: Query; + errors?: Array; + loading: boolean; + cacheStatus: FinchCacheStatus; +} + +export type FinchQueryObservable = Observable< + FinchQueryResults +>; + +export interface FinchCache { + setCache( + doc: DocumentNode, + variables: any, + result: FinchQueryResults, + ): void; + getCache( + doc: DocumentNode, + variables: any, + ): FinchQueryObservable | undefined; +} diff --git a/packages/react/package.json b/packages/react/package.json index b4f3735a..e2ea9228 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -27,6 +27,7 @@ "@types/chrome": "^0.0.170", "@types/jest": "^26.0.15", "@types/react": "^17.0.0", + "@types/use-sync-external-store": "^0.0.3", "@typescript-eslint/eslint-plugin": "^4.15.1", "@typescript-eslint/parser": "^4.15.1", "babel-jest": "^27.4.0", @@ -57,7 +58,8 @@ "@finch-graphql/client": "^3.2.1", "@finch-graphql/types": "^3.1.3", "graphql-tag": "^2.11.0", - "use-deep-compare-effect": "^1.6.1", + "use-deep-compare-effect": "1.8.1", + "use-sync-external-store": "^1.2.0", "uuid": "^8.3.2" }, "gitHead": "2719e2c70f828791c86d78120317229d35c3393e", diff --git a/packages/react/src/hooks/useQuery.test.ts b/packages/react/src/hooks/useQuery.test.ts index 1644f7f3..8b4c7418 100644 --- a/packages/react/src/hooks/useQuery.test.ts +++ b/packages/react/src/hooks/useQuery.test.ts @@ -40,8 +40,9 @@ describe('useQuery', () => { await wrapper.waitForNextUpdate(); expect(sendMessageMock.mock.calls[0][0]).toEqual({ + external: undefined, query: testDoc, - variables: {}, + variables: undefined, type: FinchMessageKey.Generic, }); @@ -68,7 +69,7 @@ describe('useQuery', () => { await wrapper.waitForNextUpdate(); - expect(wrapper.result.current.error.message).toBe('foo'); + expect(wrapper.result.current.error?.message).toBe('foo'); }); it('refetch should send another message', async () => { const sendMessageMock = jest @@ -99,8 +100,9 @@ describe('useQuery', () => { expect(sendMessageMock.mock.calls[1][0]).toEqual({ query: testDoc, - variables: {}, + variables: undefined, type: FinchMessageKey.Generic, + external: undefined, }); }); it('should clear out any old error values on refetch', async () => { @@ -126,7 +128,7 @@ describe('useQuery', () => { await wrapper.waitForNextUpdate(); // Original error - expect(wrapper.result.current.error.message).toBe('foo'); + expect(wrapper.result.current.error?.message).toBe('foo'); sendMessageMock.mockReset().mockImplementation((message, callback) => { setTimeout(() => { @@ -139,7 +141,7 @@ describe('useQuery', () => { }); // Error cache is cleared - expect(wrapper.result.current.error).toBe(null); + expect(wrapper.result.current.error).toBe(undefined); }); it('wrapping it in a provider should allow for external calls', async () => { const sendMessageMock = jest @@ -228,14 +230,16 @@ describe('useQuery', () => { // Validate initial data is present expect(wrapper.result.current.data).toEqual({ bar: 'baz' }); - await act(async () => { + act(() => { // Change the response sendMessageMock.mockImplementationOnce((_, callback) => callback({ data: { bar: 'qux' } }), ); - await client.query(testDoc, { foo: 'bar' }); + client.query(testDoc, { foo: 'bar' }); }); + await wrapper.waitForNextUpdate(); + // Validate new value is present expect(wrapper.result.current.data).toEqual({ bar: 'qux' }); }); @@ -330,4 +334,54 @@ describe('useQuery', () => { expect(() => expect(sendMessageMock).toBeCalledTimes(4)).toThrow(/3/); }); + it.only('should refetch cache when the cache is invalidated', async () => { + const sendMessageMock = jest + .fn() + .mockImplementationOnce((_, callback) => + callback({ data: { bar: 'baz' } }), + ); + chrome.runtime.sendMessage = sendMessageMock; + + const client = new FinchClient({ + cache: new QueryCache(), + useMessages: true, + }); + + const wrapper = renderHook( + ({ foo }) => + useQuery(testDoc, { + variables: { foo }, + }), + { + initialProps: { + foo: 'bar', + }, + wrapper: ({ children }) => { + return React.createElement(FinchProvider, { + // @ts-ignore + children, + client, + }); + }, + }, + ); + + await wrapper.waitForNextUpdate(); + expect(sendMessageMock).toBeCalledTimes(1); + + expect(wrapper.result.current.data).toEqual({ bar: 'baz' }); + + sendMessageMock.mockImplementationOnce((_, callback) => + callback({ data: { bar: 'qux' } }), + ); + + act(() => { + wrapper.result.current.invalidate(); + }); + + await wrapper.waitForNextUpdate(); + + expect(sendMessageMock).toBeCalledTimes(2); + expect(wrapper.result.current.data).toEqual({ bar: 'qux' }); + }); }); diff --git a/packages/react/src/hooks/useQuery.ts b/packages/react/src/hooks/useQuery.ts index c4ed357c..efb14c3e 100644 --- a/packages/react/src/hooks/useQuery.ts +++ b/packages/react/src/hooks/useQuery.ts @@ -1,6 +1,9 @@ -import { DocumentNode, GraphQLFormattedError } from 'graphql'; -import { useState, useEffect, useCallback, useRef } from 'react'; -import useDeepCompareEffect from 'use-deep-compare-effect'; +import { FinchCacheStatus } from '@finch-graphql/client'; +import { FinchCachePolicy } from '@finch-graphql/types'; +import { DocumentNode } from 'graphql'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { useDeepCompareMemoize } from 'use-deep-compare-effect'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { useFinchClient } from './FinchProvider'; interface BackgroundQueryOptions { @@ -9,10 +12,9 @@ interface BackgroundQueryOptions { pollInterval?: number; poll?: boolean; timeout?: number; + cachePolicy?: FinchCachePolicy; } -type QueryError = GraphQLFormattedError | Error; - /** * useQuery is a hook that allows you to easily fetch data from a Finch GraphQL * client in React. @@ -29,74 +31,35 @@ export const useQuery = ( query: DocumentNode, { skip, - variables, + variables: passedVariables, pollInterval: passedPollInterval = 0, poll, timeout, + cachePolicy, }: BackgroundQueryOptions = {}, ) => { const { client } = useFinchClient(); + const [variables, setVariables] = useState(passedVariables); + const cache = useMemo(() => { + const queryCache = client.cache.getCache(query, variables); + if (!skip) { + client.query(query, variables, { + timeout: timeout, + cachePolicy: cachePolicy, + }); + } + return queryCache; + }, useDeepCompareMemoize([variables, query, skip, timeout])); + const { data, errors, loading } = useSyncExternalStore(cache.subscribe, () => + cache.getSnapshot(), + ); + const error = errors?.[0]; const mounted = useRef(true); - const [data, setData] = useState(null); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); const [shouldPoll, setShouldPoll] = useState( () => poll ?? !!passedPollInterval, ); const [pollInterval, setPollInterval] = useState(passedPollInterval); - /** - * makeQuery is a helper function that runs the query against the Finch client - * and sets the state of the query. - * - * It will clear out old cache, and will also cancel the cache update if a new query is - * made. - * - * @returns a function that will stop cache from being updated with the result of the promise. - */ - const makeQuery = useCallback( - (argVars?: Variables) => { - let cancelled = false; - const queryRequest = { - cancel: () => { - cancelled = true; - }, - request: client - .query( - query, - // @ts-ignore variables are kinda weird - argVars ?? variables ?? {}, - { timeout }, - ) - .then(resp => { - if (resp.data && mounted.current && !cancelled) { - setData(resp.data); - } - if (resp.errors && resp.errors.length && !cancelled) { - setError(resp.errors[0]); - } - }) - .catch(e => { - if (mounted.current && !cancelled) { - setError(e); - } - }) - .finally(() => { - if (mounted.current && !cancelled) { - setLoading(false); - } - }), - response: null, - }; - - // Clear out old error cache - setError(null); - - return queryRequest; - }, - [query, variables], - ); - /** * startPolling turns on polling for the query. This is useful for * queries, that you want to keep up to date, but do not have things like @@ -119,39 +82,32 @@ export const useQuery = ( }, []); /** - * refetch is a small methods that allows you to refetch the query. + * invalidate is a small method that allows you to invalidate the cache + * for the query. + */ + const invalidate = useCallback(() => { + const snapshot = cache.getSnapshot(); + cache.update({ ...snapshot, cacheStatus: FinchCacheStatus.Stale }); + }, [cache]); + + /** + * refetch is a small methods that allows you to refetch the query. If there + * is no cache policy set on the hook, we default to fetch first on the refetch + * to allow for awaiting of the the refetch query. */ const refetch = useCallback( (overrideVariables?: Variables) => { - return makeQuery(overrideVariables).request; + if (overrideVariables) { + setVariables(overrideVariables); + } + return client.query(query, overrideVariables ?? variables, { + timeout: timeout, + cachePolicy: cachePolicy ?? FinchCachePolicy.FetchFirst, + }); }, - [makeQuery], + [client, timeout], ); - /** - * This effect handles the initial query and updating the query - * if the variables or query changes. - */ - useDeepCompareEffect(() => { - const unsubscribe = client.subscribe( - query, - variables, - updatedData => { - setData(updatedData); - }, - ); - let cancelQuery = () => {}; - - if (!skip) { - setLoading(true); - cancelQuery = makeQuery().cancel; - } - return () => { - cancelQuery(); - unsubscribe(); - }; - }, [query, skip, variables]); - /** * This effect handles polling the query if the pollInterval is set, * this is dependent off of two states, shouldPoll and pollInterval. @@ -161,7 +117,7 @@ export const useQuery = ( let timer: number | undefined; if (shouldPoll && pollInterval) { timer = window.setInterval(async () => { - await makeQuery(); + refetch(); }, pollInterval); } return () => { @@ -179,6 +135,10 @@ export const useQuery = ( } }, [passedPollInterval]); + useEffect(() => { + setVariables(passedVariables); + }, useDeepCompareMemoize([passedVariables])); + /** * This effect handles the mounted ref, to make sure we dont update state after the * hook is unmounted. @@ -190,12 +150,16 @@ export const useQuery = ( }; }, []); - return { - data, - error, - loading, - refetch, - startPolling, - stopPolling, - }; + return useMemo( + () => ({ + data, + error, + loading, + refetch, + startPolling, + stopPolling, + invalidate, + }), + [data, error, loading, refetch, startPolling, stopPolling, invalidate], + ); }; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index fcb073fe..6d5a6ef4 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1 +1,2 @@ export * from './types'; +export * from './utils'; diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index b789b57e..95f7ca00 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -17,6 +17,18 @@ export enum FinchConnectionType { Message = 'message', } +export enum FinchCachePolicy { + /** + * CacheFirst will try to get the data from the cache, and respond with the + * cached data and update the data in the background. + */ + CacheFirst = 'cache-first', + /** + * FetchFirst will fetch and respond with the data from the fetch, and update the cache. + */ + FetchFirst = 'fetch-first', +} + export const FinchDefaultPortName = '_finchMessagePort'; export type GenericVariables = { [key: string]: any }; @@ -66,6 +78,7 @@ export interface FinchQueryOptions { messageKey?: string; external?: boolean; timeout?: number; + cachePolicy?: FinchCachePolicy; } export interface FinchExecutionResults { diff --git a/packages/types/src/utils.ts b/packages/types/src/utils.ts new file mode 100644 index 00000000..6ea0526a --- /dev/null +++ b/packages/types/src/utils.ts @@ -0,0 +1,10 @@ +/** + * Recursively unwraps the "awaited type" of a type. Non-promise "thenables" should resolve to `never`. This emulates the behavior of `await`. + */ +export type Awaited = T extends null | undefined + ? T // special case for `null | undefined` when not in `--strictNullChecks` mode + : T extends object & { then(onfulfilled: infer F): any } // `await` only unwraps object types with a callable `then`. Non-object types are not unwrapped + ? F extends (value: infer V) => any // if the argument to `then` is callable, extracts the argument + ? Awaited // recursively unwrap the value + : never // the argument to `then` was not callable + : T; // non-object or non-thenable diff --git a/yarn.lock b/yarn.lock index e7330996..e4293f50 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4230,6 +4230,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e" integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ== +"@types/use-sync-external-store@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43" + integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== + "@types/uuid@^8.3.0": version "8.3.4" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" @@ -8316,6 +8321,11 @@ eventemitter3@^4.0.0: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== +eventemitter3@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.0.tgz#084eb7f5b5388df1451e63f4c2aafd71b217ccb3" + integrity sha512-riuVbElZZNXLeLEoprfNYoDSwTBRR44X3mnhdI1YcnENpWTCsTTVZ2zFuqQcpoyqPQIUXdiPEU0ECAq0KQRaHg== + events@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" @@ -17191,13 +17201,12 @@ use-composed-ref@^1.0.0: dependencies: ts-essentials "^2.0.3" -use-deep-compare-effect@^1.6.1: - version "1.6.1" - resolved "https://registry.yarnpkg.com/use-deep-compare-effect/-/use-deep-compare-effect-1.6.1.tgz#061a0ac5400aa0461e33dddfaa2a98bca873182a" - integrity sha512-VB3b+7tFI81dHm8buGyrpxi8yBhzYZdyMX9iBJra7SMFMZ4ci4FJ1vFc1nvChiB1iLv4GfjqaYfvbNEpTT1rFQ== +use-deep-compare-effect@1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/use-deep-compare-effect/-/use-deep-compare-effect-1.8.1.tgz#ef0ce3b3271edb801da1ec23bf0754ef4189d0c6" + integrity sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q== dependencies: "@babel/runtime" "^7.12.5" - "@types/react" "^17.0.0" dequal "^2.0.2" use-isomorphic-layout-effect@^1.0.0: @@ -17220,6 +17229,11 @@ use-sidecar@^1.0.1: detect-node-es "^1.1.0" tslib "^1.9.3" +use-sync-external-store@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" + integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" From d5d64e5bfbe4007c125ccf0fdcefc6669ff5b112 Mon Sep 17 00:00:00 2001 From: Jacob Lowe Date: Mon, 23 Jan 2023 10:49:42 -0800 Subject: [PATCH 2/3] update cache loading to allow for removing double fetchs when request is already in flight (#2) * update cache loading to allow for removing double fetchs when request is already in flight * add in update to test to accommodate caching --- packages/client/src/client/FinchClient.test.ts | 4 ++-- packages/client/src/client/FinchClient.ts | 7 ++++++- packages/client/src/client/cache/QueryCache.ts | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/client/src/client/FinchClient.test.ts b/packages/client/src/client/FinchClient.test.ts index b13f71fa..2e63363f 100644 --- a/packages/client/src/client/FinchClient.test.ts +++ b/packages/client/src/client/FinchClient.test.ts @@ -101,12 +101,12 @@ describe('FinchClient', () => { expect(browser.runtime.connect).toHaveBeenCalledTimes(1); await client.query('query foo { bar }'); const timeoutPendingQuery = client.query( - 'query foo { bar }', + 'query bar { baz }', {}, { timeout: 100 }, ); const pendingQuery = client.query( - 'query foo { bar }', + 'query baz { qux }', {}, { timeout: 1000 }, ); diff --git a/packages/client/src/client/FinchClient.ts b/packages/client/src/client/FinchClient.ts index 7f4925ff..af2e355b 100644 --- a/packages/client/src/client/FinchClient.ts +++ b/packages/client/src/client/FinchClient.ts @@ -270,7 +270,12 @@ export class FinchClient { (options.cachePolicy ?? this.cachePolicy) === FinchCachePolicy.CacheFirst; const snapshot = cache?.getSnapshot(); - if (!snapshot.data && !snapshot.errors) { + + if (snapshot.loading) { + return snapshot; + } + + if (!snapshot.data && !snapshot.errors && !snapshot.loading) { cache.update({ ...snapshot, cacheStatus: FinchCacheStatus.Fresh, diff --git a/packages/client/src/client/cache/QueryCache.ts b/packages/client/src/client/cache/QueryCache.ts index 74577d79..44b62c6d 100644 --- a/packages/client/src/client/cache/QueryCache.ts +++ b/packages/client/src/client/cache/QueryCache.ts @@ -58,7 +58,7 @@ export class QueryCache implements FinchCache { cache = new Observable>({ data: undefined, errors: undefined, - loading: true, + loading: false, cacheStatus: FinchCacheStatus.Unknown, }); this.store.set(key, cache); From ddc0ccfc7dec2b9cd2216d7ed9cd3784c99aa3f8 Mon Sep 17 00:00:00 2001 From: Jacob Lowe Date: Mon, 23 Jan 2023 11:06:03 -0800 Subject: [PATCH 3/3] revert some repo specific changes --- .github/dependabot.yml | 22 +++++++++++----------- .github/workflows/github-pages.yml | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 721d34f1..4012b339 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,7 +2,7 @@ version: 2 updates: - package-ecosystem: npm - directory: '/' + directory: "/" schedule: interval: weekly open-pull-requests-limit: 10 @@ -13,26 +13,26 @@ updates: versions: - 4.1.2 - 4.1.3 - - dependency-name: '@chakra-ui/react' + - dependency-name: "@chakra-ui/react" versions: - 1.5.0 - - dependency-name: '@docusaurus/preset-classic' + - dependency-name: "@docusaurus/preset-classic" versions: - 2.0.0-alpha.72 - - dependency-name: '@docusaurus/core' + - dependency-name: "@docusaurus/core" versions: - 2.0.0-alpha.72 - - dependency-name: '@types/chrome' + - dependency-name: "@types/chrome" versions: - 0.0.134 - - dependency-name: '@typescript-eslint/parser' + - dependency-name: "@typescript-eslint/parser" versions: - 4.15.2 - 4.17.0 - 4.18.0 - 4.19.0 - 4.20.0 - - dependency-name: '@typescript-eslint/eslint-plugin' + - dependency-name: "@typescript-eslint/eslint-plugin" versions: - 4.15.2 - 4.17.0 @@ -45,7 +45,7 @@ updates: - dependency-name: react versions: - 17.0.2 - - dependency-name: '@babel/preset-env' + - dependency-name: "@babel/preset-env" versions: - 7.12.11 - 7.12.16 @@ -55,7 +55,7 @@ updates: - 7.13.12 - 7.13.5 - 7.13.8 - - dependency-name: '@types/react' + - dependency-name: "@types/react" versions: - 17.0.3 - dependency-name: typescript @@ -68,9 +68,9 @@ updates: versions: - 8.1.0 - 8.1.1 - - dependency-name: '@types/jest' + - dependency-name: "@types/jest" versions: - 26.0.21 - - dependency-name: '@babel/preset-typescript' + - dependency-name: "@babel/preset-typescript" versions: - 7.12.17 diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 4d286fd0..1f559bf9 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -3,7 +3,7 @@ name: Deploy Github Pages on: push: branches: - - main + - master jobs: test: