├── .babelrc ├── .eslintrc ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── example ├── .eslintrc ├── DataFetcher.tsx ├── DataUpdater.tsx ├── LazyRender.tsx ├── NetworkStatusNotifier.tsx ├── NetworkStatusReporter.tsx ├── createClient.ts ├── index.html ├── index.tsx └── schema.ts ├── package.json ├── src ├── .eslintrc ├── ActionTypes.ts ├── ApolloLinkNetworkStatus.ts ├── Dispatcher.ts ├── NetworkStatusAction.ts ├── __tests__ │ └── index-test.tsx ├── createNetworkStatusNotifier.ts ├── index.ts ├── useApolloNetworkStatus.ts ├── useApolloNetworkStatusReducer.ts ├── useEventCallback.ts └── useIsomorphicLayoutEffect.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", {"modules": false}], 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": [ 8 | "@babel/plugin-proposal-class-properties" 9 | ], 10 | "env": { 11 | "test": { 12 | "plugins": ["@babel/plugin-transform-modules-commonjs"] 13 | }, 14 | "production": { 15 | "plugins" : [ 16 | "@babel/plugin-external-helpers" 17 | ] 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["molindo/react"], 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "sourceType": "module" 6 | }, 7 | "plugins": ["react-hooks", "@typescript-eslint"], 8 | "env": { 9 | "es6": true, 10 | "jest": true 11 | }, 12 | "settings": { 13 | "react": { 14 | "version": "detect" 15 | }, 16 | "import/resolver": { 17 | "typescript": {} 18 | } 19 | }, 20 | "rules": { 21 | "@typescript-eslint/no-unused-vars": "error", 22 | "no-undef": "off", 23 | "no-unused-vars": "off", 24 | "react-hooks/exhaustive-deps": "error", 25 | "react-hooks/rules-of-hooks": "error", 26 | "valid-jsdoc": "off" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .idea 4 | .cache 5 | .rts2_cache_cjs 6 | .rts2_cache_es 7 | .rts2_cache_umd 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 5.3.1 4 | 5 | ### Fixes 6 | 7 | - Fix types 8 | 9 | ## 5.3.0 10 | 11 | ### Features 12 | 13 | - React 19 support 14 | 15 | ## 5.2.1 16 | 17 | ### Fixes 18 | 19 | - Set `homepage` field in `package.json` 20 | 21 | ## 5.2.0 22 | 23 | ### Features 24 | 25 | - React 18 support 26 | - GraphQL 16 support 27 | 28 | ## 5.1.0 29 | 30 | ### Features 31 | 32 | - React 17 support (@alecdwm in [#49](https://github.com/molindo/react-apollo-network-status/pull/49)) 33 | 34 | ## 5.0.1 35 | 36 | ### Fixes 37 | 38 | - Avoid layout effect warning on the server side (@mufasa71 in [#35](https://github.com/molindo/react-apollo-network-status/pull/35)) 39 | 40 | ## 5.0 41 | 42 | ### Breaking changes 43 | 44 | - The library now supports [the new `@apollo/client@3`](https://www.apollographql.com/docs/react/migrating/apollo-client-3-migration/). Older versions are no longer supported. 45 | - Remove UMD build, as Apollo also doesn't support one anymore. 46 | 47 | Special thanks to [luuksommers](https://github.com/luuksommers) and [erosval](https://github.com/erosval) for beta testing this release in their apps. 48 | 49 | ## 4.0.1 50 | 51 | ### Fixes 52 | 53 | - Compatibility with React 16.13 54 | 55 | ## 4.0 56 | 57 | ### Improvements 58 | 59 | - Improved types for `NetworkStatusAction`. 60 | - A more reliable integration with the Apollo cache. This fixes [#22](https://github.com/molindo/react-apollo-network-status/issues/22) and [#28](https://github.com/molindo/react-apollo-network-status/issues/28). 61 | 62 | ### Breaking changes 63 | 64 | - The usage of the library has changed from using a provider to configuring a link that needs to be passed to the `ApolloClient` constructor (see [README](./README.md)). 65 | - Scoping network status handling to a subtree was removed along with the `enableBubbling` option. The network status handling is scoped to the usage of the `ApolloClient` instance. 66 | 67 | ## 3.0 68 | 69 | ### New features 70 | 71 | - Support for `@apollo/react-*` packages. 72 | - Export `NetworkStatus` and `OperationError` types for TypeScript users. 73 | 74 | ### Breaking changes 75 | 76 | - Raised required peer dependency version of `apollo-client` to `^2.6.0`. 77 | - You need to depend on a React integration from one of the `@apollo/react-*` packages. See [upgrade guide](https://www.apollographql.com/docs/react/migrating/hooks-migration/). 78 | 79 | Special thanks to [Matth10](https://github.com/Matth10), [rcohen-unext](https://github.com/rcohen-unext) and [MasterKale](https://github.com/MasterKale) for beta testing this release in their apps and code review. 80 | 81 | ## 2.0 82 | 83 | Compatible with `react-apollo@^2`. See [usage instructions](https://github.com/molindo/react-apollo-network-status/tree/e08e7b43e2e3447ec0d9399262d17b162162805e#react-apollo-network-status). 84 | 85 | ### New features 86 | 87 | - Use hooks for reading the network status. 88 | - Simplified API, so you no longer have to setup the link manually. 89 | - TypeScript support. 90 | - The reported network status is now more granular, allowing for more flexible usage. 91 | - You can now scope the reporting of the network status to a subtree instead of being forced to handle all operations globally. 92 | - You can nest the new `` in order to have multiple boundaries where network status will be reported (with optional bubbling configurable with the `enableBubbling` prop). 93 | 94 | ### Breaking changes 95 | 96 | - Updated peer dependencies. Please make sure you fulfill them. 97 | - The network status can only be read within the ``. 98 | - Queries now only reset the new `queryError` property if it was present before (same for mutations). Previously there was only a single `error` property which was affected by both types of operations. 99 | - The opt-out property `context: {useNetworkStatusNotifier: false}` was renamed to `useApolloNetworkStatus`. 100 | - If you provide a custom reducer, there's now a new signature where you only provide one function which handles action types instead of separate functions. This pattern composes better since you usually have to cover all network events to implement a given feature. 101 | 102 | ## 1.1 103 | 104 | Subscription operations no longer affect the loading property, but they can potentially set the error property (@shurik239 in [#9](https://github.com/molindo/react-apollo-network-status/pull/9)). 105 | 106 | ## 1.0 107 | 108 | Compatible with `react-apollo@<=2`. See [usage instructions](https://github.com/molindo/react-apollo-network-status/tree/583a00f6344e05edcfee90bee0823a7736f56021#react-apollo-network-status). 109 | 110 | Initial stable release. 111 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Molindo GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-apollo-network-status 2 | 3 | > Brings information about the global network status from Apollo into React. 4 | 5 | [](https://www.npmjs.com/package/react-apollo-network-status) 6 | 7 | This library helps with implementing global loading indicators like progress bars or adding global error handling, so you don't have to respond to every error in a component that invokes an operation. 8 | 9 | ## Usage 10 | 11 | ```js 12 | import React from 'react'; 13 | import ReactDOM from 'react-dom'; 14 | import {ApolloClient, InMemoryCache, createHttpLink, ApolloProvider} from '@apollo/client'; 15 | import {createNetworkStatusNotifier} from 'react-apollo-network-status'; 16 | 17 | const {link, useApolloNetworkStatus} = createNetworkStatusNotifier(); 18 | 19 | function GlobalLoadingIndicator() { 20 | const status = useApolloNetworkStatus(); 21 | 22 | if (status.numPendingQueries > 0) { 23 | return

Loading …

; 24 | } else { 25 | return null; 26 | } 27 | } 28 | 29 | const client = new ApolloClient({ 30 | cache: new InMemoryCache(), 31 | link: link.concat(createHttpLink()) 32 | }); 33 | 34 | const element = ( 35 | 36 | 37 | 38 | 39 | ); 40 | ReactDOM.render(element, document.getElementById('root')); 41 | ``` 42 | 43 | > **Note:** The current version of this library supports the latest [`@apollo-client` package](https://www.apollographql.com/docs/react/migrating/apollo-client-3-migration/). If you're using an older version of React Apollo and don't want to upgrade, you can use an older version of this library (see [changelog](./CHANGELOG.md)). 44 | 45 | ## Returned data 46 | 47 | The hook `useApolloNetworkStatus` provides an object with the following properties: 48 | 49 | ```tsx 50 | type NetworkStatus = { 51 | // The number of queries which are currently in flight. 52 | numPendingQueries: number; 53 | 54 | // The number of mutations which are currently in flight. 55 | numPendingMutations: number; 56 | 57 | // The latest query error that has occured. This will be reset once the next query starts. 58 | queryError?: OperationError; 59 | 60 | // The latest mutation error that has occured. This will be reset once the next mutation starts. 61 | mutationError?: OperationError; 62 | }; 63 | 64 | type OperationError = { 65 | networkError?: Error | ServerError | ServerParseError; 66 | operation?: Operation; 67 | response?: ExecutionResult; 68 | graphQLErrors?: ReadonlyArray; 69 | }; 70 | ``` 71 | 72 | Subscriptions currently don't affect the status returned by `useApolloNetworkStatus`. 73 | 74 | Useful applications are for example integrating with [NProgress.js](http://ricostacruz.com/nprogress/) or showing errors with [snackbars from Material UI](http://www.material-ui.com/#/components/snackbar). 75 | 76 | ## Advanced usage 77 | 78 | ### Limit handling to specific operations 79 | 80 | The default configuration enables an **opt-out** behaviour per operation by setting a context variable: 81 | 82 | ```js 83 | // Somewhere in a React component 84 | mutate({context: {useApolloNetworkStatus: false}}); 85 | ``` 86 | 87 | You can configure an **opt-in** behaviour by specifying an operation whitelist like this: 88 | 89 | ```js 90 | // Inside the component handling the network events 91 | useApolloNetworkStatus({ 92 | shouldHandleOperation: (operation: Operation) => 93 | operation.getContext().useApolloNetworkStatus === true 94 | }); 95 | 96 | // Somewhere in a React component 97 | mutate({context: {useApolloNetworkStatus: true}}); 98 | ``` 99 | 100 | ### Custom state 101 | 102 | You can fully control how operations are mapped to state by providing a custom reducer to a separate low-level hook. 103 | 104 | ```tsx 105 | const {link, useApolloNetworkStatusReducer} = createNetworkStatusNotifier(); 106 | 107 | const initialState = 0; 108 | 109 | function reducer(state: number, action: NetworkStatusAction) { 110 | switch (action.type) { 111 | case ActionTypes.REQUEST: 112 | return state + 1; 113 | 114 | case ActionTypes.ERROR: 115 | case ActionTypes.SUCCESS: 116 | case ActionTypes.CANCEL: 117 | return state - 1; 118 | } 119 | } 120 | 121 | function GlobalLoadingIndicator() { 122 | const numPendingQueries = useApolloNetworkStatusReducer(reducer, initialState); 123 | return

Pending queries: {numPendingQueries}

; 124 | } 125 | ``` 126 | -------------------------------------------------------------------------------- /example/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "rules": { 6 | "import/no-extraneous-dependencies": ["error", {"devDependencies": true, "peerDependencies": true}] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /example/DataFetcher.tsx: -------------------------------------------------------------------------------- 1 | import {useQuery} from '@apollo/client'; 2 | import gql from 'graphql-tag'; 3 | import React, {useState, useEffect} from 'react'; 4 | 5 | type Props = { 6 | isBroken?: boolean; 7 | initialSkip?: boolean; 8 | id?: string; 9 | }; 10 | 11 | const query = gql` 12 | query DataFetcher($id: ID!) { 13 | user(id: $id) { 14 | id 15 | name 16 | } 17 | } 18 | `; 19 | 20 | interface Data { 21 | user: { 22 | id: string; 23 | name: string; 24 | }; 25 | } 26 | 27 | interface Variables { 28 | id: string; 29 | } 30 | 31 | export default function DataFetcher({ 32 | isBroken, 33 | initialSkip = true, 34 | id = '1' 35 | }: Props) { 36 | const [skip, setSkip] = useState(initialSkip); 37 | const {data, loading, error, refetch} = useQuery(query, { 38 | context: {useApolloNetworkStatus: true}, 39 | notifyOnNetworkStatusChange: true, 40 | fetchPolicy: 'network-only', 41 | skip, 42 | variables: isBroken ? undefined : {id} 43 | }); 44 | 45 | function onFetchClick() { 46 | setSkip(false); 47 | } 48 | 49 | function onRefetchClick() { 50 | refetch(); 51 | } 52 | 53 | function onRetryClick() { 54 | refetch(); 55 | } 56 | 57 | useEffect(() => { 58 | if (error) console.error(error); 59 | }, [error]); 60 | 61 | let content; 62 | if (skip) { 63 | content = ( 64 | <> 65 | Local status: Idle{' '} 66 | 69 | 70 | ); 71 | } else if (loading) { 72 | content = 'Local status: Loading …'; 73 | } else if (error) { 74 | content = ( 75 | <> 76 | Error {' '} 77 | 78 | ); 79 | } else if (data && data.user) { 80 | content = ( 81 | <> 82 | User: {data.user.name}{' '} 83 | 86 | 87 | ); 88 | } else { 89 | throw new Error('Unexpected state'); 90 | } 91 | 92 | return

{content}

; 93 | } 94 | -------------------------------------------------------------------------------- /example/DataUpdater.tsx: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | import {useMutation} from '@apollo/client'; 3 | import React, {FormEvent, useState} from 'react'; 4 | 5 | const mutation = gql` 6 | mutation updateUser($id: ID!, $user: UserInput!) { 7 | updateUser(id: $id, user: $user) { 8 | id 9 | name 10 | } 11 | } 12 | `; 13 | 14 | interface Data { 15 | upderUser: { 16 | id: string; 17 | name: string; 18 | }; 19 | } 20 | 21 | interface Variables { 22 | id: string; 23 | user: { 24 | name: string; 25 | }; 26 | } 27 | 28 | export default function DataUpdater() { 29 | const [name, setName] = useState(''); 30 | const [updateUser, result] = useMutation(mutation); 31 | 32 | function onSubmit(event: FormEvent) { 33 | event.preventDefault(); 34 | updateUser({variables: {id: '1', user: {name}}}); 35 | } 36 | 37 | function onNameInputChange(event: FormEvent) { 38 | setName(event.currentTarget.value); 39 | } 40 | 41 | return ( 42 |
43 | 52 | 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /example/LazyRender.tsx: -------------------------------------------------------------------------------- 1 | import {useState, useEffect, ReactElement} from 'react'; 2 | 3 | type Props = { 4 | children: ReactElement; 5 | }; 6 | 7 | export default function LazyRender({children}: Props) { 8 | const [isChildVisible, setIsChildVisible] = useState(false); 9 | 10 | useEffect(() => { 11 | setIsChildVisible(true); 12 | }, []); 13 | 14 | return isChildVisible ? children : null; 15 | } 16 | -------------------------------------------------------------------------------- /example/NetworkStatusNotifier.tsx: -------------------------------------------------------------------------------- 1 | import {createNetworkStatusNotifier} from '../src'; 2 | 3 | const networkStatusNotifier = createNetworkStatusNotifier(); 4 | 5 | export const link = networkStatusNotifier.link; 6 | export const useApolloNetworkStatus = 7 | networkStatusNotifier.useApolloNetworkStatus; 8 | -------------------------------------------------------------------------------- /example/NetworkStatusReporter.tsx: -------------------------------------------------------------------------------- 1 | import {Operation} from '@apollo/client'; 2 | import React, {SyntheticEvent, useState} from 'react'; 3 | import { 4 | UseApolloNetworkStatusOptions, 5 | NetworkStatus 6 | } from '../src/useApolloNetworkStatus'; 7 | 8 | type Props = { 9 | initialOptIn?: boolean; 10 | useApolloNetworkStatus?: ( 11 | options?: UseApolloNetworkStatusOptions 12 | ) => NetworkStatus; 13 | }; 14 | 15 | export default function NetworkStatusReporter({ 16 | initialOptIn = false, 17 | useApolloNetworkStatus = require('./NetworkStatusNotifier') 18 | .useApolloNetworkStatus 19 | }: Props) { 20 | const [optIn, setOptIn] = useState(initialOptIn); 21 | 22 | function onOptInCheckboxChange(event: SyntheticEvent) { 23 | setOptIn(event.currentTarget.checked); 24 | } 25 | 26 | const options = optIn 27 | ? { 28 | shouldHandleOperation: (operation: Operation) => 29 | operation.getContext().useApolloNetworkStatus === true 30 | } 31 | : undefined; 32 | 33 | const status = useApolloNetworkStatus(options); 34 | 35 | let statusMessage; 36 | if (status.numPendingQueries > 0 || status.numPendingMutations > 0) { 37 | statusMessage = 'Loading …'; 38 | } else if (status.queryError || status.mutationError) { 39 | statusMessage = 'Error'; 40 | } else { 41 | statusMessage = 'Idle'; 42 | } 43 | 44 | return ( 45 |
54 |

Network status: {statusMessage}

55 | 63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /example/createClient.ts: -------------------------------------------------------------------------------- 1 | import {ApolloClient, InMemoryCache} from '@apollo/client'; 2 | import {SchemaLink} from '@apollo/client/link/schema'; 3 | import schema from './schema'; 4 | import {link as networkStatusNotifierLink} from './NetworkStatusNotifier'; 5 | 6 | export default function createClient() { 7 | return new ApolloClient({ 8 | cache: new InMemoryCache(), 9 | link: networkStatusNotifierLink.concat(new SchemaLink({schema})) 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | react-apollo-network-status 5 | 11 | 12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import {ApolloProvider} from '@apollo/client'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import DataFetcher from './DataFetcher'; 5 | import DataUpdater from './DataUpdater'; 6 | import NetworkStatusReporter from './NetworkStatusReporter'; 7 | import LazyRender from './LazyRender'; 8 | import createClient from './createClient'; 9 | 10 | const element = ( 11 |
12 | 13 |
14 | Reporters 15 | 16 |
17 | 18 |
19 |
20 |
21 | Fetchers 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 |
31 | ); 32 | 33 | ReactDOM.render(element, document.getElementById('root')); 34 | -------------------------------------------------------------------------------- /example/schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLSchema, 3 | GraphQLObjectType, 4 | GraphQLString, 5 | GraphQLID, 6 | GraphQLInputObjectType, 7 | GraphQLNonNull 8 | } from 'graphql'; 9 | 10 | const db = { 11 | user: { 12 | id: '1', 13 | name: 'Jane' 14 | } 15 | }; 16 | 17 | const User = new GraphQLObjectType({ 18 | name: 'User', 19 | fields: { 20 | id: {type: GraphQLID}, 21 | name: {type: GraphQLString} 22 | } 23 | }); 24 | 25 | const UserInput = new GraphQLInputObjectType({ 26 | name: 'UserInput', 27 | fields: { 28 | name: {type: GraphQLString} 29 | } 30 | }); 31 | 32 | function respond(result: T): Promise { 33 | return new Promise(resolve => setTimeout(() => resolve(result), 300)); 34 | } 35 | 36 | export default new GraphQLSchema({ 37 | query: new GraphQLObjectType({ 38 | name: 'Query', 39 | fields: { 40 | user: { 41 | type: User, 42 | args: { 43 | id: {type: GraphQLNonNull(GraphQLID)} 44 | }, 45 | resolve: () => respond(db.user) 46 | } 47 | } 48 | }), 49 | mutation: new GraphQLObjectType({ 50 | name: 'Mutation', 51 | fields: { 52 | updateUser: { 53 | type: User, 54 | args: { 55 | id: {type: GraphQLNonNull(GraphQLID)}, 56 | user: {type: UserInput} 57 | }, 58 | resolve: (_, {user}) => respond(Object.assign(db.user, user)) 59 | } 60 | } 61 | }) 62 | }); 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-apollo-network-status", 3 | "version": "5.3.1", 4 | "description": "Brings information about the global network status from Apollo into React.", 5 | "repository": "git@github.com:molindo/react-apollo-network-status.git", 6 | "author": "Jan Amann ", 7 | "license": "MIT", 8 | "main": "dist/src/index.js", 9 | "types": "dist/src/index.d.ts", 10 | "homepage": "https://github.com/molindo/react-apollo-network-status", 11 | "scripts": { 12 | "lint": "eslint './{src,example}/**/*.{ts,tsx}' && tsc --noEmit", 13 | "test": "jest", 14 | "build": "yarn lint && yarn test && rm -rf dist && yarn build:compile", 15 | "build:compile": "tsc", 16 | "dev": "webpack-dev-server", 17 | "prepublishOnly": "yarn build" 18 | }, 19 | "jest": { 20 | "roots": [ 21 | "src", 22 | "example" 23 | ] 24 | }, 25 | "files": [ 26 | "dist" 27 | ], 28 | "peerDependencies": { 29 | "@apollo/client": "^3.0.0", 30 | "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", 31 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 32 | }, 33 | "resolutions": { 34 | "graphql": "14.5.8" 35 | }, 36 | "devDependencies": { 37 | "@apollo/client": "3.1.5", 38 | "@babel/cli": "7.6.4", 39 | "@babel/core": "7.6.4", 40 | "@babel/plugin-proposal-class-properties": "7.5.5", 41 | "@babel/preset-env": "7.6.3", 42 | "@babel/preset-react": "7.6.3", 43 | "@babel/preset-typescript": "7.6.0", 44 | "@testing-library/react": "10.0.2", 45 | "@types/change-emitter": "0.1.2", 46 | "@types/jest": "25.2.1", 47 | "@types/react": "16.9.9", 48 | "@types/react-dom": "16.9.2", 49 | "@typescript-eslint/eslint-plugin": "2.4.0", 50 | "@typescript-eslint/parser": "2.4.0", 51 | "babel-eslint": "10.0.3", 52 | "babel-jest": "25.3.0", 53 | "babel-loader": "8.1.0", 54 | "eslint": "6.5.1", 55 | "eslint-config-molindo": "4.0.2", 56 | "eslint-import-resolver-typescript": "2.0.0", 57 | "eslint-plugin-css-modules": "2.11.0", 58 | "eslint-plugin-import": "2.18.2", 59 | "eslint-plugin-jsx-a11y": "6.2.3", 60 | "eslint-plugin-prettier": "3.1.1", 61 | "eslint-plugin-react": "7.16.0", 62 | "eslint-plugin-react-hooks": "1.7.0", 63 | "graphql-tag": "2.10.1", 64 | "html-webpack-plugin": "4.3.0", 65 | "jest": "25.3.0", 66 | "prettier": "1.18.2", 67 | "react": "16.13.1", 68 | "react-dom": "16.13.1", 69 | "regenerator-runtime": "0.13.5", 70 | "typescript": "3.7.5", 71 | "webpack": "4.43.0", 72 | "webpack-cli": "3.3.12", 73 | "webpack-dev-server": "3.11.0" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | } 5 | } -------------------------------------------------------------------------------- /src/ActionTypes.ts: -------------------------------------------------------------------------------- 1 | enum ActionTypes { 2 | REQUEST = 'REQUEST', 3 | ERROR = 'ERROR', 4 | SUCCESS = 'SUCCESS', 5 | CANCEL = 'CANCEL' 6 | } 7 | 8 | export default ActionTypes; 9 | -------------------------------------------------------------------------------- /src/ApolloLinkNetworkStatus.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloLink, 3 | Observable, 4 | Operation, 5 | NextLink, 6 | FetchResult 7 | } from '@apollo/client'; 8 | import Dispatcher from './Dispatcher'; 9 | import ActionTypes from './ActionTypes'; 10 | 11 | export default class ApolloLinkNetworkStatus extends ApolloLink { 12 | dispatcher: Dispatcher; 13 | 14 | constructor(dispatcher: Dispatcher) { 15 | super(); 16 | this.dispatcher = dispatcher; 17 | } 18 | 19 | request(operation: Operation, forward: NextLink): Observable { 20 | this.dispatcher.dispatch({ 21 | type: ActionTypes.REQUEST, 22 | payload: {operation} 23 | }); 24 | 25 | const subscriber = forward(operation); 26 | 27 | return new Observable(observer => { 28 | let isPending = true; 29 | 30 | const subscription = subscriber.subscribe({ 31 | next: result => { 32 | isPending = false; 33 | 34 | this.dispatcher.dispatch({ 35 | type: ActionTypes.SUCCESS, 36 | payload: {operation, result} 37 | }); 38 | 39 | observer.next(result); 40 | }, 41 | 42 | error: networkError => { 43 | isPending = false; 44 | 45 | this.dispatcher.dispatch({ 46 | type: ActionTypes.ERROR, 47 | payload: {operation, networkError} 48 | }); 49 | 50 | observer.error(networkError); 51 | }, 52 | 53 | complete: observer.complete.bind(observer) 54 | }); 55 | 56 | return () => { 57 | if (isPending) { 58 | this.dispatcher.dispatch({ 59 | type: ActionTypes.CANCEL, 60 | payload: {operation} 61 | }); 62 | } 63 | 64 | if (subscription) { 65 | subscription.unsubscribe(); 66 | } 67 | }; 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Dispatcher.ts: -------------------------------------------------------------------------------- 1 | import NetworkStatusAction from './NetworkStatusAction'; 2 | 3 | type Listener = (action: NetworkStatusAction) => void; 4 | 5 | export default class Dispatcher { 6 | listeners: Listener[] = []; 7 | 8 | addListener(fn: Listener) { 9 | this.listeners.push(fn); 10 | } 11 | 12 | removeListener(fn: Listener) { 13 | this.listeners = this.listeners.filter(cur => cur !== fn); 14 | } 15 | 16 | dispatch(action: NetworkStatusAction) { 17 | this.listeners.forEach(currentListener => { 18 | currentListener(action); 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/NetworkStatusAction.ts: -------------------------------------------------------------------------------- 1 | import {Operation, ServerError, ServerParseError} from '@apollo/client'; 2 | import {ExecutionResult} from 'graphql'; 3 | import ActionTypes from './ActionTypes'; 4 | 5 | interface Action { 6 | type: ActionTypes; 7 | } 8 | 9 | interface NetworkStatusActionRequest extends Action { 10 | type: typeof ActionTypes.REQUEST; 11 | payload: { 12 | operation: Operation; 13 | }; 14 | } 15 | 16 | interface NetworkStatusActionError extends Action { 17 | type: typeof ActionTypes.ERROR; 18 | payload: { 19 | operation: Operation; 20 | networkError: Error | ServerError | ServerParseError; 21 | }; 22 | } 23 | 24 | interface NetworkStatusActionSuccess extends Action { 25 | type: typeof ActionTypes.SUCCESS; 26 | payload: { 27 | operation: Operation; 28 | result: ExecutionResult; 29 | }; 30 | } 31 | 32 | interface NetworkStatusActionCancel extends Action { 33 | type: typeof ActionTypes.CANCEL; 34 | payload: { 35 | operation: Operation; 36 | }; 37 | } 38 | 39 | type NetworkStatusAction = 40 | | NetworkStatusActionRequest 41 | | NetworkStatusActionError 42 | | NetworkStatusActionSuccess 43 | | NetworkStatusActionCancel; 44 | 45 | export default NetworkStatusAction; 46 | -------------------------------------------------------------------------------- /src/__tests__/index-test.tsx: -------------------------------------------------------------------------------- 1 | import {ApolloProvider, ApolloClient, InMemoryCache} from '@apollo/client'; 2 | import {SchemaLink} from '@apollo/client/link/schema'; 3 | import {render, waitFor, fireEvent} from '@testing-library/react'; 4 | import React from 'react'; 5 | import 'regenerator-runtime/runtime.js'; 6 | import DataFetcher from '../../example/DataFetcher'; 7 | import LazyRender from '../../example/LazyRender'; 8 | import NetworkStatusReporter from '../../example/NetworkStatusReporter'; 9 | import DataUpdater from '../../example/DataUpdater'; 10 | import schema from '../../example/schema'; 11 | import {createNetworkStatusNotifier} from '..'; 12 | 13 | let ConfiguredApolloProvider: any; 14 | let ConfiguredNetworkStatusReporter: any; 15 | beforeEach(() => { 16 | const networkStatusNotifier = createNetworkStatusNotifier(); 17 | 18 | const client = new ApolloClient({ 19 | cache: new InMemoryCache(), 20 | link: networkStatusNotifier.link.concat(new SchemaLink({schema})) 21 | }); 22 | 23 | ConfiguredApolloProvider = ({children}: any) => ( 24 | {children} 25 | ); 26 | ConfiguredNetworkStatusReporter = ({initialOptIn}: any) => ( 27 | 31 | ); 32 | }); 33 | 34 | it('recognizes loading states when fetching lazily', async () => { 35 | const {getByText} = render( 36 | 37 | 38 | 39 | 40 | ); 41 | 42 | getByText('Local status: Idle'); 43 | getByText('Network status: Idle'); 44 | fireEvent.click(getByText('Fetch')); 45 | await waitFor(() => getByText('Local status: Loading …')); 46 | await waitFor(() => getByText('Network status: Loading …')); 47 | await waitFor(() => getByText('User: Jane')); 48 | await waitFor(() => getByText('Network status: Idle')); 49 | }); 50 | 51 | it('recognizes loading states once a subscription is set up and the component fetches immediately', async () => { 52 | const {getByText} = render( 53 | 54 | 55 | 56 | 57 | 58 | 59 | ); 60 | 61 | await waitFor(() => getByText('Local status: Loading …')); 62 | await waitFor(() => getByText('Network status: Loading …')); 63 | await waitFor(() => getByText('User: Jane')); 64 | await waitFor(() => getByText('Network status: Idle')); 65 | }); 66 | 67 | it('recognizes loading states when refetching', async () => { 68 | const {getByText} = render( 69 | 70 | 71 | 72 | 73 | ); 74 | 75 | await waitFor(() => getByText('User: Jane')); 76 | fireEvent.click(getByText('Refetch')); 77 | await waitFor(() => getByText('Local status: Loading …')); 78 | await waitFor(() => getByText('Network status: Loading …')); 79 | 80 | await waitFor(() => getByText('User: Jane')); 81 | await waitFor(() => getByText('Network status: Idle')); 82 | }); 83 | 84 | it('recognizes loading states when variables change', async () => { 85 | function Component({id}: {id?: string}) { 86 | return ( 87 | 88 | 89 | 90 | 91 | ); 92 | } 93 | 94 | const {getByText, rerender} = render(); 95 | 96 | await waitFor(() => getByText('Local status: Loading …')); 97 | await waitFor(() => getByText('User: Jane')); 98 | 99 | rerender(); 100 | await waitFor(() => getByText('Local status: Loading …')); 101 | await waitFor(() => getByText('Network status: Loading …')); 102 | 103 | await waitFor(() => getByText('User: Jane')); 104 | await waitFor(() => getByText('Network status: Idle')); 105 | }); 106 | 107 | it('recognizes mutation loading states', async () => { 108 | const {getByText} = render( 109 | 110 | 111 | 112 | 113 | ); 114 | 115 | fireEvent.click(getByText('Submit')); 116 | await waitFor(() => getByText('Network status: Loading …')); 117 | await waitFor(() => getByText('Network status: Idle')); 118 | }); 119 | 120 | it('incorporates mutation results into the store', async () => { 121 | const {getByText, getByLabelText} = render( 122 | 123 | 124 | 125 | 126 | 127 | ); 128 | 129 | await waitFor(() => getByText('Refetch')); 130 | fireEvent.change(getByLabelText('Update user name:'), { 131 | target: {value: 'Hans'} 132 | }); 133 | fireEvent.click(getByText('Submit')); 134 | await waitFor(() => getByText('User: Hans')); 135 | }); 136 | 137 | it('can configure which operations to handle on a case-by-case basis', async () => { 138 | const {getByText, queryAllByText} = render( 139 | 140 | 141 | 142 | 143 | 144 | 145 | ); 146 | 147 | fireEvent.click(getByText('Submit')); 148 | await waitFor(() => getByText('Network status: Idle')); 149 | getByText('Network status: Loading …'); 150 | await waitFor(() => { 151 | expect(queryAllByText('Network status: Idle').length).toBe(2); 152 | }); 153 | }); 154 | 155 | it('can detect errors', async () => { 156 | const {getByText} = render( 157 | 158 | 159 | 160 | 161 | ); 162 | 163 | fireEvent.click(getByText('Broken fetch')); 164 | await waitFor(() => getByText('Error')); 165 | getByText('Network status: Error'); 166 | }); 167 | -------------------------------------------------------------------------------- /src/createNetworkStatusNotifier.ts: -------------------------------------------------------------------------------- 1 | import NetworkStatusAction from './NetworkStatusAction'; 2 | import ApolloLinkNetworkStatus from './ApolloLinkNetworkStatus'; 3 | import Dispatcher from './Dispatcher'; 4 | import useApolloNetworkStatus, { 5 | UseApolloNetworkStatusOptions 6 | } from './useApolloNetworkStatus'; 7 | import useApolloNetworkStatusReducer from './useApolloNetworkStatusReducer'; 8 | 9 | export default function createNetworkStatusNotifier() { 10 | const dispatcher = new Dispatcher(); 11 | const link = new ApolloLinkNetworkStatus(dispatcher); 12 | 13 | function useConfiguredApolloNetworkStatus( 14 | options?: UseApolloNetworkStatusOptions 15 | ) { 16 | return useApolloNetworkStatus(dispatcher, options); 17 | } 18 | 19 | function useConfiguredApolloNetworkStatusReducer( 20 | reducer: (state: T, action: NetworkStatusAction) => T, 21 | initialState: T 22 | ) { 23 | return useApolloNetworkStatusReducer(dispatcher, reducer, initialState); 24 | } 25 | 26 | return { 27 | link, 28 | useApolloNetworkStatus: useConfiguredApolloNetworkStatus, 29 | useApolloNetworkStatusReducer: useConfiguredApolloNetworkStatusReducer 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {default as ActionTypes} from './ActionTypes'; 2 | export {default as NetworkStatusAction} from './NetworkStatusAction'; 3 | export { 4 | default as createNetworkStatusNotifier 5 | } from './createNetworkStatusNotifier'; 6 | export {OperationError, NetworkStatus} from './useApolloNetworkStatus'; 7 | -------------------------------------------------------------------------------- /src/useApolloNetworkStatus.ts: -------------------------------------------------------------------------------- 1 | import {Operation, ServerError, ServerParseError} from '@apollo/client'; 2 | import {OperationTypeNode, ExecutionResult, GraphQLError} from 'graphql'; 3 | import {useMemo} from 'react'; 4 | import Dispatcher from './Dispatcher'; 5 | import ActionTypes from './ActionTypes'; 6 | import NetworkStatusAction from './NetworkStatusAction'; 7 | import useApolloNetworkStatusReducer from './useApolloNetworkStatusReducer'; 8 | import useEventCallback from './useEventCallback'; 9 | 10 | /** 11 | * Applies reasonable defaults to `useApolloNetworkStatusReducer`. 12 | */ 13 | 14 | export type OperationError = { 15 | networkError?: Error | ServerError | ServerParseError; 16 | operation: Operation; 17 | response?: ExecutionResult; 18 | graphQLErrors?: ReadonlyArray; 19 | }; 20 | 21 | export type NetworkStatus = { 22 | numPendingQueries: number; 23 | numPendingMutations: number; 24 | queryError?: OperationError; 25 | mutationError?: OperationError; 26 | }; 27 | 28 | function isOperationType(operation: Operation, type: OperationTypeNode) { 29 | return operation.query.definitions.some( 30 | definition => 31 | definition.kind === 'OperationDefinition' && definition.operation === type 32 | ); 33 | } 34 | 35 | function pendingOperations(type: OperationTypeNode) { 36 | return function pendingOperationsByType( 37 | state: number = 0, 38 | action: NetworkStatusAction 39 | ) { 40 | if (!isOperationType(action.payload.operation, type)) { 41 | return state; 42 | } 43 | 44 | switch (action.type) { 45 | case ActionTypes.REQUEST: 46 | return state + 1; 47 | 48 | case ActionTypes.ERROR: 49 | case ActionTypes.SUCCESS: 50 | case ActionTypes.CANCEL: 51 | // Just to be safe. See also the comment about `useEffect` 52 | // in `./useApolloNetworkStatusReducer.js` 53 | return Math.max(state - 1, 0); 54 | } 55 | 56 | return state; 57 | }; 58 | } 59 | 60 | function latestOperationError(type: OperationTypeNode) { 61 | return function latestOperationErrorByType( 62 | state: OperationError | undefined, 63 | action: NetworkStatusAction 64 | ): OperationError | undefined { 65 | if (!isOperationType(action.payload.operation, type)) { 66 | return state; 67 | } 68 | 69 | switch (action.type) { 70 | case ActionTypes.REQUEST: 71 | return undefined; 72 | 73 | case ActionTypes.ERROR: { 74 | const {networkError, operation} = action.payload; 75 | return {networkError, operation}; 76 | } 77 | 78 | case ActionTypes.SUCCESS: { 79 | const {result, operation} = action.payload; 80 | 81 | if (result && result.errors) { 82 | return {graphQLErrors: result.errors, response: result, operation}; 83 | } else { 84 | return state; 85 | } 86 | } 87 | } 88 | 89 | return state; 90 | }; 91 | } 92 | 93 | const pendingQueries = pendingOperations('query'); 94 | const pendingMutations = pendingOperations('mutation'); 95 | 96 | const queryError = latestOperationError('query'); 97 | const mutationError = latestOperationError('mutation'); 98 | 99 | function reducer( 100 | state: NetworkStatus, 101 | action: NetworkStatusAction 102 | ): NetworkStatus { 103 | if (isOperationType(action.payload.operation, 'subscription')) { 104 | return state; 105 | } 106 | 107 | const updatedState = {...state}; 108 | 109 | // Pending operations 110 | updatedState.numPendingQueries = pendingQueries( 111 | updatedState.numPendingQueries, 112 | action 113 | ); 114 | updatedState.numPendingMutations = pendingMutations( 115 | updatedState.numPendingMutations, 116 | action 117 | ); 118 | 119 | // Latest errors 120 | updatedState.queryError = queryError(updatedState.queryError, action); 121 | updatedState.mutationError = mutationError( 122 | updatedState.mutationError, 123 | action 124 | ); 125 | 126 | // The identity of the state should be kept if possible to avoid unnecessary re-renders. 127 | const haveValuesChanged = Object.keys(state).some( 128 | key => (updatedState as any)[key] !== (state as any)[key] 129 | ); 130 | return haveValuesChanged ? updatedState : state; 131 | } 132 | 133 | const initialState: NetworkStatus = { 134 | numPendingQueries: 0, 135 | numPendingMutations: 0, 136 | queryError: undefined, 137 | mutationError: undefined 138 | }; 139 | 140 | function defaultShouldHandleOperation(operation: Operation) { 141 | // Enable opt-out per operation 142 | return operation.getContext().useApolloNetworkStatus !== false; 143 | } 144 | 145 | export type UseApolloNetworkStatusOptions = { 146 | shouldHandleOperation?: (operation: Operation) => boolean; 147 | }; 148 | 149 | export default function useApolloNetworkStatus( 150 | dispatcher: Dispatcher, 151 | options?: UseApolloNetworkStatusOptions 152 | ) { 153 | if (!options) options = {}; 154 | const shouldHandleOperation = 155 | options.shouldHandleOperation || defaultShouldHandleOperation; 156 | 157 | // Performance optimization to allow changing this option 158 | // via props without causing the reducer to change. 159 | const shouldHandleOperationEventCallback = useEventCallback( 160 | shouldHandleOperation 161 | ); 162 | 163 | const configuredReducer = useMemo( 164 | () => (state: NetworkStatus, action: NetworkStatusAction) => { 165 | if (!shouldHandleOperationEventCallback(action.payload.operation)) { 166 | return state; 167 | } 168 | 169 | return reducer(state, action); 170 | }, 171 | [shouldHandleOperationEventCallback] 172 | ); 173 | 174 | return useApolloNetworkStatusReducer( 175 | dispatcher, 176 | configuredReducer, 177 | initialState 178 | ); 179 | } 180 | -------------------------------------------------------------------------------- /src/useApolloNetworkStatusReducer.ts: -------------------------------------------------------------------------------- 1 | import {useReducer, useEffect} from 'react'; 2 | import Dispatcher from './Dispatcher'; 3 | import NetworkStatusAction from './NetworkStatusAction'; 4 | 5 | /** 6 | * Lower level hook which can be used for customizing the resulting state. 7 | */ 8 | 9 | export default function useApolloNetworkStatusReducer( 10 | dispatcher: Dispatcher, 11 | reducer: (state: T, action: NetworkStatusAction) => T, 12 | initialState: T 13 | ) { 14 | const [status, dispatch] = useReducer(reducer, initialState); 15 | 16 | // Effects fire bottom-up. Therefore it's possible that when a query is nested 17 | // further down the tree than a component using this hook and the query fires 18 | // on the initial render, we'll miss the request event. Note that this isn't 19 | // an issue when pre-rendering or fetching on the server side. 20 | useEffect(() => { 21 | let animationFrameId: number; 22 | 23 | function onDispatch(action: NetworkStatusAction) { 24 | // Apollo fetches data while rendering and therefore invoking a GraphQL 25 | // request would trigger an update while another component renders. In 26 | // React@^16.13.1 this triggers a warning. To avoid this, we can handle 27 | // all network events at the beginning of the next frame. 28 | animationFrameId = requestAnimationFrame(() => { 29 | dispatch(action); 30 | }); 31 | } 32 | 33 | dispatcher.addListener(onDispatch); 34 | return () => { 35 | dispatcher.removeListener(onDispatch); 36 | cancelAnimationFrame(animationFrameId); 37 | }; 38 | }, [dispatcher]); 39 | 40 | return status; 41 | } 42 | -------------------------------------------------------------------------------- /src/useEventCallback.ts: -------------------------------------------------------------------------------- 1 | import {useRef, useCallback} from 'react'; 2 | import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect'; 3 | 4 | export default function useEventCallback(fn: Function) { 5 | const ref = useRef(() => { 6 | throw new Error('Function is called before it was assigned.'); 7 | }); 8 | 9 | useIsomorphicLayoutEffect(() => { 10 | ref.current = fn; 11 | }); 12 | 13 | return useCallback((...args) => ref.current(...args), []); 14 | } 15 | -------------------------------------------------------------------------------- /src/useIsomorphicLayoutEffect.ts: -------------------------------------------------------------------------------- 1 | import {useLayoutEffect, useEffect} from 'react'; 2 | 3 | export default typeof window !== 'undefined' ? useLayoutEffect : useEffect; 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "lib": ["dom", "es2018"], 5 | "outDir": "./dist", 6 | "declaration": true, 7 | "allowSyntheticDefaultImports": true, 8 | "noImplicitAny": true, 9 | "strictNullChecks": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | 4 | module.exports = { 5 | entry: './example/index.tsx', 6 | mode: 'development', 7 | module: { 8 | rules: [ 9 | { 10 | test: /\.tsx?$/, 11 | exclude: /node_modules/, 12 | use: 'babel-loader' 13 | }, 14 | { 15 | test: /\.mjs$/, 16 | include: /node_modules/, 17 | type: 'javascript/auto' 18 | } 19 | ] 20 | }, 21 | resolve: { 22 | extensions: ['.tsx', '.ts', '.js', '.mjs'] 23 | }, 24 | output: { 25 | path: path.resolve(__dirname, 'dist'), 26 | filename: 'bundle.js' 27 | }, 28 | devServer: { 29 | open: true 30 | }, 31 | plugins: [ 32 | new HtmlWebpackPlugin({ 33 | filename: 'index.html', 34 | template: 'example/index.html' 35 | }) 36 | ] 37 | }; 38 | --------------------------------------------------------------------------------