Skip to content

Commit 2cb9f9f

Browse files
dominic-farquharsonnicklmart
authored andcommitted
feat(fetchye-one-app): add streaming support (#110)
* feat(fetchye): expose computeKey * feat(fetchye-one-app): add streamedFetchye thunk * feat(fetchye-one-app): add useStreamedFetchye hook * chore(docs): add streaming docs * chore(jest): update test patterns to target nested directories * chore(nvmrc): match node version from actions * chore(lint): resolve lint errors * feat(fetchye): add throwOnError option for streaming (#111) * feat(fetchye): add throwOnError option for streaming * chore(docs): update docs with throwOnError option * chore(fetchye-one-app): rename streamedFetchye to streamFetchye * refactor(fetchye-one-app): change thunk arg for streamFetchye from promise to function * feat(fetchye-one-app): throw error when response error is present
1 parent 563f9c3 commit 2cb9f9f

File tree

17 files changed

+855
-26
lines changed

17 files changed

+855
-26
lines changed

README.md

Lines changed: 149 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ const AbortComponent = () => {
355355
const controller = new AbortController();
356356
useFetchye('http://example.com/api/books', { signal: controller.signal });
357357

358+
// eslint-disable-next-line react-hooks/exhaustive-deps -- sample code.
358359
useEffect(() => () => controller.abort(), []);
359360

360361
return (
@@ -796,26 +797,53 @@ const ParentComponent = ({ children }) => (
796797
797798
**Contents**
798799
799-
* [`useFetchye`](#usefetchye)
800-
* [`makeServerFetchye`](#makeserverfetchye)
801-
* [`makeOneServerFetchye`](#makeoneserverfetchye) (deprecated)
802-
* [`oneFetchye`](#oneFetchye)
803-
* [Providers](#providers)
804-
* [`FetchyeProvider`](#fetchyeprovider)
805-
* [`FetchyeReduxProvider`](#fetchyereduxprovider)
806-
* [`OneFetchyeProvider`](#oneFetchyeProvider)
807-
* [Caches](#caches)
808-
* [`SimpleCache`](#simplecache)
809-
* [`ImmutableCache`](#immutablecache)
810-
* [`OneCache`](#onecache)
811-
* [Actions](#actions)
812-
* [`IS_LOADING`](#is_loading)
813-
* [`SET_DATA`](#set_data)
814-
* [`DELETE_DATA`](#delete_data)
815-
* [`ERROR`](#error)
816-
* [`CLEAR_ERROR`](#clear_error)
817-
* [`mapOptionToKey Helpers`](#mapoptiontokey-helpers)
818-
* [`ignoreHeadersByKey`](#ignoreheadersbykey)
800+
- [📖 Table of Contents](#-table-of-contents)
801+
- [✨ Features](#-features)
802+
- [⬇️ Install \& Setup](#️-install--setup)
803+
- [Quick Install](#quick-install)
804+
- [`FetchyeProvider` Install](#fetchyeprovider-install)
805+
- [`FetchyeReduxProvider` Install](#fetchyereduxprovider-install)
806+
- [One App Install](#one-app-install)
807+
- [🤹‍ Usage](#-usage)
808+
- [Real-World Example](#real-world-example)
809+
- [Deferred execution](#deferred-execution)
810+
- [Abort Inflight Requests](#abort-inflight-requests)
811+
- [Sequential API Execution](#sequential-api-execution)
812+
- [Refreshing](#refreshing)
813+
- [Custom Fetcher](#custom-fetcher)
814+
- [Controlling the Cache Key](#controlling-the-cache-key)
815+
- [Passing dynamic headers](#passing-dynamic-headers)
816+
- [SSR](#ssr)
817+
- [One App SSR](#one-app-ssr)
818+
- [Next.JS SSR](#nextjs-ssr)
819+
- [Write your own Cache](#write-your-own-cache)
820+
- [🎛️ API](#️-api)
821+
- [`useFetchye`](#usefetchye)
822+
- [`makeServerFetchye`](#makeserverfetchye)
823+
- [`makeOneServerFetchye`](#makeoneserverfetchye)
824+
- [oneFetchye](#onefetchye)
825+
- [streamFetchye](#streamfetchye)
826+
- [useStreamedFetchye](#usestreamedfetchye)
827+
- [Providers](#providers)
828+
- [`FetchyeProvider`](#fetchyeprovider)
829+
- [`FetchyeReduxProvider`](#fetchyereduxprovider)
830+
- [`OneFetchyeProvider`](#onefetchyeprovider)
831+
- [Caches](#caches)
832+
- [`SimpleCache`](#simplecache)
833+
- [`ImmutableCache`](#immutablecache)
834+
- [`OneCache`](#onecache)
835+
- [Actions](#actions)
836+
- [`IS_LOADING`](#is_loading)
837+
- [`SET_DATA`](#set_data)
838+
- [`DELETE_DATA`](#delete_data)
839+
- [`ERROR`](#error)
840+
- [`CLEAR_ERROR`](#clear_error)
841+
- [mapOptionToKey Helpers](#mapoptiontokey-helpers)
842+
- [ignoreHeadersByKey](#ignoreheadersbykey)
843+
- [📢 Mission](#-mission)
844+
- [🏆 Contributing](#-contributing)
845+
- [🗝️ License](#️-license)
846+
- [🗣️ Code of Conduct](#️-code-of-conduct)
819847
820848
### `useFetchye`
821849
@@ -957,6 +985,107 @@ A promise resolving to an object with the below keys:
957985
| `error?` | `Object` | An object containing an error if present. *Defaults to an `Error` object with a thrown `fetch` error. This is not for API errors (e.g. Status 500 or 400). See `data` for that* |
958986
| `run` | `async () => {}` | A function for bypassing the cache and firing an API call. Can be awaited. |
959987
988+
### streamFetchye
989+
990+
A helper to enable streaming for server side Fetchye API calls. The first parameter is a [`oneFetchye`](https://github.com/americanexpress/fetchye?tab=readme-ov-file#oneFetchye) thunk.
991+
992+
Note: The key and options are used to compute the cache key and must match the values passed to `useStreamedFetchye` for a successful cache hit. When `options.throwOnError` is enabled, the function will throw an error for unsuccessful requests which will bubble up to the nearest error boundary. When disabled, the success status of the request must be manually checked.
993+
994+
**Shape**
995+
```js
996+
import { streamFetchye, oneFetchye } from 'fetchye-one-app';
997+
998+
const key = 'https://example.com/api/v2/people';
999+
const options = { throwOnError: true };
1000+
const streamFetchyeThunk = streamFetchye(oneFetchye, key, options, fetcher);
1001+
const reportError = (response) => {};
1002+
// NOTE: When throwOnError is true, promise rejections must be handled.
1003+
const loadModuleData = async ({ store: { dispatch } }) => {
1004+
dispatch(streamFetchyeThunk).catch(reportError);
1005+
};
1006+
```
1007+
1008+
**`streamFetchye` Arguments**
1009+
1010+
| name | type | required | description |
1011+
|-----------|------------------------------------------------------------------------------------------------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------|
1012+
| `thunk` | `Function` | `true` | A fetchye Redux thunk that will be called with the key, options, and fetcher. |
1013+
| `key` | `String` | `true` | A string that factors into cache key creation. *Defaults to URL compatible string*. |
1014+
| `options` | `Object<ES6FetchOptions & CustomFetchOptions>` | `false` | Options to pass through to [ES6 Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). See **Options** table for the CustomFetchOptions which do not get passed through to [ES6 Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). |
1015+
| `fetcher` | `async (fetchClient: Fetch, key: String, options: Options) => ({ payload: Object, error?: Object })` | `false` | The async function that calls `fetchClient` by key and options. Returns a `payload` with outcome of `fetchClient` and an optional `error` object. |
1016+
1017+
**`streamFetchye` Options**
1018+
1019+
| name | type | required | description |
1020+
|---------------------|-------------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
1021+
| `throwOnError` | `boolean` | `false` | This option overrides the default fetchye behavior of catching failed requests. When enabled, unsuccessful responses will throw an error containing the payload of the request. This is intended to be used within a suspense boundary.
1022+
1023+
**`streamFetchye` Returns**
1024+
1025+
A promise resolving to the value of the thunk.
1026+
1027+
### useStreamedFetchye
1028+
1029+
A React hook used to read streamed data from the server. It will return the raw promise that the user can then parse manually. If there is no existing data from the server, a request will be performed on the client.
1030+
1031+
The key and options are used to compute the cache key and must match the values passed to `streamedFetchye` for a successful cache hit. When `options.throwOnError` is enabled, the hook will throw an error for unsuccessful responses which will bubble up to the nearest error boundary. When disabled, the success status of the request must be manually checked.
1032+
1033+
Note: The hook is intended to be used within a suspense boundary.
1034+
1035+
**Shape**
1036+
```jsx
1037+
import { use } from 'react';
1038+
import { useStreamedFetchye } from 'fetchye-one-app';
1039+
import { Spinner } from 'design-library';
1040+
1041+
const MyComponent = () => {
1042+
const response = useStreamedFetchye('https://example.com/api/v2/people', {
1043+
headers: {
1044+
'Content-Type': 'application/json',
1045+
},
1046+
throwOnError: true,
1047+
});
1048+
1049+
const { data } = use(response);
1050+
1051+
// If `throwOnError` is false, the success status must be manually checked.
1052+
/*
1053+
if (!data.ok) {
1054+
throw new Error(data.body || 'something went wrong');
1055+
}
1056+
*/
1057+
1058+
return (
1059+
<p>{data.body.name}</p>
1060+
);
1061+
};
1062+
1063+
const Container = () => (
1064+
<Suspense fallback={<Spinner size="sm" />}>
1065+
<MyComponent />
1066+
</Suspense>
1067+
);
1068+
```
1069+
1070+
**`useStreamedFetchye` Arguments**
1071+
1072+
| name | type | required | description |
1073+
|-----------|------------------------------------------------------------------------------------------------------|----------|---------------------------------------------------------------------------------------------------------------------------------------------------|
1074+
| `key` | `String` | `true` | A string that factors into cache key creation. *Defaults to URL compatible string*. |
1075+
| `options` | `Object<ES6FetchOptions & CustomFetchOptions>` | `false` | Options to pass through to [ES6 Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). See **Options** table for the CustomFetchOptions which do not get passed through to [ES6 Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). |
1076+
| `fetcher` | `async (fetchClient: Fetch, key: String, options: Options) => ({ payload: Object, error?: Object })` | `false` | The async function that calls `fetchClient` by key and options. Returns a `payload` with outcome of `fetchClient` and an optional `error` object. |
1077+
1078+
**`useStreamedFetchye` Options**
1079+
1080+
| name | type | required | description |
1081+
|---------------------|-------------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
1082+
| `throwOnError` | `boolean` | `false` | This option overrides the default fetchye behavior of catching failed requests. When enabled, unsuccessful responses will throw an error containing the payload of the request. This is intended to be used within a suspense boundary.
1083+
1084+
1085+
**`useStreamedFetchye` Returns**
1086+
1087+
A promise resolving to the streamed or local promise.
1088+
9601089
### Providers
9611090
9621091
A Provider creates a React Context to connect all the `useFetchye` Hooks into a centrally stored cache.

jest.config.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ module.exports = {
2121
],
2222
snapshotSerializers: [],
2323
testMatch: [
24-
'**/__tests__/*.spec.{js,jsx}',
24+
'**/__tests__/**/*.spec.{js,jsx}',
2525
],
2626
collectCoverageFrom: [
27-
'packages/*/src/*.{js,jsx}',
27+
'packages/*/src/**/*.{js,jsx}',
28+
'!packages/*/src/**/index.{js,jsx}',
2829
],
2930
moduleNameMapper: {
3031
'^fetchye-redux-provider$': '<rootDir>/packages/fetchye-redux-provider/src/index.js',
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* Copyright 2026 American Express Travel Related Services Company, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
13+
* or implied. See the License for the specific language governing
14+
* permissions and limitations under the License.
15+
*/
16+
17+
import * as actions from '../../src/streaming/actions';
18+
19+
const storeLocalPromiseMock = jest.fn();
20+
const getLocalPromiseMock = jest.fn();
21+
const getStreamingPromisesMock = jest.fn();
22+
const storeStreamingPromiseMock = jest.fn();
23+
const promiseStore = {
24+
storeLocalPromise: storeLocalPromiseMock,
25+
getLocalPromise: getLocalPromiseMock,
26+
getStreamingPromises: getStreamingPromisesMock,
27+
storeStreamingPromise: storeStreamingPromiseMock,
28+
};
29+
const dispatchMock = jest.fn((thunk) => thunk(undefined, undefined, { promiseStore }));
30+
31+
describe('Streaming actions', () => {
32+
beforeEach(() => {
33+
jest.clearAllMocks();
34+
});
35+
36+
describe('storeLocalPromise', () => {
37+
it('stores a promise in the promise store', () => {
38+
expect.assertions(1);
39+
40+
const promise = new Promise(() => { });
41+
const domain = Symbol('domain');
42+
const key = Symbol('key');
43+
dispatchMock(actions.storeLocalPromise(domain, key, promise));
44+
45+
expect(storeLocalPromiseMock).toHaveBeenCalledWith(domain, key, promise);
46+
});
47+
});
48+
49+
describe('getLocalPromise', () => {
50+
it('retrieves a promise from the promise store', () => {
51+
expect.assertions(1);
52+
53+
const promise = new Promise(() => { });
54+
const domain = Symbol('domain');
55+
const key = Symbol('key');
56+
dispatchMock(actions.getLocalPromise(domain, key, promise));
57+
58+
expect(getLocalPromiseMock).toHaveBeenCalledWith(domain, key);
59+
});
60+
});
61+
62+
describe('getStreamingPromises', () => {
63+
it('retrieves all promises in promise store', () => {
64+
expect.assertions(2);
65+
66+
getStreamingPromisesMock.mockReturnValue(null);
67+
68+
expect(dispatchMock(actions.getStreamingPromises())).toEqual([]);
69+
70+
const promises = [Symbol('promise-1'), Symbol('promise-2')];
71+
getStreamingPromisesMock.mockReturnValue(promises);
72+
73+
expect(dispatchMock(actions.getStreamingPromises())).toEqual(promises);
74+
});
75+
});
76+
77+
describe('stream', () => {
78+
const { stream } = actions;
79+
80+
test.each([
81+
{
82+
input: 'not an array',
83+
error: 'PromiseArray must be an array',
84+
label: 'promiseArray is not an array',
85+
},
86+
{
87+
input: [{ domain: 1 }],
88+
error: 'Domain must be a string',
89+
label: 'domain is provided but not a string',
90+
},
91+
{
92+
input: [{ key: 1 }],
93+
error: 'Key must be a string',
94+
label: 'key not a string',
95+
},
96+
{
97+
input: [{ key: 'test', promise: 'not a promise' }],
98+
error: 'Promise must be an instance of Promise',
99+
label: 'promise is not an instance of Promise',
100+
},
101+
].map((testCase) => [testCase.label, testCase.input, testCase.error]))('should throw an error when %s', (_, input, error) => {
102+
expect(() => dispatchMock(stream(input))).toThrow(error);
103+
});
104+
105+
it('should call promiseStore.storeStreamingPromise with correct parameters', () => {
106+
expect.assertions(1);
107+
108+
const promise = Promise.resolve('test');
109+
110+
dispatchMock(stream([{ domain: 'domain1', key: 'key1', promise }]));
111+
112+
expect(storeStreamingPromiseMock).toHaveBeenCalledWith('domain1', 'key1', promise);
113+
});
114+
115+
it('should call promiseStore.storeStreamingPromise with domain not provided', () => {
116+
expect.assertions(1);
117+
118+
const promise = Promise.resolve('test');
119+
120+
dispatchMock(stream([{ key: 'key1', promise }]));
121+
122+
expect(storeStreamingPromiseMock).toHaveBeenCalledWith('key1', 'key1', promise);
123+
});
124+
});
125+
});

0 commit comments

Comments
 (0)