├── .eslintrc.js ├── .github ├── FUNDING.yml └── workflows │ └── build.yaml ├── .gitignore ├── .npmignore ├── .prettierrc ├── LICENSE ├── README.md ├── __tests__ ├── ReactRelayQueryRenderer-test.tsx ├── RelayModernEnvironmentMock.ts ├── RelayOfflineHydrate-test.tsx ├── RelayOfflineTTL-test.tsx ├── __generated__ │ └── .gitignore └── relay.tsx ├── docs ├── ReactRelayOffline-Introduction.md └── assets │ └── memorang-logo.png ├── jest.config.js ├── package-lock.json ├── package.json ├── scripts └── setup.ts ├── src-test └── index.ts ├── src ├── RelayOfflineTypes.ts ├── hooks │ ├── useLazyLoadQueryOffline.ts │ ├── useOffline.ts │ ├── usePreloadedQueryOffline.ts │ ├── useQueryOffline.ts │ └── useRestore.ts ├── index.ts └── runtime │ ├── EnvironmentIDB.ts │ └── loadQuery.ts ├── tsconfig-test.json ├── tsconfig.json └── website ├── .gitignore ├── core └── Footer.js ├── i18n └── en.json ├── package.json ├── sidebars.json ├── siteConfig.js ├── static ├── css │ └── custom.css ├── img │ ├── favicon.ico │ ├── oss_logo.png │ ├── undraw_code_review.svg │ ├── undraw_monitor.svg │ ├── undraw_note_list.svg │ ├── undraw_online.svg │ ├── undraw_open_source.svg │ ├── undraw_operating_system.svg │ ├── undraw_react.svg │ ├── undraw_tweetstorm.svg │ └── undraw_youtube_tutorial.svg └── index.html └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 4 | 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 5 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 6 | "plugin:react/recommended" 7 | ], 8 | parserOptions: { 9 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features 10 | sourceType: 'module', // Allows for the use of imports 11 | }, 12 | parser: '@typescript-eslint/parser', 13 | plugins: ['@typescript-eslint', 'react', 'prettier', 'eslint-plugin-import'], 14 | settings: { 15 | 'import/parsers': { 16 | '@typescript-eslint/parser': ['.ts', '.tsx'], 17 | }, 18 | 'import/resolver': { 19 | typescript: {}, 20 | }, 21 | }, 22 | rules: { 23 | 'react/jsx-filename-extension': [2, { extensions: ['.js', '.jsx', '.ts', '.tsx'] }], 24 | 'import/no-extraneous-dependencies': [2, { devDependencies: ['**/test.tsx', '**/test.ts'] }], 25 | "indent": [2, 4, { "SwitchCase": 1 }], 26 | "lines-between-class-members": ["error", "always", { exceptAfterSingleLine: true }], 27 | "space-before-function-paren": ["error", { "anonymous": "never", "named": "never", "asyncArrow": "ignore" }], 28 | "max-len": [ 29 | 2, 30 | { 31 | "code": 140, 32 | "ignorePattern": "^import [^,]+ from |^export | implements" 33 | } 34 | ], 35 | "@typescript-eslint/interface-name-prefix": [2, { "prefixWithI": "never" }], 36 | "@typescript-eslint/no-unused-vars": [2, { "argsIgnorePattern": "^_" }], 37 | "object-curly-newline": ["error", { "consistent": true }], 38 | "@typescript-eslint/no-explicit-any": [0], 39 | "no-plusplus": ["error", { "allowForLoopAfterthoughts": true }], 40 | "linebreak-style": [0], 41 | "@typescript-eslint/no-use-before-define": [0], 42 | }, 43 | }; -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [morrys] 2 | custom: https://www.paypal.me/m0rrys 3 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | 8 | strategy: 9 | matrix: 10 | node-version: [12.x] 11 | 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | - name: install dependencies 19 | run: npm ci 20 | - name: compile 21 | run: npm run compile 22 | - name: lint 23 | run: npm run eslint 24 | - name: test 25 | run: npm run test 26 | env: 27 | CI: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cache-persist 2 | node_modules 3 | lib 4 | test 5 | *.tgz 6 | coverage 7 | .DS_Store 8 | .idea -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | src-test/ 3 | *.tgz 4 | tsconfig.json 5 | tsconfig-test.json 6 | examples/ 7 | website/ 8 | docs/ 9 | scripts 10 | coverage 11 | __tests__ 12 | __mocks__ 13 | .github 14 | .eslintrc.js 15 | .prettierrc 16 | jest.config.js -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "arrowParens": "always", 6 | "printWidth": 140, 7 | "parser": "typescript", 8 | "endOfLine": "auto" 9 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Lorenzo Di Giacomo 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 | --- 2 | id: react-relay-offline 3 | title: Getting Started 4 | --- 5 | 6 | # [React Relay Offline](https://github.com/morrys/react-relay-offline) 7 | 8 | React Relay Offline is a extension of [Relay](https://facebook.github.io/relay/) for offline capabilities 9 | 10 | ## Installation React Web 11 | 12 | Install react-relay and react-relay-offline using yarn or npm: 13 | 14 | ``` 15 | yarn add react-relay react-relay-offline 16 | ``` 17 | 18 | ## Installation React Native 19 | 20 | Install react-relay and react-relay-offline using yarn or npm: 21 | 22 | ``` 23 | yarn add @react-native-community/netinfo react-relay react-relay-offline 24 | ``` 25 | 26 | You then need to do some extra configurations to run netinfo package with React Native. Please check [@react-native-community/netinfo official README.md](https://github.com/react-native-netinfo/react-native-netinfo#using-react-native--060) to get the full step guide. 27 | 28 | ## Main Additional Features 29 | 30 | - automatic persistence and rehydration of the store (AsyncStorage, localStorage, IndexedDB) 31 | 32 | - configuration of persistence 33 | 34 | - custom storage 35 | 36 | - different key prefix (multi user) 37 | 38 | - serialization: JSON or none 39 | 40 | - fetchPolicy network-only, store-and-network, store-or-network, store-only 41 | 42 | - management and utilities for network detection 43 | 44 | - automatic use of the policy **store-only** when the application is offline 45 | 46 | - optimization in store management and addition of **TTL** to queries in the store 47 | 48 | - offline mutation management 49 | 50 | - backup of mutation changes 51 | 52 | - update and publication of the mutation changes in the store 53 | 54 | - persistence of mutation information performed 55 | 56 | - automatic execution of mutations persisted when the application returns online 57 | 58 | - configurability of the offline mutation execution network 59 | 60 | - onComplete callback of the mutation performed successfully 61 | 62 | - onDiscard callback of the failed mutation 63 | 64 | ## Contributing 65 | 66 | - **Give a star** to the repository and **share it**, you will **help** the **project** and the **people** who will find it useful 67 | 68 | - **Create issues**, your **questions** are a **valuable help** 69 | 70 | - **PRs are welcome**, but it is always **better to open the issue first** so as to **help** me and other people **evaluating it** 71 | 72 | - **Please sponsor me** 73 | 74 | ### Sponsors 75 | 76 | Memorang 77 | 78 | ## react-relay-offline examples 79 | 80 | The [offline-examples](https://github.com/morrys/offline-examples) repository contains example projects on how to use react-relay-offline: 81 | 82 | * `nextjs-ssr-preload`: using the render-as-you-fetch pattern with loadQuery in SSR contexts 83 | * `nextjs`: using the QueryRenderer in SSR contexts 84 | * `react-native/todo-updater`: using QueryRender in an RN application 85 | * `todo-updater`: using the QueryRender 86 | * `suspense/cra`: using useLazyLoadQuery in a CRA 87 | * `suspense/nextjs-ssr-preload`: using the render-as-you-fetch pattern with loadLazyQuery in react concurrent + SSR contexts 88 | * `suspense/nextjs-ssr`: using useLazyLoadQuery in SSR contexts 89 | 90 | To try it out! 91 | 92 | 93 | ## Environment 94 | 95 | ```ts 96 | import { Network } from "relay-runtime"; 97 | import { RecordSource, Store, Environment } from "react-relay-offline"; 98 | 99 | const network = Network.create(fetchQuery); 100 | const recordSource = new RecordSource(); 101 | const store = new Store(recordSource); 102 | const environment = new Environment({ network, store }); 103 | ``` 104 | 105 | ## Environment with Offline Options 106 | 107 | ```ts 108 | import { Network } from "relay-runtime"; 109 | import { RecordSource, Store, Environment } from "react-relay-offline"; 110 | 111 | const network = Network.create(fetchQuery); 112 | 113 | const networkOffline = Network.create(fetchQueryOffline); 114 | const manualExecution = false; 115 | 116 | const recordSource = new RecordSource(); 117 | const store = new Store(recordSource); 118 | const environment = new Environment({ network, store }); 119 | environment.setOfflineOptions({ 120 | manualExecution, //optional 121 | network: networkOffline, //optional 122 | start: async mutations => { 123 | //optional 124 | console.log("start offline", mutations); 125 | return mutations; 126 | }, 127 | finish: async (mutations, error) => { 128 | //optional 129 | console.log("finish offline", error, mutations); 130 | }, 131 | onExecute: async mutation => { 132 | //optional 133 | console.log("onExecute offline", mutation); 134 | return mutation; 135 | }, 136 | onComplete: async options => { 137 | //optional 138 | console.log("onComplete offline", options); 139 | return true; 140 | }, 141 | onDiscard: async options => { 142 | //optional 143 | console.log("onDiscard offline", options); 144 | return true; 145 | }, 146 | onPublish: async offlinePayload => { 147 | //optional 148 | console.log("offlinePayload", offlinePayload); 149 | return offlinePayload; 150 | } 151 | }); 152 | ``` 153 | 154 | - manualExecution: if set to true, mutations in the queue are no longer performed automatically as soon as you go back online. invoke manually: `environment.getStoreOffline().execute();` 155 | 156 | - network: it is possible to configure a different network for the execution of mutations in the queue; all the information of the mutation saved in the offline store are inserted into the "metadata" field of the CacheConfig so that they can be used during communication with the server. 157 | 158 | * start: function that is called once the request queue has been started. 159 | 160 | * finish: function that is called once the request queue has been processed. 161 | 162 | * onExecute: function that is called before the request is sent to the network. 163 | 164 | * onPublish: function that is called before saving the mutation in the store 165 | 166 | * onComplete: function that is called once the request has been successfully completed. Only if the function returns the value true, the request is deleted from the queue. 167 | 168 | * onDiscard: function that is called when the request returns an error. Only if the function returns the value true, the mutation is deleted from the queue 169 | 170 | ## IndexedDB 171 | 172 | localStorage is used as the default react web persistence, while AsyncStorage is used for react-native. 173 | 174 | To use persistence via IndexedDB: 175 | 176 | ```ts 177 | import { Network } from "relay-runtime"; 178 | import EnvironmentIDB from "react-relay-offline/lib/runtime/EnvironmentIDB"; 179 | 180 | const network = Network.create(fetchQuery); 181 | const environment = EnvironmentIDB.create({ network }); 182 | ``` 183 | 184 | ## Environment with PersistOfflineOptions 185 | 186 | ```ts 187 | import { Network } from "relay-runtime"; 188 | import { RecordSource, Store, Environment } from "react-relay-offline"; 189 | import { CacheOptions } from "@wora/cache-persist"; 190 | 191 | const network = Network.create(fetchQuery); 192 | 193 | const networkOffline = Network.create(fetchQueryOffline); 194 | 195 | const persistOfflineOptions: CacheOptions = { 196 | prefix: "app-user1" 197 | }; 198 | const recordSource = new RecordSource(); 199 | const store = new Store(recordSource); 200 | const environment = new Environment({ network, store }, persistOfflineOptions); 201 | ``` 202 | 203 | [CacheOptions](https://morrys.github.io/wora/docs/cache-persist.html#cache-options) 204 | 205 | ## Store with custom options 206 | 207 | ```ts 208 | import { Store } from "react-relay-offline"; 209 | import { CacheOptions } from "@wora/cache-persist"; 210 | import { StoreOptions } from "@wora/relay-store"; 211 | 212 | const persistOptionsStore: CacheOptions = { }; 213 | const persistOptionsRecords: CacheOptions = {}; 214 | const relayStoreOptions: StoreOptions = { queryCacheExpirationTime: 10 * 60 * 1000 }; // default 215 | const recordSource = new RecordSource(persistOptionsRecords); 216 | const store = new Store(recordSource, persistOptionsStore, relayStoreOptions); 217 | const environment = new Environment({ network, store }); 218 | ``` 219 | 220 | 221 | ## useQuery 222 | 223 | `useQuery` does not take an environment as an argument. Instead, it reads the environment set in the context; this also implies that it does not set any React context. 224 | In addition to `query` (first argument) and `variables` (second argument), `useQuery` accepts a third argument `options`. 225 | 226 | **options** 227 | 228 | `fetchPolicy`: determine whether it should use data cached in the Relay store and whether to send a network request. The options are: 229 | * `store-or-network` (default): Reuse data cached in the store; if the whole query is cached, skip the network request 230 | * `store-and-network`: Reuse data cached in the store; always send a network request. 231 | * `network-only`: Don't reuse data cached in the store; always send a network request. (This is the default behavior of Relay's existing `QueryRenderer`.) 232 | * `store-only`: Reuse data cached in the store; never send a network request. 233 | 234 | `fetchKey`: [Optional] A fetchKey can be passed to force a refetch of the current query and variables when the component re-renders, even if the variables didn't change, or even if the component isn't remounted (similarly to how passing a different key to a React component will cause it to remount). If the fetchKey is different from the one used in the previous render, the current query and variables will be refetched. 235 | 236 | `networkCacheConfig`: [Optional] Object containing cache config options for the network layer. Note the the network layer may contain an additional query response cache which will reuse network responses for identical queries. If you want to bypass this cache completely, pass {force: true} as the value for this option. **Added the TTL property to configure a specific ttl for the query.** 237 | 238 | `skip`: [Optional] If skip is true, the query will be skipped entirely. 239 | 240 | `onComplete`: [Optional] Function that will be called whenever the fetch request has completed 241 | 242 | ```ts 243 | import { useQuery } from "react-relay-offline"; 244 | const networkCacheConfig = { 245 | ttl: 1000 246 | } 247 | const hooksProps = useQuery(query, variables, { 248 | networkCacheConfig, 249 | fetchPolicy, 250 | }); 251 | ``` 252 | 253 | ## useLazyLoadQuery 254 | 255 | ```ts 256 | import { useQuery } from "react-relay-offline"; 257 | const networkCacheConfig = { 258 | ttl: 1000 259 | } 260 | const hooksProps = useLazyLoadQuery(query, variables, { 261 | networkCacheConfig, 262 | fetchPolicy, 263 | }); 264 | ``` 265 | 266 | ## useRestore & loading 267 | 268 | the **useRestore** hook allows you to manage the hydratation of persistent data in memory and to initialize the environment. 269 | 270 | **It must always be used before using environement in web applications without SSR & react legacy & react-native.** 271 | 272 | **Otherwise, for SSR and react concurrent applications the restore is natively managed by QueryRenderer & useQueryLazyLoad & useQuery.** 273 | 274 | ``` 275 | const isRehydrated = useRestore(environment); 276 | if (!isRehydrated) { 277 | return ; 278 | } 279 | ``` 280 | 281 | ## fetchQuery_DEPRECATED 282 | 283 | ```ts 284 | import { fetchQuery_DEPRECATED } from "react-relay-offline"; 285 | ``` 286 | 287 | 288 | ## Detect Network 289 | 290 | ```ts 291 | import { useIsConnected } from "react-relay-offline"; 292 | import { useNetInfo } from "react-relay-offline"; 293 | import { NetInfo } from "react-relay-offline"; 294 | ``` 295 | 296 | ## Supports Hooks from relay-hooks 297 | 298 | Now you can use hooks (useFragment, usePagination, useRefetch) from [relay-hooks](https://github.com/relay-tools/relay-hooks) 299 | 300 | ## render-as-you-fetch & usePreloadedQuery 301 | 302 | ### loadQuery 303 | 304 | * input parameters 305 | 306 | same as useQuery + environment 307 | 308 | * output parameters 309 | * 310 | `next: ( 311 | environment: Environment, 312 | gqlQuery: GraphQLTaggedNode, 313 | variables?: TOperationType['variables'], 314 | options?: QueryOptions, 315 | ) => Promise`: fetches data. A promise returns to allow the await in case of SSR 316 | * `dispose: () => void`: cancel the subscription and dispose of the fetch 317 | * `subscribe: (callback: (value: any) => any) => () => void`: used by the usePreloadedQuery 318 | * `getValue (environment?: Environment,) => OfflineRenderProps | Promise`: used by the usePreloadedQuery 319 | 320 | ```ts 321 | import {graphql, loadQuery} from 'react-relay-offline'; 322 | import {environment} from ''./environment'; 323 | 324 | const query = graphql` 325 | query AppQuery($id: ID!) { 326 | user(id: $id) { 327 | name 328 | } 329 | } 330 | `; 331 | 332 | const prefetch = loadQuery(); 333 | prefetch.next( 334 | environment, 335 | query, 336 | {id: '4'}, 337 | {fetchPolicy: 'store-or-network'}, 338 | ); 339 | // pass prefetch to usePreloadedQuery() 340 | ``` 341 | 342 | ### loadLazyQuery 343 | 344 | **is the same as loadQuery but must be used with suspense** 345 | 346 | ### render-as-you-fetch in SSR 347 | 348 | In SSR contexts, **not using the useRestore hook** it is necessary to manually invoke the hydrate but without using the await. 349 | 350 | This will allow the usePreloadedQuery hook to correctly retrieve the data from the store and once the hydration is done it will be react-relay-offline 351 | 352 | to notify any updated data in the store. 353 | 354 | ```ts 355 | if (!environment.isRehydrated() && ssr) { 356 | environment.hydrate().then(() => {}).catch((error) => {}); 357 | } 358 | prefetch.next(environment, QUERY_APP, variables, { 359 | fetchPolicy: NETWORK_ONLY, 360 | }); 361 | ``` 362 | 363 | ## Requirement 364 | 365 | - Version >=11.0.2 of the relay-runtime library 366 | - When a new node is created by mutation the id must be generated in the browser to use it in the optimistic response 367 | 368 | ## License 369 | 370 | React Relay Offline is [MIT licensed](./LICENSE). 371 | -------------------------------------------------------------------------------- /__tests__/RelayModernEnvironmentMock.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-ignore */ 2 | /* eslint-disable @typescript-eslint/no-empty-interface */ 3 | /* eslint-disable @typescript-eslint/interface-name-prefix */ 4 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 5 | /** 6 | * Copyright (c) Facebook, Inc. and its affiliates. 7 | * 8 | * This source code is licensed under the MIT license found in the 9 | * LICENSE file in the root directory of this source tree. 10 | * 11 | * @format 12 | * @flow strict-local 13 | */ 14 | 15 | 'use strict'; 16 | 17 | /* global jest */ 18 | 19 | import * as areEqual from 'fbjs/lib/areEqual'; 20 | import * as invariant from 'fbjs/lib/invariant'; 21 | 22 | import { QueryResponseCache, Observable, Network, EnvironmentConfig } from 'relay-runtime'; 23 | 24 | import { Store, RecordSource, Environment } from '../src'; 25 | import { createPersistedStorage } from './Utils'; 26 | 27 | type PendingRequest = { 28 | request: any; 29 | variables: any; 30 | cacheConfig: any; 31 | sink: any; 32 | }; 33 | 34 | const MAX_SIZE = 10; 35 | const MAX_TTL = 5 * 60 * 1000; // 5 min 36 | 37 | function mockInstanceMethod(object: any, key: string) { 38 | object[key] = jest.fn(object[key].bind(object)); 39 | } 40 | 41 | function mockDisposableMethod(object: any, key: string) { 42 | const fn = object[key].bind(object); 43 | object[key] = jest.fn((...args) => { 44 | const disposable = fn(...args); 45 | const dispose = jest.fn(() => disposable.dispose()); 46 | object[key].mock.dispose = dispose; 47 | return { dispose }; 48 | }); 49 | const mockClear = object[key].mockClear.bind(object[key]); 50 | object[key].mockClear = () => { 51 | mockClear(); 52 | object[key].mock.dispose = null; 53 | }; 54 | } 55 | 56 | function mockObservableMethod(object: any, key: string) { 57 | const fn = object[key].bind(object); 58 | const subscriptions = []; 59 | object[key] = jest.fn((...args) => 60 | fn(...args).do({ 61 | start: (subscription) => { 62 | subscriptions.push(subscription); 63 | }, 64 | }), 65 | ); 66 | object[key].mock.subscriptions = subscriptions; 67 | const mockClear = object[key].mockClear.bind(object[key]); 68 | object[key].mockClear = () => { 69 | mockClear(); 70 | object[key].mock.subscriptions = []; 71 | }; 72 | } 73 | 74 | type OperationMockResolver = (operation: any) => any | Error; 75 | 76 | type MockFunctions = { 77 | clearCache: () => void; 78 | cachePayload: (request: any | any, variables: any, payload: any) => void; 79 | isLoading: (request: any | any, variables: any, cacheConfig?: any) => boolean; 80 | reject: (request: any | any, error: Error | string) => void; 81 | nextValue: (request: any | any, payload: any) => void; 82 | complete: (request: any | any) => void; 83 | resolve: (request: any | any, payload: any) => void; 84 | getAllOperations: () => ReadonlyArray; 85 | findOperation: (findFn: (operation: any) => boolean) => any; 86 | getMostRecentOperation: () => any; 87 | resolveMostRecentOperation: (payload: any | ((operation: any) => any)) => void; 88 | rejectMostRecentOperation: (error: Error | ((operation: any) => Error)) => void; 89 | queueOperationResolver: (resolver: OperationMockResolver) => void; 90 | }; 91 | 92 | interface MockEnvironment { 93 | mock: MockFunctions; 94 | mockClear: () => void; 95 | } 96 | 97 | export interface RelayMockEnvironment extends MockEnvironment {} 98 | 99 | /** 100 | * Creates an instance of the `Environment` interface defined in 101 | * RelayStoreTypes with a mocked network layer. 102 | * 103 | * Usage: 104 | * 105 | * ``` 106 | * const environment = RelayModernMockEnvironment.createMockEnvironment(); 107 | * ``` 108 | * 109 | * Mock API: 110 | * 111 | * Helpers are available as `environment.mock.`: 112 | * 113 | * - `isLoading(query, variables): boolean`: Determine whether the given query 114 | * is currently being loaded (not yet rejected/resolved). 115 | * - `reject(query, error: Error): void`: Reject a query that has been fetched 116 | * by the environment. 117 | * - `resolve(query, payload: PayloadData): void`: Resolve a query that has been 118 | * fetched by the environment. 119 | * - `nextValue(...) - will add payload to the processing, but won't complete 120 | * the request () 121 | * - getAllOperations() - every time there is an operation created by 122 | * the Relay Component (query, mutation, subscription) this operation will be 123 | * added to the internal list on the Mock Environment. This method will return 124 | * an array of all pending operations in the order they occurred. 125 | * - findOperation(findFn) - should find operation if findFn(...) return `true` 126 | * for it. Otherwise, it will throw. 127 | * - getMostRecentOperation(...) - should return the most recent operation 128 | * generated by Relay Component. 129 | * - resolveMostRecentOperation(...) - is accepting `any` or a 130 | * callback function that will receive `operation` and should return 131 | * `any` 132 | * - rejectMostRecentOperation(...) - should reject the most recent operation 133 | * with a specific error 134 | */ 135 | export function createMockEnvironment(config?: Partial): RelayMockEnvironment { 136 | const store = 137 | config?.store ?? 138 | new Store( 139 | new RecordSource({ storage: createPersistedStorage() }), 140 | { 141 | storage: createPersistedStorage(), 142 | }, 143 | { 144 | queryCacheExpirationTime: -1, 145 | }, 146 | ); 147 | const cache = new QueryResponseCache({ 148 | size: MAX_SIZE, 149 | ttl: MAX_TTL, 150 | }); 151 | 152 | let pendingRequests: ReadonlyArray = []; 153 | let pendingOperations: ReadonlyArray = []; 154 | let resolversQueue: ReadonlyArray = []; 155 | 156 | const queueOperationResolver = (resolver: OperationMockResolver): void => { 157 | resolversQueue = resolversQueue.concat([resolver]); 158 | }; 159 | 160 | // Mock the network layer 161 | const execute = (request: any, variables: any, cacheConfig: any) => { 162 | const { id, text } = request; 163 | const cacheID = id ?? text; 164 | 165 | let cachedPayload = null; 166 | if ((cacheConfig?.force == null || cacheConfig?.force === false) && cacheID != null) { 167 | cachedPayload = cache.get(cacheID, variables); 168 | } 169 | if (cachedPayload !== null) { 170 | return Observable.from(cachedPayload); 171 | } 172 | 173 | const currentOperation = pendingOperations.find( 174 | (op) => op.request.node.params === request && areEqual(op.request.variables, variables), 175 | ); 176 | 177 | // Handle network responses added by 178 | if (currentOperation != null && resolversQueue.length > 0) { 179 | const currentResolver = resolversQueue[0]; 180 | const result = currentResolver(currentOperation); 181 | if (result != null) { 182 | resolversQueue = resolversQueue.filter((res) => res !== currentResolver); 183 | pendingOperations = pendingOperations.filter((op) => op !== currentOperation); 184 | if (result instanceof Error) { 185 | return Observable.create((sink) => { 186 | sink.error(result); 187 | }); 188 | } else { 189 | return Observable.from(result); 190 | } 191 | } 192 | } 193 | 194 | return Observable.create((sink) => { 195 | const nextRequest = { request, variables, cacheConfig, sink }; 196 | pendingRequests = pendingRequests.concat([nextRequest]); 197 | 198 | return () => { 199 | pendingRequests = pendingRequests.filter((pending) => !areEqual(pending, nextRequest)); 200 | pendingOperations = pendingOperations.filter((op) => op !== currentOperation); 201 | }; 202 | }); 203 | }; 204 | 205 | function getConcreteRequest(input: any | any): any { 206 | if (input.kind === 'Request') { 207 | const request: any = input as any; 208 | return request; 209 | } else { 210 | const operationDescriptor: any = input as any; 211 | invariant( 212 | pendingOperations.includes(operationDescriptor), 213 | 'RelayModernMockEnvironment: Operation "%s" was not found in the list of pending operations', 214 | operationDescriptor.request.node.operation.name, 215 | ); 216 | return operationDescriptor.request.node; 217 | } 218 | } 219 | 220 | // The same request may be made by multiple query renderers 221 | function getRequests(input: any | any): ReadonlyArray { 222 | let concreteRequest: any; 223 | let operationDescriptor: any; 224 | if (input.kind === 'Request') { 225 | concreteRequest = input as any; 226 | } else { 227 | operationDescriptor = input as any; 228 | concreteRequest = operationDescriptor.request.node; 229 | } 230 | const foundRequests = pendingRequests.filter((pending) => { 231 | if (!areEqual(pending.request, concreteRequest.params)) { 232 | return false; 233 | } 234 | if (operationDescriptor) { 235 | // If we handling `any` we also need to check variables 236 | // and return only pending request with equal variables 237 | return areEqual(operationDescriptor.request.variables, pending.variables); 238 | } else { 239 | // In the case we received `any` as input we will return 240 | // all pending request, even if they have different variables 241 | return true; 242 | } 243 | }); 244 | invariant(foundRequests.length, 'MockEnvironment: Cannot respond to request, it has not been requested yet.'); 245 | foundRequests.forEach((foundRequest) => { 246 | invariant( 247 | foundRequest.sink, 248 | 'MockEnvironment: Cannot respond to `%s`, it has not been requested yet.', 249 | concreteRequest.params.name, 250 | ); 251 | }); 252 | return foundRequests; 253 | } 254 | 255 | function ensureValidPayload(payload: any) { 256 | invariant( 257 | typeof payload === 'object' && payload !== null && payload.hasOwnProperty('data'), 258 | 'MockEnvironment(): Expected payload to be an object with a `data` key.', 259 | ); 260 | return payload; 261 | } 262 | 263 | const cachePayload = (request: any | any, variables: any, payload: any): void => { 264 | const { id, text } = getConcreteRequest(request).params; 265 | const cacheID = id ?? text; 266 | invariant(cacheID != null, 'CacheID should not be null'); 267 | cache.set(cacheID, variables, payload); 268 | }; 269 | 270 | const clearCache = (): void => { 271 | cache.clear(); 272 | }; 273 | 274 | // Helper to determine if a given query/variables pair is pending 275 | const isLoading = (request: any | any, variables: any, cacheConfig?: any): boolean => { 276 | return pendingRequests.some( 277 | (pending) => 278 | areEqual(pending.request, getConcreteRequest(request).params) && 279 | areEqual(pending.variables, variables) && 280 | areEqual(pending.cacheConfig, cacheConfig ?? {}), 281 | ); 282 | }; 283 | 284 | // Helpers to reject or resolve the payload for an individual request. 285 | const reject = (request: any | any, error: Error | string): void => { 286 | const rejectError = typeof error === 'string' ? new Error(error) : error; 287 | getRequests(request).forEach((foundRequest) => { 288 | const { sink } = foundRequest; 289 | invariant(sink !== null, 'Sink should be defined.'); 290 | sink.error(rejectError); 291 | }); 292 | }; 293 | 294 | const nextValue = (request: any | any, payload: any): void => { 295 | getRequests(request).forEach((foundRequest) => { 296 | const { sink } = foundRequest; 297 | invariant(sink !== null, 'Sink should be defined.'); 298 | sink.next(ensureValidPayload(payload)); 299 | }); 300 | }; 301 | 302 | const complete = (request: any | any): void => { 303 | getRequests(request).forEach((foundRequest) => { 304 | const { sink } = foundRequest; 305 | invariant(sink !== null, 'Sink should be defined.'); 306 | sink.complete(); 307 | }); 308 | }; 309 | 310 | const resolve = (request: any | any, payload: any): void => { 311 | getRequests(request).forEach((foundRequest) => { 312 | const { sink } = foundRequest; 313 | invariant(sink !== null, 'Sink should be defined.'); 314 | sink.next(ensureValidPayload(payload)); 315 | sink.complete(); 316 | }); 317 | }; 318 | 319 | const getMostRecentOperation = (): any => { 320 | const mostRecentOperation = pendingOperations[pendingOperations.length - 1]; 321 | invariant(mostRecentOperation != null, 'RelayModernMockEnvironment: There are no pending operations in the list'); 322 | return mostRecentOperation; 323 | }; 324 | 325 | const findOperation = (findFn: (operation: any) => boolean): any => { 326 | const pendingOperation = pendingOperations.find(findFn); 327 | invariant(pendingOperation != null, 'RelayModernMockEnvironment: Operation was not found in the list of pending operations'); 328 | return pendingOperation; 329 | }; 330 | 331 | // @ts-ignore 332 | const environment: RelayMockEnvironment = new Environment({ 333 | configName: 'RelayModernMockEnvironment', 334 | network: Network.create(execute, execute), 335 | store, 336 | ...config, 337 | }); 338 | 339 | const createExecuteProxy = (env: any, fn: any) => { 340 | return (...argumentsList) => { 341 | const [{ operation }] = argumentsList; 342 | pendingOperations = pendingOperations.concat([operation]); 343 | return fn.apply(env, argumentsList); 344 | }; 345 | }; 346 | 347 | // @ts-ignore 348 | environment.execute = createExecuteProxy(environment, environment.execute); 349 | // @ts-ignore 350 | environment.executeMutation = createExecuteProxy( 351 | environment, 352 | // @ts-ignore 353 | environment.executeMutation, 354 | ); 355 | 356 | if (global?.process?.env?.NODE_ENV === 'test') { 357 | // Mock all the functions with their original behavior 358 | mockDisposableMethod(environment, 'applyUpdate'); 359 | mockInstanceMethod(environment, 'commitPayload'); 360 | mockInstanceMethod(environment, 'getStore'); 361 | mockInstanceMethod(environment, 'lookup'); 362 | mockInstanceMethod(environment, 'check'); 363 | mockDisposableMethod(environment, 'subscribe'); 364 | mockDisposableMethod(environment, 'retain'); 365 | // @ts-ignore 366 | mockObservableMethod(environment, 'execute'); 367 | // @ts-ignore 368 | mockObservableMethod(environment, 'executeMutation'); 369 | 370 | mockInstanceMethod(store, 'getSource'); 371 | mockInstanceMethod(store, 'lookup'); 372 | mockInstanceMethod(store, 'notify'); 373 | mockInstanceMethod(store, 'publish'); 374 | mockDisposableMethod(store, 'retain'); 375 | mockDisposableMethod(store, 'subscribe'); 376 | } 377 | 378 | const mock: MockFunctions = { 379 | cachePayload, 380 | clearCache, 381 | isLoading, 382 | reject, 383 | resolve, 384 | nextValue, 385 | complete, 386 | getMostRecentOperation, 387 | resolveMostRecentOperation(payload): void { 388 | const operation = getMostRecentOperation(); 389 | const data = typeof payload === 'function' ? payload(operation) : payload; 390 | return resolve(operation, data); 391 | }, 392 | rejectMostRecentOperation(error): void { 393 | const operation = getMostRecentOperation(); 394 | const rejector = typeof error === 'function' ? error(operation) : error; 395 | return reject(operation, rejector); 396 | }, 397 | findOperation, 398 | getAllOperations() { 399 | return pendingOperations; 400 | }, 401 | queueOperationResolver, 402 | }; 403 | 404 | // @ts-ignore 405 | environment.mock = mock; 406 | 407 | // @ts-ignore 408 | environment.mockClear = () => { 409 | (environment as any).applyUpdate.mockClear(); 410 | (environment as any).commitPayload.mockClear(); 411 | (environment as any).getStore.mockClear(); 412 | (environment as any).lookup.mockClear(); 413 | (environment as any).check.mockClear(); 414 | (environment as any).subscribe.mockClear(); 415 | (environment as any).retain.mockClear(); 416 | (environment as any).execute.mockClear(); 417 | (environment as any).executeMutation.mockClear(); 418 | 419 | store.getSource.mockClear(); 420 | store.lookup.mockClear(); 421 | store.notify.mockClear(); 422 | store.publish.mockClear(); 423 | store.retain.mockClear(); 424 | store.subscribe.mockClear(); 425 | 426 | cache.clear(); 427 | pendingOperations = []; 428 | pendingRequests = []; 429 | }; 430 | 431 | return environment; 432 | } 433 | -------------------------------------------------------------------------------- /__tests__/RelayOfflineHydrate-test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | /* eslint-disable prefer-const */ 3 | /* eslint-disable max-len */ 4 | /* eslint-disable @typescript-eslint/no-var-requires */ 5 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 6 | 7 | jest.mock('scheduler', () => jest.requireActual('scheduler/unstable_mock')); 8 | 9 | import * as React from 'react'; 10 | //import * as Scheduler from 'scheduler'; 11 | const Scheduler = require('scheduler'); 12 | 13 | //import { ReactRelayContext } from "react-relay"; 14 | 15 | import { useQuery, Store, RecordSource, useRestore } from '../src'; 16 | import { RelayEnvironmentProvider } from 'relay-hooks'; 17 | 18 | import * as ReactTestRenderer from 'react-test-renderer'; 19 | 20 | //import readContext from "react-relay/lib/readContext"; 21 | 22 | import { createOperationDescriptor, graphql } from 'relay-runtime'; 23 | 24 | import { simpleClone } from 'relay-test-utils-internal'; 25 | import { createMockEnvironment, createPersistedStore, createPersistedRecordSource } from '../src-test'; 26 | /* 27 | function expectToBeRendered(renderFn, readyState) { 28 | // Ensure useEffect is called before other timers 29 | ReactTestRenderer.act(() => { 30 | jest.runAllImmediates(); 31 | }); 32 | expect(renderFn).toBeCalledTimes(1); 33 | expect(renderFn.mock.calls[0][0]).toEqual(readyState); 34 | renderFn.mockClear(); 35 | }*/ 36 | 37 | const QueryRendererHook = (props: any) => { 38 | const { render, query, variables, cacheConfig } = props; 39 | const queryData = useQuery(query, variables, { 40 | networkCacheConfig: cacheConfig, 41 | //fetchKey 42 | }); 43 | 44 | return {render(queryData)}; 45 | }; 46 | 47 | const ReactRelayQueryRenderer = (props: any) => ( 48 | 49 | 50 | 51 | ); 52 | 53 | const NOT_REHYDRATED = 'NOT_REHYDRATED'; 54 | 55 | const QueryRendererUseRestore = (props: any): any => { 56 | const rehydrated = useRestore(props.environment); 57 | if (!rehydrated) { 58 | return NOT_REHYDRATED; 59 | } 60 | 61 | return ( 62 | 63 | 64 | 65 | ); 66 | }; 67 | 68 | /*function sleep(ms) { 69 | return new Promise((resolve) => setTimeout(resolve, ms)); 70 | }*/ 71 | 72 | function expectToBeRendered( 73 | renderSpy, 74 | readyState: { 75 | data: any; 76 | error: Error | null; 77 | }, 78 | ): void { 79 | // Ensure useEffect is called before other timers 80 | 81 | expect(renderSpy).toBeCalledTimes(2); 82 | 83 | expect(renderSpy.mock.calls[0][0].isLoading).toEqual(true); 84 | expect(renderSpy.mock.calls[0][0].error).toEqual(null); 85 | 86 | const actualResult = renderSpy.mock.calls[1][0]; 87 | expect(renderSpy.mock.calls[1][0].isLoading).toEqual(false); 88 | 89 | expect(actualResult.data).toEqual(readyState.data); 90 | expect(actualResult.error).toEqual(readyState.error); 91 | expect(actualResult.retry).toEqual(expect.any(Function)); 92 | } 93 | 94 | function expectToBeLoading(renderSpy): void { 95 | // Ensure useEffect is called before other timers 96 | 97 | expect(renderSpy).toBeCalledTimes(1); 98 | 99 | const actualResult = renderSpy.mock.calls[0][0]; 100 | expect(actualResult.isLoading).toEqual(true); 101 | expect(actualResult.error).toEqual(null); 102 | expect(actualResult.data).toEqual(null); 103 | } 104 | 105 | function expectToBeNotLoading(renderSpy): void { 106 | // Ensure useEffect is called before other timers 107 | 108 | expect(renderSpy).toBeCalledTimes(1); 109 | 110 | const actualResult = renderSpy.mock.calls[0][0]; 111 | expect(actualResult.isLoading).toEqual(false); 112 | expect(actualResult.error).toEqual(null); 113 | expect(actualResult.data).toEqual(null); 114 | } 115 | 116 | function expectToBeError(renderSpy, error): void { 117 | // Ensure useEffect is called before other timers 118 | 119 | expect(renderSpy).toBeCalledTimes(1); 120 | 121 | const actualResult = renderSpy.mock.calls[0][0]; 122 | expect(actualResult.isLoading).toEqual(false); 123 | expect(actualResult.error).toEqual(error); 124 | expect(actualResult.data).toEqual(null); 125 | } 126 | 127 | function expectHydrate(environment, rehydrated, online): void { 128 | expect(environment.isOnline()).toEqual(online); 129 | expect(environment.isRehydrated()).toEqual(rehydrated); 130 | } 131 | 132 | function expectToBeRenderedFirst( 133 | renderSpy, 134 | readyState: { 135 | data: any; 136 | error: Error | null; 137 | isLoading?: boolean; 138 | }, 139 | ): void { 140 | // Ensure useEffect is called before other timers 141 | 142 | expect(renderSpy).toBeCalledTimes(1); 143 | const { isLoading = false } = readyState; 144 | 145 | const actualResult = renderSpy.mock.calls[0][0]; 146 | expect(actualResult.isLoading).toEqual(isLoading); 147 | 148 | expect(actualResult.data).toEqual(readyState.data); 149 | expect(actualResult.error).toEqual(readyState.error); 150 | expect(actualResult.retry).toEqual(expect.any(Function)); 151 | } 152 | 153 | describe('ReactRelayQueryRenderer', () => { 154 | let TestQuery; 155 | 156 | let cacheConfig; 157 | let environment; 158 | let render; 159 | let store; 160 | let data; 161 | let initialData; 162 | let owner; 163 | let ownerTTL; 164 | let onlineGetter; 165 | const variables = { id: '4' }; 166 | const restoredState = { 167 | '4': { 168 | __id: '4', 169 | id: '4', 170 | __typename: 'User', 171 | name: 'ZUCK', 172 | }, 173 | 'client:root': { 174 | __id: 'client:root', 175 | __typename: '__Root', 176 | 'node(id:"4")': { __ref: '4' }, 177 | }, 178 | }; 179 | const dataInitialState = (owner, isLoading = false, ttl = false) => { 180 | return { 181 | error: null, 182 | data: { 183 | node: { 184 | id: '4', 185 | name: 'Zuck', 186 | __isWithinUnmatchedTypeRefinement: false, 187 | 188 | __fragments: { 189 | RelayOfflineHydrateTestFragment: {}, 190 | }, 191 | 192 | __fragmentOwner: ttl ? ownerTTL.request : owner.request, 193 | __id: '4', 194 | }, 195 | }, 196 | isLoading, 197 | }; 198 | }; 199 | 200 | const dataRestoredState = (owner, isLoading = false, ttl = false) => { 201 | return { 202 | error: null, 203 | data: { 204 | node: { 205 | id: '4', 206 | name: 'ZUCK', 207 | __isWithinUnmatchedTypeRefinement: false, 208 | 209 | __fragments: { 210 | RelayOfflineHydrateTestFragment: {}, 211 | }, 212 | 213 | __fragmentOwner: ttl ? ownerTTL.request : owner.request, 214 | __id: '4', 215 | }, 216 | }, 217 | isLoading, 218 | }; 219 | }; 220 | 221 | const loadingStateRehydrated = { 222 | error: null, 223 | props: null, 224 | rehydrated: true, 225 | online: true, 226 | retry: expect.any(Function), 227 | }; 228 | 229 | const loadingStateRehydratedOffline = { 230 | error: null, 231 | props: null, 232 | rehydrated: true, 233 | online: false, 234 | retry: expect.any(Function), 235 | }; 236 | 237 | const loadingStateNotRehydrated = { 238 | error: null, 239 | props: null, 240 | rehydrated: false, 241 | online: false, 242 | retry: expect.any(Function), 243 | }; 244 | 245 | const frag = graphql` 246 | fragment RelayOfflineHydrateTestFragment on User { 247 | name 248 | }` 249 | 250 | TestQuery = graphql` 251 | query RelayOfflineHydrateTestQuery($id: ID = "") { 252 | node(id: $id) { 253 | id 254 | name 255 | ...RelayOfflineHydrateTestFragment 256 | } 257 | } 258 | `; 259 | 260 | owner = createOperationDescriptor(TestQuery, variables); 261 | 262 | beforeEach(async () => { 263 | Scheduler.unstable_clearYields(); 264 | jest.resetModules(); 265 | expect.extend({ 266 | toBeRendered(readyState) { 267 | const calls = render.mock.calls; 268 | expect(calls.length).toBe(1); 269 | expect(calls[0][0]).toEqual(readyState); 270 | return { message: '', pass: true }; 271 | }, 272 | }); 273 | data = { 274 | '4': { 275 | __id: '4', 276 | id: '4', 277 | __typename: 'User', 278 | name: 'Zuck', 279 | }, 280 | 'client:root': { 281 | __id: 'client:root', 282 | __typename: '__Root', 283 | 'node(id:"4")': { __ref: '4' }, 284 | }, 285 | }; 286 | initialData = simpleClone(data); 287 | render = jest.fn(() =>
); 288 | }); 289 | 290 | afterEach(async () => { 291 | // wait for GC to run in setImmediate 292 | await Promise.resolve(); 293 | }); 294 | 295 | describe('rehydrate the environment when online', () => { 296 | describe('no initial state', () => { 297 | beforeEach(async () => { 298 | store = new Store( 299 | new RecordSource({ storage: createPersistedRecordSource() }), 300 | { 301 | storage: createPersistedStore(), 302 | }, 303 | { queryCacheExpirationTime: null }, 304 | ); 305 | environment = createMockEnvironment({ store }); 306 | }); 307 | 308 | it('with useRestore', () => { 309 | const instance = ReactTestRenderer.create( 310 | , 317 | ); 318 | expect(instance.toJSON()).toEqual(NOT_REHYDRATED); 319 | 320 | render.mockClear(); 321 | jest.runAllTimers(); 322 | expectHydrate(environment, true, true); 323 | }); 324 | 325 | it('without useRestore', () => { 326 | ReactTestRenderer.create( 327 | , 334 | ); 335 | expectHydrate(environment, false, false); 336 | 337 | render.mockClear(); 338 | jest.runAllTimers(); 339 | expectHydrate(environment, true, true); 340 | }); 341 | }); 342 | describe('initial state', () => { 343 | beforeEach(async () => { 344 | store = new Store( 345 | new RecordSource({ storage: createPersistedRecordSource(), initialState: { ...data } }), 346 | { 347 | storage: createPersistedStore(), 348 | }, 349 | { queryCacheExpirationTime: null }, 350 | ); 351 | environment = createMockEnvironment({ store }); 352 | }); 353 | 354 | it('with useRestore', () => { 355 | const instance = ReactTestRenderer.create( 356 | , 363 | ); 364 | expect(instance.toJSON()).toEqual(NOT_REHYDRATED); 365 | 366 | render.mockClear(); 367 | jest.runAllTimers(); 368 | 369 | expectHydrate(environment, true, true); 370 | expectToBeRenderedFirst(render, dataInitialState(owner)); 371 | }); 372 | 373 | it('without useRestore', () => { 374 | ReactTestRenderer.create( 375 | , 382 | ); 383 | expectHydrate(environment, false, false); 384 | expectToBeRenderedFirst(render, dataInitialState(owner)); 385 | 386 | render.mockClear(); 387 | jest.runAllTimers(); 388 | const calls = render.mock.calls; 389 | expect(calls.length).toBe(0); 390 | }); 391 | }); 392 | 393 | describe('initial state is different from restored state', () => { 394 | beforeEach(async () => { 395 | store = new Store( 396 | new RecordSource({ 397 | storage: createPersistedRecordSource(restoredState), 398 | initialState: { ...data }, 399 | }), 400 | { storage: createPersistedStore() }, 401 | { queryCacheExpirationTime: null }, 402 | ); 403 | environment = createMockEnvironment({ store }); 404 | }); 405 | 406 | it('with useRestore', () => { 407 | const instance = ReactTestRenderer.create( 408 | , 415 | ); 416 | expect(instance.toJSON()).toEqual(NOT_REHYDRATED); 417 | render.mockClear(); 418 | jest.runAllTimers(); 419 | 420 | expectHydrate(environment, true, true); 421 | expectToBeRenderedFirst(render, dataRestoredState(owner)); 422 | }); 423 | 424 | it('without useRestore', () => { 425 | ReactTestRenderer.create( 426 | , 433 | ); 434 | 435 | expectHydrate(environment, false, false); 436 | expectToBeRenderedFirst(render, dataInitialState(owner)); 437 | 438 | render.mockClear(); 439 | jest.runAllTimers(); 440 | 441 | expectHydrate(environment, true, true); 442 | expectToBeRenderedFirst(render, dataRestoredState(owner)); 443 | }); 444 | }); 445 | 446 | describe(' no initial state, with restored state', () => { 447 | beforeEach(async () => { 448 | store = new Store( 449 | new RecordSource({ storage: createPersistedRecordSource(restoredState) }), 450 | { 451 | storage: createPersistedStore(), 452 | }, 453 | { queryCacheExpirationTime: null }, 454 | ); 455 | environment = createMockEnvironment({ store }); 456 | }); 457 | 458 | it('with useRestore', () => { 459 | const instance = ReactTestRenderer.create( 460 | , 467 | ); 468 | expect(instance.toJSON()).toEqual(NOT_REHYDRATED); 469 | 470 | render.mockClear(); 471 | jest.runAllTimers(); 472 | 473 | expectHydrate(environment, true, true); 474 | expectToBeRenderedFirst(render, dataRestoredState(owner)); 475 | }); 476 | 477 | it('without useRestore', () => { 478 | ReactTestRenderer.create( 479 | , 486 | ); 487 | expectHydrate(environment, false, false); 488 | expectToBeNotLoading(render); 489 | 490 | render.mockClear(); 491 | jest.runAllTimers(); 492 | expectHydrate(environment, true, true); 493 | expectToBeRenderedFirst(render, dataRestoredState(owner)); 494 | }); 495 | }); 496 | }); 497 | 498 | describe('rehydrate the environment when offline', () => { 499 | describe('no initial state', () => { 500 | beforeEach(async () => { 501 | store = new Store( 502 | new RecordSource({ storage: createPersistedRecordSource() }), 503 | { 504 | storage: createPersistedStore(), 505 | }, 506 | { queryCacheExpirationTime: null }, 507 | ); 508 | environment = createMockEnvironment({ store }); 509 | 510 | onlineGetter = jest.spyOn(window.navigator, 'onLine', 'get'); 511 | onlineGetter.mockReturnValue(false); 512 | }); 513 | 514 | it('with useRestore', () => { 515 | const instance = ReactTestRenderer.create( 516 | , 523 | ); 524 | expect(instance.toJSON()).toEqual(NOT_REHYDRATED); 525 | 526 | render.mockClear(); 527 | jest.runAllTimers(); 528 | 529 | expectToBeNotLoading(render); 530 | expectHydrate(environment, true, false); 531 | }); 532 | 533 | it('without useRestore', () => { 534 | ReactTestRenderer.create( 535 | , 542 | ); 543 | 544 | expectToBeNotLoading(render); 545 | expectHydrate(environment, false, false); 546 | 547 | render.mockClear(); 548 | jest.runAllTimers(); 549 | 550 | expectToBeNotLoading(render); 551 | expectHydrate(environment, true, false); 552 | }); 553 | }); 554 | describe('initial state', () => { 555 | beforeEach(async () => { 556 | store = new Store( 557 | new RecordSource({ storage: createPersistedRecordSource(), initialState: { ...data } }), 558 | { 559 | storage: createPersistedStore(), 560 | }, 561 | { queryCacheExpirationTime: null }, 562 | ); 563 | environment = createMockEnvironment({ store }); 564 | }); 565 | 566 | it('with useRestore', () => { 567 | const instance = ReactTestRenderer.create( 568 | , 575 | ); 576 | expect(instance.toJSON()).toEqual(NOT_REHYDRATED); 577 | 578 | render.mockClear(); 579 | jest.runAllTimers(); 580 | 581 | expectHydrate(environment, true, false); 582 | expectToBeRenderedFirst(render, dataInitialState(owner)); 583 | //expect(propsInitialState(owner, true, false)).toBeRendered(); 584 | }); 585 | 586 | it('without useRestore', () => { 587 | ReactTestRenderer.create( 588 | , 595 | ); 596 | expectHydrate(environment, false, false); 597 | expectToBeRenderedFirst(render, dataInitialState(owner)); 598 | 599 | render.mockClear(); 600 | jest.runAllTimers(); 601 | const calls = render.mock.calls; 602 | expect(calls.length).toBe(0); 603 | }); 604 | }); 605 | 606 | describe('initial state is different from restored state', () => { 607 | beforeEach(async () => { 608 | store = new Store( 609 | new RecordSource({ storage: createPersistedRecordSource(restoredState), initialState: { ...data } }), 610 | { 611 | storage: createPersistedStore(), 612 | }, 613 | { queryCacheExpirationTime: null }, 614 | ); 615 | environment = createMockEnvironment({ store }); 616 | }); 617 | 618 | it('with useRestore', () => { 619 | const instance = ReactTestRenderer.create( 620 | , 627 | ); 628 | expect(instance.toJSON()).toEqual(NOT_REHYDRATED); 629 | 630 | render.mockClear(); 631 | jest.runAllTimers(); 632 | 633 | expectHydrate(environment, true, false); 634 | expectToBeRenderedFirst(render, dataRestoredState(owner)); 635 | }); 636 | 637 | it('without useRestore', () => { 638 | ReactTestRenderer.create( 639 | , 646 | ); 647 | 648 | expectHydrate(environment, false, false); 649 | expectToBeRenderedFirst(render, dataInitialState(owner)); 650 | 651 | render.mockClear(); 652 | jest.runAllTimers(); 653 | 654 | expectHydrate(environment, true, false); 655 | expectToBeRenderedFirst(render, dataRestoredState(owner)); 656 | }); 657 | }); 658 | 659 | describe(' no initial state, with restored state', () => { 660 | beforeEach(async () => { 661 | store = new Store( 662 | new RecordSource({ storage: createPersistedRecordSource(restoredState) }), 663 | { 664 | storage: createPersistedStore(), 665 | }, 666 | { queryCacheExpirationTime: null }, 667 | ); 668 | environment = createMockEnvironment({ store }); 669 | }); 670 | 671 | it('with useRestore', () => { 672 | const instance = ReactTestRenderer.create( 673 | , 680 | ); 681 | expect(instance.toJSON()).toEqual(NOT_REHYDRATED); 682 | 683 | render.mockClear(); 684 | jest.runAllTimers(); 685 | 686 | expectHydrate(environment, true, false); 687 | expectToBeRenderedFirst(render, dataRestoredState(owner)); 688 | }); 689 | 690 | /* 691 | if the application is offline, the policy is still store-only and since it has not been modified, 692 | the execute is not re-executed and the application is still in a loading state. (We want to avoid it, we want recover restored state) 693 | */ 694 | it('without useRestore', () => { 695 | ReactTestRenderer.create( 696 | , 703 | ); 704 | 705 | expectHydrate(environment, false, false); 706 | expectToBeNotLoading(render); 707 | 708 | render.mockClear(); 709 | jest.runAllTimers(); 710 | 711 | expectHydrate(environment, true, false); 712 | expectToBeNotLoading(render); 713 | }); 714 | }); 715 | }); 716 | }); 717 | -------------------------------------------------------------------------------- /__tests__/RelayOfflineTTL-test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 3 | /* eslint-disable @typescript-eslint/no-unused-vars */ 4 | /* eslint-disable prefer-const */ 5 | 6 | jest.mock('scheduler', () => jest.requireActual('scheduler/unstable_mock')); 7 | 8 | import * as React from 'react'; 9 | const Scheduler = require('scheduler'); 10 | import { useQuery, Store, RecordSource, useRestore } from '../src'; 11 | import { RelayEnvironmentProvider } from 'relay-hooks'; 12 | import * as ReactTestRenderer from 'react-test-renderer'; 13 | import { createOperationDescriptor, graphql } from 'relay-runtime'; 14 | 15 | import { simpleClone } from 'relay-test-utils-internal'; 16 | 17 | import { createMockEnvironment, createPersistedStore, createPersistedRecordSource } from '../src-test'; 18 | 19 | const QueryRendererHook = (props: any) => { 20 | const { render, query, variables, cacheConfig, ttl } = props; 21 | let networkCacheConfig = cacheConfig; 22 | if (ttl) { 23 | if (!networkCacheConfig) { 24 | networkCacheConfig = { ttl }; 25 | } else { 26 | networkCacheConfig.ttl = ttl; 27 | } 28 | } 29 | const queryData = useQuery(query, variables, { 30 | networkCacheConfig, 31 | }); 32 | 33 | return {render(queryData)}; 34 | }; 35 | 36 | function expectToBeRendered( 37 | renderSpy, 38 | readyState: { 39 | data: any; 40 | error: Error | null; 41 | }, 42 | ): void { 43 | // Ensure useEffect is called before other timers 44 | 45 | ReactTestRenderer.act(() => { 46 | jest.runAllImmediates(); 47 | }); 48 | expect(renderSpy).toBeCalledTimes(2); 49 | 50 | expect(renderSpy.mock.calls[0][0].isLoading).toEqual(true); 51 | expect(renderSpy.mock.calls[0][0].error).toEqual(null); 52 | 53 | const actualResult = renderSpy.mock.calls[1][0]; 54 | expect(renderSpy.mock.calls[1][0].isLoading).toEqual(false); 55 | 56 | expect(actualResult.data).toEqual(readyState.data); 57 | expect(actualResult.error).toEqual(readyState.error); 58 | expect(actualResult.retry).toEqual(expect.any(Function)); 59 | } 60 | 61 | function expectToBeLoading(renderSpy): void { 62 | // Ensure useEffect is called before other timers 63 | 64 | ReactTestRenderer.act(() => { 65 | jest.runAllImmediates(); 66 | }); 67 | expect(renderSpy).toBeCalledTimes(1); 68 | 69 | const actualResult = renderSpy.mock.calls[0][0]; 70 | expect(actualResult.isLoading).toEqual(true); 71 | expect(actualResult.error).toEqual(null); 72 | expect(actualResult.data).toEqual(null); 73 | } 74 | 75 | function expectToBeError(renderSpy, error): void { 76 | // Ensure useEffect is called before other timers 77 | 78 | ReactTestRenderer.act(() => { 79 | jest.runAllImmediates(); 80 | }); 81 | expect(renderSpy).toBeCalledTimes(1); 82 | 83 | const actualResult = renderSpy.mock.calls[0][0]; 84 | expect(actualResult.isLoading).toEqual(false); 85 | expect(actualResult.error).toEqual(error); 86 | expect(actualResult.data).toEqual(null); 87 | } 88 | 89 | function expectToBeRenderedFirst( 90 | renderSpy, 91 | readyState: { 92 | data: any; 93 | error: Error | null; 94 | isLoading?: boolean; 95 | }, 96 | ): void { 97 | // Ensure useEffect is called before other timers 98 | 99 | ReactTestRenderer.act(() => { 100 | jest.runAllImmediates(); 101 | }); 102 | expect(renderSpy).toBeCalledTimes(1); 103 | const { isLoading = false } = readyState; 104 | 105 | const actualResult = renderSpy.mock.calls[0][0]; 106 | expect(actualResult.isLoading).toEqual(isLoading); 107 | 108 | expect(actualResult.data).toEqual(readyState.data); 109 | expect(actualResult.error).toEqual(readyState.error); 110 | expect(actualResult.retry).toEqual(expect.any(Function)); 111 | } 112 | 113 | const NOT_REHYDRATED = 'NOT_REHYDRATED'; 114 | 115 | const QueryRendererUseRestore = (props: any): any => { 116 | const rehydrated = useRestore(props.environment); 117 | if (!rehydrated) { 118 | return NOT_REHYDRATED; 119 | } 120 | 121 | return ( 122 | 123 | 124 | 125 | ); 126 | }; 127 | 128 | function unmount(unmount, ttl) { 129 | const realDate = Date.now; 130 | const date = Date.now(); 131 | Date.now = jest.fn(() => date + ttl); 132 | unmount(); 133 | Date.now = realDate; 134 | jest.runAllTimers(); 135 | } 136 | 137 | describe('ReactRelayQueryRenderer', () => { 138 | let TestQuery; 139 | 140 | let cacheConfig; 141 | let environment; 142 | let render; 143 | let store; 144 | let data; 145 | let initialData; 146 | let owner; 147 | let ownerTTL; 148 | const variables = { id: '4' }; 149 | const dataInitialState = (owner, isLoading, ttl = false) => { 150 | return { 151 | error: null, 152 | data: { 153 | node: { 154 | id: '4', 155 | name: 'Zuck', 156 | __isWithinUnmatchedTypeRefinement: false, 157 | 158 | __fragments: { 159 | RelayOfflineTTLTestFragment: {}, 160 | }, 161 | 162 | __fragmentOwner: ttl ? ownerTTL.request : owner.request, 163 | __id: '4', 164 | }, 165 | }, 166 | isLoading, 167 | }; 168 | }; 169 | 170 | const frag = graphql` 171 | fragment RelayOfflineTTLTestFragment on User { 172 | name 173 | } 174 | `; 175 | 176 | TestQuery = graphql` 177 | query RelayOfflineTTLTestQuery($id: ID = "") { 178 | node(id: $id) { 179 | id 180 | name 181 | ...RelayOfflineTTLTestFragment 182 | } 183 | } 184 | `; 185 | 186 | owner = createOperationDescriptor(TestQuery, variables); 187 | ownerTTL = createOperationDescriptor(TestQuery, variables, { ttl: 500 } as any); 188 | 189 | beforeEach(() => { 190 | Scheduler.unstable_clearYields(); 191 | jest.resetModules(); 192 | expect.extend({ 193 | toBeRendered(readyState) { 194 | const calls = render.mock.calls; 195 | expect(calls.length).toBe(1); 196 | expect(calls[0][0]).toEqual(readyState); 197 | return { message: '', pass: true }; 198 | }, 199 | }); 200 | data = { 201 | '4': { 202 | __id: '4', 203 | id: '4', 204 | __typename: 'User', 205 | name: 'Zuck', 206 | }, 207 | 'client:root': { 208 | __id: 'client:root', 209 | __typename: '__Root', 210 | 'node(id:"4")': { __ref: '4' }, 211 | }, 212 | }; 213 | initialData = simpleClone(data); 214 | render = jest.fn(() =>
); 215 | }); 216 | 217 | afterEach(async () => { 218 | // wait for GC to run in setImmediate 219 | await Promise.resolve(); 220 | }); 221 | 222 | describe('Time To Live', () => { 223 | it('without TTL', () => { 224 | store = new Store( 225 | new RecordSource({ storage: createPersistedRecordSource(), initialState: { ...data } }), 226 | { 227 | storage: createPersistedStore(), 228 | }, 229 | { queryCacheExpirationTime: null }, 230 | ); 231 | environment = createMockEnvironment({ store }); 232 | environment.hydrate(); 233 | jest.runAllTimers(); 234 | const instanceA = ReactTestRenderer.create( 235 | , 242 | ); 243 | 244 | render.mockClear(); 245 | ReactTestRenderer.act(() => { 246 | // added for execute useEffect retain 247 | jest.runAllImmediates(); 248 | }); 249 | expect(environment.retain).toBeCalled(); 250 | expect(environment.retain.mock.calls.length).toBe(1); 251 | const dispose = environment.retain.mock.dispose; 252 | expect(dispose).not.toBeCalled(); 253 | unmount(instanceA.unmount, 1); 254 | expect(dispose).toBeCalled(); 255 | 256 | render.mockClear(); 257 | environment.mockClear(); 258 | ReactTestRenderer.create( 259 | , 266 | ); 267 | expectToBeLoading(render); 268 | }); 269 | 270 | it('with defaultTTL', () => { 271 | store = new Store( 272 | new RecordSource({ storage: createPersistedRecordSource(), initialState: { ...data } }), 273 | { 274 | storage: createPersistedStore(), 275 | }, 276 | { queryCacheExpirationTime: 100 }, 277 | ); 278 | environment = createMockEnvironment({ store }); 279 | environment.hydrate(); 280 | jest.runAllTimers() 281 | const instanceA = ReactTestRenderer.create( 282 | , 289 | ); 290 | 291 | render.mockClear(); 292 | ReactTestRenderer.act(() => { 293 | // added for execute useEffect retain 294 | jest.runAllImmediates(); 295 | }); 296 | expect(environment.retain).toBeCalled(); 297 | expect(environment.retain.mock.calls.length).toBe(1); 298 | let dispose = environment.retain.mock.dispose; 299 | expect(dispose).not.toBeCalled(); 300 | unmount(instanceA.unmount, 1); 301 | expect(dispose).toBeCalled(); 302 | 303 | render.mockClear(); 304 | environment.mockClear(); 305 | const instanceB = ReactTestRenderer.create( 306 | , 313 | ); 314 | expectToBeRenderedFirst(render, dataInitialState(owner, false)); 315 | 316 | render.mockClear(); 317 | ReactTestRenderer.act(() => { 318 | // added for execute useEffect retain 319 | jest.runAllImmediates(); 320 | }); 321 | expect(environment.retain).toBeCalled(); 322 | expect(environment.retain.mock.calls.length).toBe(1); 323 | dispose = environment.retain.mock.dispose; 324 | expect(dispose).not.toBeCalled(); 325 | unmount(instanceB.unmount, 200); 326 | expect(dispose).toBeCalled(); 327 | //runAllTimersDelay(200); 328 | 329 | render.mockClear(); 330 | environment.mockClear(); 331 | ReactTestRenderer.create( 332 | , 339 | ); 340 | expectToBeLoading(render); 341 | }); 342 | 343 | it('with custom TTL', () => { 344 | store = new Store( 345 | new RecordSource({ storage: createPersistedRecordSource(), initialState: { ...data } }), 346 | { 347 | storage: createPersistedStore(), 348 | }, 349 | { queryCacheExpirationTime: 100 }, 350 | ); 351 | environment = createMockEnvironment({ store }); 352 | environment.hydrate(); 353 | jest.runAllTimers(); 354 | const instanceA = ReactTestRenderer.create( 355 | , 363 | ); 364 | 365 | render.mockClear(); 366 | ReactTestRenderer.act(() => { 367 | // added for execute useEffect retain 368 | jest.runAllImmediates(); 369 | }); 370 | expect(environment.retain).toBeCalled(); 371 | expect(environment.retain.mock.calls.length).toBe(1); 372 | let dispose = environment.retain.mock.dispose; 373 | expect(dispose).not.toBeCalled(); 374 | instanceA.unmount(); 375 | unmount(instanceA.unmount, 1); 376 | expect(dispose).toBeCalled(); 377 | 378 | render.mockClear(); 379 | environment.mockClear(); 380 | const instanceB = ReactTestRenderer.create( 381 | , 389 | ); 390 | 391 | expectToBeRenderedFirst(render, dataInitialState(owner, false, true)); 392 | 393 | render.mockClear(); 394 | ReactTestRenderer.act(() => { 395 | // added for execute useEffect retain 396 | jest.runAllImmediates(); 397 | }); 398 | expect(environment.retain).toBeCalled(); 399 | expect(environment.retain.mock.calls.length).toBe(1); 400 | dispose = environment.retain.mock.dispose; 401 | expect(dispose).not.toBeCalled(); 402 | unmount(instanceB.unmount, 200); 403 | expect(dispose).toBeCalled(); 404 | //runAllTimersDelay(200); 405 | 406 | render.mockClear(); 407 | environment.mockClear(); 408 | const instanceC = ReactTestRenderer.create( 409 | , 417 | ); 418 | 419 | expectToBeRenderedFirst(render, dataInitialState(owner, false, true)); 420 | 421 | render.mockClear(); 422 | ReactTestRenderer.act(() => { 423 | // added for execute useEffect retain 424 | jest.runAllImmediates(); 425 | }); 426 | expect(environment.retain).toBeCalled(); 427 | expect(environment.retain.mock.calls.length).toBe(1); 428 | dispose = environment.retain.mock.dispose; 429 | expect(dispose).not.toBeCalled(); 430 | unmount(instanceC.unmount, 500); 431 | expect(dispose).toBeCalled(); 432 | //runAllTimersDelay(500); 433 | 434 | render.mockClear(); 435 | environment.mockClear(); 436 | ReactTestRenderer.create( 437 | , 445 | ); 446 | expectToBeLoading(render); 447 | }); 448 | }); 449 | }); 450 | -------------------------------------------------------------------------------- /__tests__/__generated__/.gitignore: -------------------------------------------------------------------------------- 1 | *.ts -------------------------------------------------------------------------------- /docs/ReactRelayOffline-Introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: react-relay-offline 3 | title: Getting Started 4 | --- 5 | 6 | # [React Relay Offline](https://github.com/morrys/react-relay-offline) 7 | 8 | React Relay Offline is a extension of [Relay](https://facebook.github.io/relay/) for offline capabilities 9 | 10 | ## Installation React Web 11 | 12 | Install react-relay and react-relay-offline using yarn or npm: 13 | 14 | ``` 15 | yarn add react-relay react-relay-offline 16 | ``` 17 | 18 | ## Installation React Native 19 | 20 | Install react-relay and react-relay-offline using yarn or npm: 21 | 22 | ``` 23 | yarn add @react-native-community/netinfo react-relay react-relay-offline 24 | ``` 25 | 26 | You then need to link the native parts of the library for the platforms you are using. The easiest way to link the library is using the CLI tool by running this command from the root of your project: 27 | 28 | `react-native link @react-native-community/netinfo` 29 | 30 | ## Main Additional Features 31 | 32 | - automatic persistence and rehydration of the store (AsyncStorage, localStorage, IndexedDB) 33 | 34 | - configuration of persistence 35 | 36 | - custom storage 37 | 38 | - different key prefix (multi user) 39 | 40 | - serialization: JSON or none 41 | 42 | - fetchPolicy network-only, store-and-network, store-or-network, store-only 43 | 44 | - management and utilities for network detection 45 | 46 | - automatic use of the polity **store-only** when the application is offline 47 | 48 | - optimization in store management and addition of **TTL** to queries in the store 49 | 50 | - offline mutation management 51 | 52 | - backup of mutation changes 53 | 54 | - update and publication of the mutation changes in the store 55 | 56 | - persistence of mutation information performed 57 | 58 | - automatic execution of mutations persisted when the application returns online 59 | 60 | - configurability of the offline mutation execution network 61 | 62 | - onComplete callback of the mutation performed successfully 63 | 64 | - onDiscard callback of the failed mutation 65 | 66 | ## Contributing 67 | 68 | - **Give a star** to the repository and **share it**, you will **help** the **project** and the **people** who will find it useful 69 | 70 | - **Create issues**, your **questions** are a **valuable help** 71 | 72 | - **PRs are welcome**, but it is always **better to open the issue first** so as to **help** me and other people **evaluating it** 73 | 74 | - **Please sponsor me** 75 | 76 | ### Sponsors 77 | 78 | Memorang 79 | 80 | ## react-relay-offline examples 81 | 82 | The [offline-examples](https://github.com/morrys/offline-examples) repository contains example projects on how to use react-relay-offline: 83 | 84 | * `nextjs-ssr-preload`: using the render-as-you-fetch pattern with loadQuery in SSR contexts 85 | * `nextjs`: using the QueryRenderer in SSR contexts 86 | * `react-native/todo-updater`: using QueryRender in an RN application 87 | * `todo-updater`: using the QueryRender 88 | * `suspense/cra`: using useLazyLoadQuery in a CRA 89 | * `suspense/nextjs-ssr-preload`: using the render-as-you-fetch pattern with loadLazyQuery in react concurrent + SSR contexts 90 | * `suspense/nextjs-ssr`: using useLazyLoadQuery in SSR contexts 91 | 92 | To try it out! 93 | 94 | 95 | ## Environment 96 | 97 | ```ts 98 | import { Network } from "relay-runtime"; 99 | import { RecordSource, Store, Environment } from "react-relay-offline"; 100 | 101 | const network = Network.create(fetchQuery); 102 | const recordSource = new RecordSource(); 103 | const store = new Store(recordSource); 104 | const environment = new Environment({ network, store }); 105 | ``` 106 | 107 | ## Environment with Offline Options 108 | 109 | ```ts 110 | import { Network } from "relay-runtime"; 111 | import { RecordSource, Store, Environment } from "react-relay-offline"; 112 | 113 | const network = Network.create(fetchQuery); 114 | 115 | const networkOffline = Network.create(fetchQueryOffline); 116 | const manualExecution = false; 117 | 118 | const recordSource = new RecordSource(); 119 | const store = new Store(recordSource); 120 | const environment = new Environment({ network, store }); 121 | environment.setOfflineOptions({ 122 | manualExecution, //optional 123 | network: networkOffline, //optional 124 | start: async mutations => { 125 | //optional 126 | console.log("start offline", mutations); 127 | return mutations; 128 | }, 129 | finish: async (mutations, error) => { 130 | //optional 131 | console.log("finish offline", error, mutations); 132 | }, 133 | onExecute: async mutation => { 134 | //optional 135 | console.log("onExecute offline", mutation); 136 | return mutation; 137 | }, 138 | onComplete: async options => { 139 | //optional 140 | console.log("onComplete offline", options); 141 | return true; 142 | }, 143 | onDiscard: async options => { 144 | //optional 145 | console.log("onDiscard offline", options); 146 | return true; 147 | }, 148 | onPublish: async offlinePayload => { 149 | //optional 150 | console.log("offlinePayload", offlinePayload); 151 | return offlinePayload; 152 | } 153 | }); 154 | ``` 155 | 156 | - manualExecution: if set to true, mutations in the queue are no longer performed automatically as soon as you go back online. invoke manually: `environment.getStoreOffline().execute();` 157 | 158 | - network: it is possible to configure a different network for the execution of mutations in the queue; all the information of the mutation saved in the offline store are inserted into the "metadata" field of the CacheConfig so that they can be used during communication with the server. 159 | 160 | * start: function that is called once the request queue has been started. 161 | 162 | * finish: function that is called once the request queue has been processed. 163 | 164 | * onExecute: function that is called before the request is sent to the network. 165 | 166 | * onPublish: function that is called before saving the mutation in the store 167 | 168 | * onComplete: function that is called once the request has been successfully completed. Only if the function returns the value true, the request is deleted from the queue. 169 | 170 | * onDiscard: function that is called when the request returns an error. Only if the function returns the value true, the mutation is deleted from the queue 171 | 172 | ## IndexedDB 173 | 174 | localStorage is used as the default react web persistence, while AsyncStorage is used for react-native. 175 | 176 | To use persistence via IndexedDB: 177 | 178 | ```ts 179 | import { Network } from "relay-runtime"; 180 | import EnvironmentIDB from "react-relay-offline/lib/runtime/EnvironmentIDB"; 181 | 182 | const network = Network.create(fetchQuery); 183 | const environment = EnvironmentIDB.create({ network }); 184 | ``` 185 | 186 | ## Environment with PersistOfflineOptions 187 | 188 | ```ts 189 | import { Network } from "relay-runtime"; 190 | import { RecordSource, Store, Environment } from "react-relay-offline"; 191 | import { CacheOptions } from "@wora/cache-persist"; 192 | 193 | const network = Network.create(fetchQuery); 194 | 195 | const networkOffline = Network.create(fetchQueryOffline); 196 | 197 | const persistOfflineOptions: CacheOptions = { 198 | prefix: "app-user1" 199 | }; 200 | const recordSource = new RecordSource(); 201 | const store = new Store(recordSource); 202 | const environment = new Environment({ network, store }, persistOfflineOptions); 203 | ``` 204 | 205 | [CacheOptions](https://morrys.github.io/wora/docs/cache-persist.html#cache-options) 206 | 207 | ## Store with custom options 208 | 209 | ```ts 210 | import { Store } from "react-relay-offline"; 211 | import { CacheOptions } from "@wora/cache-persist"; 212 | import { StoreOptions } from "@wora/relay-store"; 213 | 214 | const persistOptionsStore: CacheOptions = { }; 215 | const persistOptionsRecords: CacheOptions = {}; 216 | const relayStoreOptions: StoreOptions = { queryCacheExpirationTime: 10 * 60 * 1000 }; // default 217 | const recordSource = new RecordSource(persistOptionsRecords); 218 | const store = new Store(recordSource, persistOptionsStore, relayStoreOptions); 219 | const environment = new Environment({ network, store }); 220 | ``` 221 | 222 | 223 | ## useQuery 224 | 225 | `useQuery` does not take an environment as an argument. Instead, it reads the environment set in the context; this also implies that it does not set any React context. 226 | In addition to `query` (first argument) and `variables` (second argument), `useQuery` accepts a third argument `options`. 227 | 228 | **options** 229 | 230 | `fetchPolicy`: determine whether it should use data cached in the Relay store and whether to send a network request. The options are: 231 | * `store-or-network` (default): Reuse data cached in the store; if the whole query is cached, skip the network request 232 | * `store-and-network`: Reuse data cached in the store; always send a network request. 233 | * `network-only`: Don't reuse data cached in the store; always send a network request. (This is the default behavior of Relay's existing `QueryRenderer`.) 234 | * `store-only`: Reuse data cached in the store; never send a network request. 235 | 236 | `fetchKey`: [Optional] A fetchKey can be passed to force a refetch of the current query and variables when the component re-renders, even if the variables didn't change, or even if the component isn't remounted (similarly to how passing a different key to a React component will cause it to remount). If the fetchKey is different from the one used in the previous render, the current query and variables will be refetched. 237 | 238 | `networkCacheConfig`: [Optional] Object containing cache config options for the network layer. Note the the network layer may contain an additional query response cache which will reuse network responses for identical queries. If you want to bypass this cache completely, pass {force: true} as the value for this option. **Added the TTL property to configure a specific ttl for the query.** 239 | 240 | `skip`: [Optional] If skip is true, the query will be skipped entirely. 241 | 242 | `onComplete`: [Optional] Function that will be called whenever the fetch request has completed 243 | 244 | ```ts 245 | import { useQuery } from "react-relay-offline"; 246 | const networkCacheConfig = { 247 | ttl: 1000 248 | } 249 | const hooksProps = useQuery(query, variables, { 250 | networkCacheConfig, 251 | fetchPolicy, 252 | }); 253 | ``` 254 | 255 | ## useLazyLoadQuery 256 | 257 | ```ts 258 | import { useQuery } from "react-relay-offline"; 259 | const networkCacheConfig = { 260 | ttl: 1000 261 | } 262 | const hooksProps = useLazyLoadQuery(query, variables, { 263 | networkCacheConfig, 264 | fetchPolicy, 265 | }); 266 | ``` 267 | 268 | ## useRestore & loading 269 | 270 | the **useRestore** hook allows you to manage the hydratation of persistent data in memory and to initialize the environment. 271 | 272 | **It must always be used before using environement in web applications without SSR & react legacy & react-native.** 273 | 274 | **Otherwise, for SSR and react concurrent applications the restore is natively managed by QueryRenderer & useQueryLazyLoad & useQuery.** 275 | 276 | ``` 277 | const isRehydrated = useRestore(environment); 278 | if (!isRehydrated) { 279 | return ; 280 | } 281 | ``` 282 | 283 | ## fetchQuery_DEPRECATED 284 | 285 | ```ts 286 | import { fetchQuery_DEPRECATED } from "react-relay-offline"; 287 | ``` 288 | 289 | 290 | ## Detect Network 291 | 292 | ```ts 293 | import { useIsConnected } from "react-relay-offline"; 294 | import { useNetInfo } from "react-relay-offline"; 295 | import { NetInfo } from "react-relay-offline"; 296 | ``` 297 | 298 | ## Supports Hooks from relay-hooks 299 | 300 | Now you can use hooks (useFragment, usePagination, useRefetch) from [relay-hooks](https://github.com/relay-tools/relay-hooks) 301 | 302 | ## render-as-you-fetch & usePreloadedQuery 303 | 304 | ### loadQuery 305 | 306 | * input parameters 307 | 308 | same as useQuery + environment 309 | 310 | * output parameters 311 | * 312 | `next: ( 313 | environment: Environment, 314 | gqlQuery: GraphQLTaggedNode, 315 | variables?: TOperationType['variables'], 316 | options?: QueryOptions, 317 | ) => Promise`: fetches data. A promise returns to allow the await in case of SSR 318 | * `dispose: () => void`: cancel the subscription and dispose of the fetch 319 | * `subscribe: (callback: (value: any) => any) => () => void`: used by the usePreloadedQuery 320 | * `getValue (environment?: Environment,) => OfflineRenderProps | Promise`: used by the usePreloadedQuery 321 | 322 | ```ts 323 | import {graphql, loadQuery} from 'react-relay-offline'; 324 | import {environment} from ''./environment'; 325 | 326 | const query = graphql` 327 | query AppQuery($id: ID!) { 328 | user(id: $id) { 329 | name 330 | } 331 | } 332 | `; 333 | 334 | const prefetch = loadQuery(); 335 | prefetch.next( 336 | environment, 337 | query, 338 | {id: '4'}, 339 | {fetchPolicy: 'store-or-network'}, 340 | ); 341 | // pass prefetch to usePreloadedQuery() 342 | ``` 343 | 344 | ### loadLazyQuery 345 | 346 | **is the same as loadQuery but must be used with suspense** 347 | 348 | ### render-as-you-fetch in SSR 349 | 350 | In SSR contexts, **not using the useRestore hook** it is necessary to manually invoke the hydrate but without using the await. 351 | 352 | This will allow the usePreloadedQuery hook to correctly retrieve the data from the store and once the hydration is done it will be react-relay-offline 353 | 354 | to notify any updated data in the store. 355 | 356 | ```ts 357 | if (!environment.isRehydrated() && ssr) { 358 | environment.hydrate().then(() => {}).catch((error) => {}); 359 | } 360 | prefetch.next(environment, QUERY_APP, variables, { 361 | fetchPolicy: NETWORK_ONLY, 362 | }); 363 | ``` 364 | 365 | ## Requirement 366 | 367 | - Version >=11.0.2 of the relay-runtime library 368 | - When a new node is created by mutation the id must be generated in the browser to use it in the optimistic response 369 | 370 | ## License 371 | 372 | React Relay Offline is [MIT licensed](./LICENSE). 373 | 374 | -------------------------------------------------------------------------------- /docs/assets/memorang-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morrys/react-relay-offline/378bb98eb354c03621aed60f95d66aa286a434a9/docs/assets/memorang-logo.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.(js|jsx|ts|tsx)$': 'ts-jest', 4 | }, 5 | preset: 'ts-jest', 6 | verbose: true, 7 | 8 | globals: { 9 | __DEV__: true, 10 | 'ts-jest': { 11 | astTransformers: { 12 | before: ['ts-relay-plugin'], 13 | }, 14 | diagnostics: { 15 | warnOnly: true, 16 | }, 17 | isolatedModules: true, 18 | }, 19 | }, 20 | 21 | moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], 22 | testURL: 'http://localhost', 23 | testEnvironment: 'jsdom', 24 | 25 | testMatch: ['/__tests__/*-test.tsx'], 26 | testPathIgnorePatterns: ['./node_modules/', '/node_modules/', '/lib/', '/lib/', '/node_modules/'], 27 | transformIgnorePatterns: ['./node_modules/(?!(@react-native-community|react-native))'], 28 | coverageThreshold: { 29 | global: { 30 | branches: 0, 31 | functions: 0, 32 | lines: 0, 33 | statements: 0, 34 | }, 35 | }, 36 | setupFiles: ['./scripts/setup.ts'], 37 | timers: 'fake', 38 | }; 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-relay-offline", 3 | "version": "6.0.0", 4 | "keywords": [ 5 | "graphql", 6 | "relay", 7 | "offline", 8 | "react" 9 | ], 10 | "main": "lib/index.js", 11 | "license": "MIT", 12 | "description": "React Relay Offline", 13 | "author": { 14 | "name": "morrys" 15 | }, 16 | "homepage": "https://github.com/morrys/react-relay-offline", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/morrys/react-relay-offline" 20 | }, 21 | "scripts": { 22 | "clean": "rimraf lib/*", 23 | "precompile": "npm run clean", 24 | "compile": " npm run compile-src && npm run compile-test", 25 | "compile-test": "tsc --project ./tsconfig-test.json", 26 | "compile-src": "tsc", 27 | "build": "npm run compile && npm run test", 28 | "format": "prettier --write \"src/**/*.{j,t}s*\"", 29 | "eslint": "eslint ./src --ext .js,.jsx,.ts,.tsx", 30 | "relay-compile-test": "relay-compiler", 31 | "test": "npm run relay-compile-test && jest --coverage", 32 | "prepublishOnly": "npm run build" 33 | }, 34 | "relay": { 35 | "src": "./__tests__", 36 | "schema": "./node_modules/relay-test-utils-internal/lib/testschema.graphql", 37 | "excludes": [ 38 | "__generated__", 39 | "node_modules" 40 | ], 41 | "language": "typescript", 42 | "artifactDirectory": "./__tests__/__generated__/" 43 | }, 44 | "dependencies": { 45 | "@babel/runtime": "^7.7.2", 46 | "@wora/relay-offline": "^7.0.0", 47 | "@wora/netinfo": "^2.1.1", 48 | "@wora/detect-network": "^2.0.1", 49 | "@wora/cache-persist": "^2.2.1", 50 | "@wora/offline-first": "^2.4.1", 51 | "@wora/relay-store": "^7.0.0", 52 | "fbjs": "^3.0.0", 53 | "nullthrows": "^1.1.0", 54 | "uuid": "3.3.2", 55 | "relay-hooks": "^7.1.0", 56 | "tslib": "^1.11.1" 57 | }, 58 | "peerDependencies": { 59 | "react": "^16.9.0 || ^17 || ^18", 60 | "relay-runtime": "^13.0.0" 61 | }, 62 | "devDependencies": { 63 | "@react-native-community/netinfo": "3.2.1", 64 | "@types/jest": "^26.0.0", 65 | "@types/node": "13.9.3", 66 | "@types/promise-polyfill": "^6.0.3", 67 | "@types/react": "16.8.14", 68 | "@types/react-dom": "16.8.4", 69 | "@types/relay-runtime": "^13.0.0", 70 | "@typescript-eslint/eslint-plugin": "2.24.0", 71 | "@typescript-eslint/parser": "2.24.0", 72 | "babel-jest": "^26.0.0", 73 | "eslint": "6.8.0", 74 | "eslint-config-airbnb": "18.0.1", 75 | "eslint-config-prettier": "6.10.1", 76 | "eslint-import-resolver-typescript": "2.0.0", 77 | "eslint-plugin-import": "2.20.1", 78 | "eslint-plugin-json": "2.1.1", 79 | "eslint-plugin-jsx-a11y": "6.2.3", 80 | "eslint-plugin-prettier": "3.1.2", 81 | "eslint-plugin-react": "7.19.0", 82 | "eslint-plugin-react-hooks": "2.5.1", 83 | "idb": "^4.0.0", 84 | "jest": "^26.0.0", 85 | "jest-junit": "8.0.0", 86 | "lerna": "^3.16.4", 87 | "prettier": "2.0.1", 88 | "promise-polyfill": "6.1.0", 89 | "react": "16.11.0", 90 | "react-test-renderer": "16.11.0", 91 | "react-native": "0.59.9", 92 | "relay-runtime": "^13.0.0", 93 | "relay-compiler": "^13.0.0", 94 | "relay-test-utils-internal": "^13.0.0", 95 | "relay-test-utils": "^13.0.0", 96 | "rimraf": "2.6.3", 97 | "ts-jest": "^26.5.6", 98 | "typescript": "3.8.3", 99 | "ts-relay-plugin": "1.0.1", 100 | "graphql": "^15.0.0" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /scripts/setup.ts: -------------------------------------------------------------------------------- 1 | import * as Promise from 'promise-polyfill'; 2 | global.Promise = Promise; 3 | -------------------------------------------------------------------------------- /src-test/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | createMockEnvironment, 3 | createPersistedStorage, 4 | createPersistedStore, 5 | createPersistedRecordSource, 6 | } from '@wora/relay-offline/test'; 7 | -------------------------------------------------------------------------------- /src/RelayOfflineTypes.ts: -------------------------------------------------------------------------------- 1 | import { OperationType, CacheConfig, GraphQLTaggedNode } from 'relay-runtime'; 2 | import { RenderProps, QueryOptions, LoadQuery } from 'relay-hooks'; 3 | import { Environment } from '@wora/relay-offline'; 4 | 5 | export interface CacheConfigTTL extends CacheConfig { 6 | ttl?: number; 7 | } 8 | 9 | export interface OfflineLoadQuery extends LoadQuery { 10 | getValue: ( 11 | environment?: Environment, 12 | ) => RenderProps | Promise; 13 | next: ( 14 | environment: Environment, 15 | gqlQuery: GraphQLTaggedNode, 16 | variables?: TOperationType['variables'], 17 | options?: QueryOptionsOffline, 18 | ) => Promise; 19 | } 20 | 21 | export interface QueryProps extends QueryOptionsOffline { 22 | query: GraphQLTaggedNode; 23 | variables: T['variables']; 24 | } 25 | 26 | export type QueryOptionsOffline = QueryOptions & { 27 | networkCacheConfig?: CacheConfigTTL; 28 | }; 29 | -------------------------------------------------------------------------------- /src/hooks/useLazyLoadQueryOffline.ts: -------------------------------------------------------------------------------- 1 | import { useRelayEnvironment, useLazyLoadQuery, STORE_ONLY, RenderProps } from 'relay-hooks'; 2 | import { OperationType, GraphQLTaggedNode } from 'relay-runtime'; 3 | import { QueryOptionsOffline } from '../RelayOfflineTypes'; 4 | import { Environment } from '@wora/relay-offline'; 5 | 6 | export const useLazyLoadQueryOffline = function ( 7 | gqlQuery: GraphQLTaggedNode, 8 | variables: TOperationType['variables'], 9 | options: QueryOptionsOffline = {}, 10 | ): RenderProps { 11 | const environment = useRelayEnvironment(); 12 | 13 | const rehydrated = environment.isRehydrated(); 14 | 15 | const online = environment.isOnline(); 16 | 17 | if (!rehydrated || !online) { 18 | options.fetchPolicy = STORE_ONLY; 19 | } 20 | 21 | const queryResult = useLazyLoadQuery(gqlQuery, variables, options); 22 | 23 | if (!rehydrated) { 24 | const promise = environment 25 | .hydrate() 26 | .then(() => undefined) 27 | .catch((error) => { 28 | throw error; // 29 | }); 30 | if (!queryResult.data) { 31 | throw promise; 32 | } 33 | } 34 | return queryResult; 35 | }; 36 | -------------------------------------------------------------------------------- /src/hooks/useOffline.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | import * as areEqual from 'fbjs/lib/areEqual'; 3 | import { OfflineRecordCache } from '@wora/offline-first'; 4 | import { Payload, Environment } from '@wora/relay-offline'; 5 | import { useRelayEnvironment } from 'relay-hooks'; 6 | 7 | export function useOffline(): ReadonlyArray> { 8 | const ref = useRef(); 9 | const environment = useRelayEnvironment(); 10 | const [state, setState] = useState>>(environment.getStoreOffline().getListMutation()); 11 | 12 | useEffect(() => { 13 | const dispose = environment.getStoreOffline().subscribe((nextState, _action) => { 14 | if (!areEqual(ref.current, nextState)) { 15 | ref.current = nextState; 16 | setState(nextState); 17 | } 18 | }); 19 | return (): void => { 20 | dispose(); 21 | }; 22 | }, []); 23 | 24 | return state; 25 | } 26 | -------------------------------------------------------------------------------- /src/hooks/usePreloadedQueryOffline.ts: -------------------------------------------------------------------------------- 1 | import { OperationType } from 'relay-runtime'; 2 | import { OfflineLoadQuery } from '../RelayOfflineTypes'; 3 | import { usePreloadedQuery, RenderProps } from 'relay-hooks'; 4 | 5 | export const usePreloadedQueryOffline = ( 6 | loadQuery: OfflineLoadQuery, 7 | ): RenderProps => { 8 | return usePreloadedQuery(loadQuery); 9 | }; 10 | -------------------------------------------------------------------------------- /src/hooks/useQueryOffline.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, useRelayEnvironment, STORE_ONLY, RenderProps } from 'relay-hooks'; 2 | import { OperationType, GraphQLTaggedNode } from 'relay-runtime'; 3 | import { QueryOptionsOffline } from '../RelayOfflineTypes'; 4 | import { useForceUpdate } from 'relay-hooks/lib/useForceUpdate'; 5 | import { Environment } from '@wora/relay-offline'; 6 | 7 | export const useQueryOffline = function ( 8 | gqlQuery: GraphQLTaggedNode, 9 | variables: TOperationType['variables'], 10 | options: QueryOptionsOffline = {}, 11 | ): RenderProps { 12 | const environment = useRelayEnvironment(); 13 | 14 | const rehydrated = environment.isRehydrated(); 15 | 16 | const forceUpdate = useForceUpdate(); 17 | 18 | const online = environment.isOnline(); 19 | 20 | if (!rehydrated || !online) { 21 | options.fetchPolicy = STORE_ONLY; 22 | } 23 | 24 | const queryResult = useQuery(gqlQuery, variables, options); 25 | 26 | if (!rehydrated) { 27 | environment 28 | .hydrate() 29 | .then(() => { 30 | if (!queryResult.data) { 31 | forceUpdate(); 32 | } 33 | }) 34 | .catch((error) => { 35 | throw error; // 36 | }); 37 | } 38 | return queryResult; 39 | }; 40 | -------------------------------------------------------------------------------- /src/hooks/useRestore.ts: -------------------------------------------------------------------------------- 1 | import { Environment } from '@wora/relay-offline'; 2 | import { useState } from 'react'; 3 | 4 | export function useRestore(environment: Environment): boolean { 5 | const [rehydratate, setRehydratate] = useState(environment.isRehydrated()); 6 | 7 | if (!rehydratate) { 8 | environment.hydrate().then(() => setRehydratate(environment.isRehydrated())); 9 | } 10 | return rehydratate; 11 | } 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { applyOptimisticMutation, commitMutation, commitLocalUpdate, graphql, requestSubscription } from 'relay-runtime'; 2 | export { useRestore } from './hooks/useRestore'; 3 | export { loadQuery, loadLazyQuery } from './runtime/loadQuery'; 4 | export { useQueryOffline as useQuery } from './hooks/useQueryOffline'; 5 | export { usePreloadedQueryOffline as usePreloadedQuery } from './hooks/usePreloadedQueryOffline'; 6 | export { useLazyLoadQueryOffline as useLazyLoadQuery } from './hooks/useLazyLoadQueryOffline'; 7 | // eslint-disable-next-line @typescript-eslint/camelcase 8 | export { Environment, fetchQuery_DEPRECATED } from '@wora/relay-offline'; 9 | export { Store, RecordSource } from '@wora/relay-store'; 10 | export { NetInfo } from '@wora/netinfo'; 11 | export { useNetInfo, useIsConnected } from '@wora/detect-network'; 12 | export * from './RelayOfflineTypes'; 13 | export { 14 | NETWORK_ONLY, 15 | STORE_THEN_NETWORK, 16 | STORE_OR_NETWORK, 17 | STORE_ONLY, 18 | FetchPolicy, 19 | useFragment, 20 | useMutation, 21 | usePagination, 22 | useRefetchable, 23 | useRefetchableFragment, 24 | usePaginationFragment, 25 | useSuspenseFragment, 26 | RelayEnvironmentProvider, 27 | } from 'relay-hooks'; 28 | -------------------------------------------------------------------------------- /src/runtime/EnvironmentIDB.ts: -------------------------------------------------------------------------------- 1 | import EnvironmentIDB from '@wora/relay-offline/lib/EnvironmentIDB'; 2 | export default EnvironmentIDB; 3 | -------------------------------------------------------------------------------- /src/runtime/loadQuery.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLTaggedNode, OperationType, IEnvironment } from 'relay-runtime'; 2 | import { QueryOptionsOffline } from '../RelayOfflineTypes'; 3 | import { QueryFetcher } from 'relay-hooks/lib/QueryFetcher'; 4 | import { RenderProps, LoadQuery } from 'relay-hooks'; 5 | 6 | const emptyFunction = (): void => undefined; 7 | export const internalLoadQuery = (promise = false): LoadQuery => { 8 | let queryFetcher = new QueryFetcher(); 9 | 10 | const dispose = (): void => { 11 | queryFetcher.dispose(); 12 | queryFetcher = new QueryFetcher(); 13 | }; 14 | 15 | const next = ( 16 | environment, 17 | gqlQuery: GraphQLTaggedNode, 18 | variables: TOperationType['variables'] = {}, 19 | options: QueryOptionsOffline = {}, 20 | ): Promise => { 21 | const online = environment.isOnline(); 22 | const rehydrated = environment.isRehydrated(); 23 | if (!online || !rehydrated) { 24 | options.fetchPolicy = 'store-only'; 25 | } 26 | options.networkCacheConfig = options.networkCacheConfig ?? { force: true }; 27 | queryFetcher.resolve(environment, gqlQuery, variables, options); 28 | const toThrow = queryFetcher.checkAndSuspense(); 29 | return toThrow ? (toThrow instanceof Error ? Promise.reject(toThrow) : toThrow) : Promise.resolve(); 30 | }; 31 | 32 | const getValue = (environment?: IEnvironment): RenderProps | null | Promise => { 33 | queryFetcher.resolveEnvironment(environment); 34 | queryFetcher.checkAndSuspense(promise); 35 | return queryFetcher.getData(); 36 | }; 37 | 38 | const subscribe = (callback: () => any): (() => void) => { 39 | queryFetcher.setForceUpdate(callback); 40 | return (): void => { 41 | if (queryFetcher.getForceUpdate() === callback) { 42 | queryFetcher.setForceUpdate(emptyFunction); 43 | } 44 | }; 45 | }; 46 | return { 47 | next, 48 | subscribe, 49 | getValue, 50 | dispose, 51 | }; 52 | }; 53 | 54 | export const loadLazyQuery = (): LoadQuery => { 55 | return internalLoadQuery(true); 56 | }; 57 | 58 | export const loadQuery = (): LoadQuery => { 59 | return internalLoadQuery(false); 60 | }; 61 | -------------------------------------------------------------------------------- /tsconfig-test.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "test", 4 | "rootDir": "src-test", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "moduleResolution": "node", 8 | "noEmitOnError": true, 9 | "declaration": true, 10 | "importHelpers": true, 11 | "lib": [ 12 | "dom", 13 | "es6", 14 | "esnext.asynciterable", 15 | "es2017.object" 16 | ], 17 | "jsx": "react", 18 | "skipLibCheck": true 19 | }, 20 | "exclude": [ 21 | "lib", 22 | "test", 23 | "__tests__", 24 | "scripts", 25 | "src" 26 | ], 27 | "compileOnSave": true 28 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "lib", 4 | "rootDir": "src", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "moduleResolution": "node", 8 | "noEmitOnError": true, 9 | "declaration": true, 10 | "importHelpers": true, 11 | "lib": [ 12 | "dom", 13 | "es6", 14 | "esnext.asynciterable", 15 | "es2017.object" 16 | ], 17 | "jsx": "react", 18 | "skipLibCheck": true 19 | }, 20 | "exclude": [ 21 | "lib", 22 | "test", 23 | "__tests__", 24 | "scripts", 25 | "src-test" 26 | ], 27 | "compileOnSave": true 28 | } -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | *.tgz 4 | lerna-debug.log 5 | .npmrc 6 | build -------------------------------------------------------------------------------- /website/core/Footer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const React = require('react'); 9 | 10 | class Footer extends React.Component { 11 | docUrl(doc, language) { 12 | const baseUrl = this.props.config.baseUrl; 13 | const docsUrl = this.props.config.docsUrl; 14 | const docsPart = `${docsUrl ? `${docsUrl}/` : ''}`; 15 | const langPart = `${language ? `${language}/` : ''}`; 16 | return `${baseUrl}${docsPart}${langPart}${doc}`; 17 | } 18 | 19 | pageUrl(doc, language) { 20 | const baseUrl = this.props.config.baseUrl; 21 | return baseUrl + (language ? `${language}/` : '') + doc; 22 | } 23 | 24 | render() { 25 | return ( 26 | 104 | ); 105 | } 106 | } 107 | 108 | module.exports = Footer; 109 | -------------------------------------------------------------------------------- /website/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "_comment": "This file is auto-generated by write-translations.js", 3 | "localized-strings": { 4 | "next": "Next", 5 | "previous": "Previous", 6 | "tagline": "Collection of libraries usable for the web, react and react-native.", 7 | "docs": { 8 | "react-relay-offline": { 9 | "title": "Getting Started" 10 | } 11 | }, 12 | "links": { 13 | "Docs": "Docs", 14 | "GitHub": "GitHub" 15 | }, 16 | "categories": { 17 | "React Relay Offline": "React Relay Offline" 18 | } 19 | }, 20 | "pages-strings": { 21 | "Help Translate|recruit community translators for your project": "Help Translate", 22 | "Edit this Doc|recruitment message asking to edit the doc source": "Edit", 23 | "Translate this Doc|recruitment message asking to translate the docs": "Translate" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": "morrys/react-relay-offline", 3 | "license": "MIT", 4 | "private": true, 5 | "scripts": { 6 | "start": "docusaurus-start", 7 | "build": "docusaurus-build", 8 | "docpub": "docusaurus-publish" 9 | }, 10 | "dependencies": { 11 | "docusaurus": "1.12.0" 12 | } 13 | } -------------------------------------------------------------------------------- /website/sidebars.json: -------------------------------------------------------------------------------- 1 | { 2 | "docs": { 3 | "React Relay Offline": [ 4 | "react-relay-offline" 5 | ] 6 | } 7 | } -------------------------------------------------------------------------------- /website/siteConfig.js: -------------------------------------------------------------------------------- 1 | const siteConfig = { 2 | title: 'Morrys Repositories', 3 | tagline: 'Collection of libraries usable for the web, react and react-native.', 4 | url: 'https://morrys.github.io', 5 | baseUrl: '/react-relay-offline/', 6 | projectName: 'react-relay-offline', 7 | organizationName: 'morrys', 8 | headerLinks: [ 9 | { doc: 'react-relay-offline', label: 'Docs' }, 10 | { 11 | href: 'https://github.com/morrys/react-relay-offline', 12 | label: 'GitHub', 13 | }, 14 | { languages: false }, 15 | ], 16 | 17 | /* path to images for header/footer */ 18 | headerIcon: 'img/favicon.ico', 19 | footerIcon: 'img/favicon.ico', 20 | favicon: 'img/favicon.ico', 21 | colors: { 22 | primaryColor: '#008ed8', 23 | secondaryColor: '#17afff', 24 | }, 25 | 26 | algolia: { 27 | apiKey: '87a72e28932891cac536490e275e834e', 28 | indexName: 'morrys', 29 | placeholder: 'Search' 30 | }, 31 | gaTrackingId: "UA-146953551-1", 32 | 33 | /* Custom fonts for website */ 34 | /* 35 | fonts: { 36 | myFont: [ 37 | "Times New Roman", 38 | "Serif" 39 | ], 40 | myOtherFont: [ 41 | "-apple-system", 42 | "system-ui" 43 | ] 44 | }, 45 | */ 46 | 47 | // This copyright info is used in /core/Footer.js and blog RSS/Atom feeds. 48 | copyright: `Copyright © ${new Date().getFullYear()} Lorenzo Di Giacomo`, 49 | 50 | highlight: { 51 | // Highlight.js theme to use for syntax highlighting in code blocks. 52 | theme: 'default', 53 | }, 54 | 55 | // Add custom scripts here that would be placed in 9 | Your Site Title Here 10 | 11 | 12 | If you are not redirected automatically, follow this link. 13 | 14 | --------------------------------------------------------------------------------