diff --git a/.eslintrc b/.eslintrc index 70843243..02a3ac38 100644 --- a/.eslintrc +++ b/.eslintrc @@ -20,5 +20,17 @@ "react": { "version": "detect" } - } -} + }, + "overrides": [{ + "files": "packages/**/*.{ts,tsx}", + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module", + "ecmaFeatures": { + "modules": true + } + } + }] +} \ No newline at end of file diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js index 3976b3d1..3f6fe527 100644 --- a/.storybook/webpack.config.js +++ b/.storybook/webpack.config.js @@ -1,4 +1,12 @@ module.exports = async ({ config }) => { delete config.module.rules[0].include + config.module.rules.push({ + test: /\.(ts|tsx)$/, + loader: require.resolve('babel-loader'), + options: { + presets: [['react-app', { flow: false, typescript: true }]], + }, + }); + config.resolve.extensions.push('.ts', '.tsx'); return config } diff --git a/babel.config.js b/babel.config.js index 49a48f94..4e9750dd 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,11 +1,11 @@ module.exports = { presets: ["@babel/preset-react"], - plugins: ["@babel/plugin-proposal-object-rest-spread"], + plugins: ["@babel/plugin-proposal-object-rest-spread", "@babel/plugin-proposal-class-properties"], env: { test: { - presets: ["@babel/preset-env", "@babel/preset-react"], - plugins: ["@babel/plugin-transform-runtime"], + presets: ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"], + plugins: ["@babel/plugin-transform-runtime", "@babel/plugin-proposal-class-properties"], }, }, } diff --git a/examples/with-typescript/package.json b/examples/with-typescript/package.json index 1b7ce772..d82ce02a 100644 --- a/examples/with-typescript/package.json +++ b/examples/with-typescript/package.json @@ -14,7 +14,7 @@ "now-build": "SKIP_PREFLIGHT_CHECK=true react-scripts build" }, "dependencies": { - "@types/node": "12.12.6", + "@types/node": "12.12.7", "@types/react": "16.9.11", "@types/react-dom": "16.9.4", "react": "16.11.0", diff --git a/jest.config.js b/jest.config.js index ef3484d5..b5124c80 100644 --- a/jest.config.js +++ b/jest.config.js @@ -17,7 +17,9 @@ module.exports = { coverageDirectory: "/coverage", verbose: true, bail: true, - transform: { "^.+\\.js$": "babel-jest" }, + transform: { + "^.+\\.[tj]sx?$": "babel-jest", + }, projects: ["/packages/*"], setupFiles: ["/jest.setup.js"], testPathIgnorePatterns: ["/node_modules/", "/pkg/"], diff --git a/package.json b/package.json index 541eddbf..1d400de2 100644 --- a/package.json +++ b/package.json @@ -15,17 +15,18 @@ "start": "run-p start:*", "start:examples": "now dev", "start:storybook": "start-storybook -p 6006", - "lint": "eslint packages/*/src/*.js", + "lint": "eslint packages/*/src/*.{js,ts,tsx}", "test": "jest packages/*/src/*.spec.js", "test:watch": "yarn test -- --watch", "test:devtools": "jest react-async-devtools/src", "test:components": "jest src/Async.spec.js --collectCoverageFrom=src/Async.js", - "test:backwards": "yarn add -D -W react@16.3.1 react-dom@16.3.1 && yarn test:components", - "test:forwards": "yarn add -D -W react@next react-dom@next && yarn test", - "test:latest": "yarn add -D -W react@latest react-dom@latest && yarn test", + "test:backwards": "yarn add -D -W react@16.3.1 react-dom@16.3.1 && yarn resolutions:fix-react && yarn test:components", + "test:forwards": "yarn add -D -W react@next react-dom@next && yarn resolutions:fix-react && yarn test", + "test:latest": "yarn add -D -W react@latest react-dom@latest && yarn resolutions:fix-react && yarn test", "test:compat": "yarn test:backwards && yarn test:forwards && yarn test:latest", "test:examples": "CI=1 lerna run --scope '*-example' test -- --passWithNoTests --watchAll=false", "test:chromatic": "chromatic --app-code iiua39bmt0j --build-script-name build:storybook", + "resolutions:fix-react": "jq '.resolutions.react = .devDependencies.react|.resolutions.\"react-dom\"=.devDependencies.react' package.json > package.json.new; mv package.json.new package.json; yarn install", "ci": "yarn lint && yarn test:compat && yarn test:examples", "build:packages": "lerna run --scope 'react-async*' build", "build:examples": "lerna run --scope '*-example' build", @@ -36,18 +37,25 @@ }, "devDependencies": { "@babel/core": "7.7.2", + "@babel/plugin-proposal-class-properties": "7.7.0", "@babel/plugin-proposal-object-rest-spread": "7.6.2", "@babel/plugin-transform-runtime": "7.6.2", "@babel/preset-env": "7.7.1", "@babel/preset-react": "7.7.0", + "@babel/preset-typescript": "7.7.2", "@pika/pack": "0.5.0", "@pika/plugin-build-node": "0.7.1", "@pika/plugin-build-types": "0.7.1", + "@pika/plugin-build-umd": "0.7.1", "@pika/plugin-build-web": "0.7.1", + "@pika/plugin-bundle-types": "0.7.1", "@pika/plugin-standard-pkg": "0.7.1", + "@pika/plugin-ts-standard-pkg": "0.7.1", "@storybook/react": "5.2.6", "@testing-library/jest-dom": "4.2.3", "@testing-library/react": "9.3.2", + "@typescript-eslint/eslint-plugin": "2.6.1", + "@typescript-eslint/parser": "2.6.1", "babel-eslint": "10.0.3", "babel-jest": "24.9.0", "babel-loader": "8.0.6", @@ -61,6 +69,7 @@ "eslint-plugin-react-hooks": "2.2.0", "jest": "24.9.0", "lerna": "3.18.4", + "node-jq": "1.11.0", "now": "16.4.4", "npm-run-all": "4.1.5", "prettier": "1.19.1", @@ -68,6 +77,7 @@ "react": "16.11.0", "react-async": "9.0.0", "react-dom": "16.11.0", - "storybook-chromatic": "3.1.0" + "storybook-chromatic": "3.1.0", + "typescript": "3.7.2" } } diff --git a/packages/react-async-devtools/src/index.js b/packages/react-async-devtools/src/index.js index a5b7ebee..0989c94f 100644 --- a/packages/react-async-devtools/src/index.js +++ b/packages/react-async-devtools/src/index.js @@ -1,5 +1,5 @@ import React from "react" -import { actionTypes, reducer, globalScope } from "react-async" +import { ActionTypes, reducer, globalScope } from "react-async" import { Root, Range, Checkbox, Label, Small, Ol, Li, Button } from "./components" @@ -17,14 +17,14 @@ globalScope.__REACT_ASYNC__.devToolsDispatcher = (action, dispatch) => { state.update(action) } switch (action.type) { - case actionTypes.start: + case ActionTypes.start: if (state.intercept) { dispatch({ ...action, payload: undefined }) state.update(action, run) } else run() break - case actionTypes.fulfill: - case actionTypes.reject: + case ActionTypes.fulfill: + case ActionTypes.reject: setTimeout(run, state.latency * 1000) break default: diff --git a/packages/react-async/package.json b/packages/react-async/package.json index d1174e29..c88d35dd 100644 --- a/packages/react-async/package.json +++ b/packages/react-async/package.json @@ -29,22 +29,26 @@ "@pika/pack": { "pipeline": [ [ - "@pika/plugin-standard-pkg", - { - "exclude": [ - "specs.js", - "*.spec.js" - ] - } + "@pika/plugin-ts-standard-pkg" ], [ "@pika/plugin-build-node" ], [ - "@pika/plugin-build-web" + "@pika/plugin-build-web", + { + "entrypoint": [ + "module", + "unpkg", + "jsdelivr" + ] + } + ], + [ + "@pika/plugin-build-umd" ], [ - "@pika/plugin-build-types" + "@pika/plugin-bundle-types" ] ] } diff --git a/packages/react-async/src/Async.spec.js b/packages/react-async/src/Async.spec.js index a666888b..cb00a41d 100644 --- a/packages/react-async/src/Async.spec.js +++ b/packages/react-async/src/Async.spec.js @@ -38,6 +38,7 @@ describe("Async", () => { {value => { one = value + return null }} @@ -47,6 +48,7 @@ describe("Async", () => { {value => { two = value + return null }} @@ -55,6 +57,36 @@ describe("Async", () => { }) }) +describe("rendering context consumers without provider should throw an error", () => { + for (const Component of [ + Async.Initial, + Async.Pending, + Async.Fulfilled, + Async.Rejected, + Async.Settled, + ]) { + test("does not throw an error when rendered within ", () => { + expect(() => + render( + + {() => null} + + ) + ).not.toThrowError() + }) + test("does throw an error when not rendered within ", () => { + // Prevent the thrown error from showing up in test output by mocking console.error. + jest.spyOn(console, "error") + global.console.error.mockImplementation(() => {}) + + expect(() => render({() => null})).toThrowError() + + // Restore the original console.error so other tests will still print errors that occur. + global.console.error.mockRestore() + }) + } +}) + describe("Async.Fulfilled", () => { test("renders only after the promise is resolved", async () => { const promiseFn = () => resolveTo("ok") diff --git a/packages/react-async/src/Async.js b/packages/react-async/src/Async.tsx similarity index 54% rename from packages/react-async/src/Async.js rename to packages/react-async/src/Async.tsx index 0a123706..6f7e8971 100644 --- a/packages/react-async/src/Async.js +++ b/packages/react-async/src/Async.tsx @@ -1,25 +1,109 @@ import React from "react" -import globalScope from "./globalScope" +import globalScope, { MockAbortController } from "./globalScope" import { IfInitial, IfPending, IfFulfilled, IfRejected, IfSettled } from "./helpers" import propTypes from "./propTypes" import { neverSettle, - actionTypes, + ActionTypes, init, dispatchMiddleware, reducer as asyncReducer, } from "./reducer" +import { + AsyncProps, + AsyncState, + InitialChildren, + PendingChildren, + FulfilledChildren, + SettledChildren, + RejectedChildren, + AsyncAction, + ReducerAsyncState, +} from "./types" + +interface InitialProps { + children?: InitialChildren + persist?: boolean +} +interface PendingProps { + children?: PendingChildren + initial?: boolean +} +interface FulfilledProps { + children?: FulfilledChildren + persist?: boolean +} +interface RejectedProps { + children?: RejectedChildren + persist?: boolean +} +interface SettledProps { + children?: SettledChildren + persist?: boolean +} + +class Async extends React.Component, AsyncState> {} +type GenericAsync = typeof Async & { + Initial(props: InitialProps): JSX.Element + Pending(props: PendingProps): JSX.Element + Loading(props: PendingProps): JSX.Element + Fulfilled(props: FulfilledProps): JSX.Element + Resolved(props: FulfilledProps): JSX.Element + Rejected(props: RejectedProps): JSX.Element + Settled(props: SettledProps): JSX.Element +} + +type AsyncConstructor = React.ComponentClass> & { + Initial: React.FC> + Pending: React.FC> + Loading: React.FC> + Fulfilled: React.FC> + Resolved: React.FC> + Rejected: React.FC> + Settled: React.FC> +} /** * createInstance allows you to create instances of Async that are bound to a specific promise. * A unique instance also uses its own React context for better nesting capability. */ -export const createInstance = (defaultOptions = {}, displayName = "Async") => { - const { Consumer, Provider } = React.createContext() +export const createInstance = ( + defaultOptions: AsyncProps = {}, + displayName = "Async" +): AsyncConstructor => { + const { Consumer: UnguardedConsumer, Provider } = React.createContext | undefined>( + undefined + ) + function Consumer({ children }: { children: (value: AsyncState) => React.ReactNode }) { + return ( + + {value => { + if (!value) { + throw new Error( + "this component should only be used within an associated component!" + ) + } + return children(value) + }} + + ) + } - class Async extends React.Component { - constructor(props) { + type Props = AsyncProps + type State = AsyncState + type Constructor = AsyncConstructor + + class Async extends React.Component { + private mounted = false + private counter = 0 + private args: any[] = [] + private promise?: Promise = neverSettle + private abortController: AbortController = new MockAbortController() + private debugLabel?: string + private dispatch: (action: AsyncAction, ...args: any[]) => void + + constructor(props: Props) { super(props) this.start = this.start.bind(this) @@ -35,13 +119,8 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { const promiseFn = props.promiseFn || defaultOptions.promiseFn const initialValue = props.initialValue || defaultOptions.initialValue - this.mounted = false - this.counter = 0 - this.args = [] - this.promise = neverSettle - this.abortController = { abort: () => {} } this.state = { - ...init({ initialValue, promise, promiseFn }), + ...init({ initialValue, promise, promiseFn }), cancel: this.cancel, run: this.run, reload: () => { @@ -56,10 +135,13 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { const { devToolsDispatcher } = globalScope.__REACT_ASYNC__ const _reducer = props.reducer || defaultOptions.reducer const _dispatcher = props.dispatcher || defaultOptions.dispatcher || devToolsDispatcher - const reducer = _reducer + const reducer: ( + state: ReducerAsyncState, + action: AsyncAction + ) => ReducerAsyncState = _reducer ? (state, action) => _reducer(state, action, asyncReducer) : asyncReducer - const dispatch = dispatchMiddleware((action, callback) => { + const dispatch = dispatchMiddleware((action, callback) => { this.setState(state => reducer(state, action), callback) }) this.dispatch = _dispatcher ? action => _dispatcher(action, dispatch, props) : dispatch @@ -72,7 +154,7 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { } } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: Props) { const { watch, watchFn = defaultOptions.watchFn, promise, promiseFn } = this.props if (watch !== prevProps.watch) { if (this.counter) this.cancel() @@ -100,7 +182,7 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { this.mounted = false } - getMeta(meta) { + getMeta(meta?: M) { return { counter: this.counter, promise: this.promise, @@ -109,16 +191,16 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { } } - start(promiseFn) { + start(promiseFn: () => Promise) { if ("AbortController" in globalScope) { this.abortController.abort() - this.abortController = new globalScope.AbortController() + this.abortController = new globalScope.AbortController!() } this.counter++ return (this.promise = new Promise((resolve, reject) => { if (!this.mounted) return const executor = () => promiseFn().then(resolve, reject) - this.dispatch({ type: actionTypes.start, payload: executor, meta: this.getMeta() }) + this.dispatch({ type: ActionTypes.start, payload: executor, meta: this.getMeta() }) })) } @@ -137,7 +219,7 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { } } - run(...args) { + run(...args: any[]) { const deferFn = this.props.deferFn || defaultOptions.deferFn if (deferFn) { this.args = args @@ -154,11 +236,11 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { onCancel && onCancel() this.counter++ this.abortController.abort() - this.mounted && this.dispatch({ type: actionTypes.cancel, meta: this.getMeta() }) + this.mounted && this.dispatch({ type: ActionTypes.cancel, meta: this.getMeta() }) } - onResolve(counter) { - return data => { + onResolve(counter: Number) { + return (data: T) => { if (this.counter === counter) { const onResolve = this.props.onResolve || defaultOptions.onResolve this.setData(data, () => onResolve && onResolve(data)) @@ -167,8 +249,8 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { } } - onReject(counter) { - return error => { + onReject(counter: Number) { + return (error: Error) => { if (this.counter === counter) { const onReject = this.props.onReject || defaultOptions.onReject this.setError(error, () => onReject && onReject(error)) @@ -177,16 +259,16 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { } } - setData(data, callback) { + setData(data: T, callback?: () => void) { this.mounted && - this.dispatch({ type: actionTypes.fulfill, payload: data, meta: this.getMeta() }, callback) + this.dispatch({ type: ActionTypes.fulfill, payload: data, meta: this.getMeta() }, callback) return data } - setError(error, callback) { + setError(error: Error, callback?: () => void) { this.mounted && this.dispatch( - { type: actionTypes.reject, payload: error, error: true, meta: this.getMeta() }, + { type: ActionTypes.reject, payload: error, error: true, meta: this.getMeta() }, callback ) return error @@ -199,7 +281,8 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { throw this.promise } if (typeof children === "function") { - return {children(this.state)} + const render = children as (state: State) => React.ReactNode + return {render(this.state)} } if (children !== undefined && children !== null) { return {children} @@ -208,13 +291,23 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { } } - if (propTypes) Async.propTypes = propTypes.Async + if (propTypes) (Async as React.ComponentClass).propTypes = propTypes.Async - const AsyncInitial = props => {st => } - const AsyncPending = props => {st => } - const AsyncFulfilled = props => {st => } - const AsyncRejected = props => {st => } - const AsyncSettled = props => {st => } + const AsyncInitial: Constructor["Initial"] = props => ( + {st => } + ) + const AsyncPending: Constructor["Pending"] = props => ( + {st => } + ) + const AsyncFulfilled: Constructor["Fulfilled"] = props => ( + {st => } + ) + const AsyncRejected: Constructor["Rejected"] = props => ( + {st => } + ) + const AsyncSettled: Constructor["Settled"] = props => ( + {st => } + ) AsyncInitial.displayName = `${displayName}.Initial` AsyncPending.displayName = `${displayName}.Pending` @@ -222,16 +315,16 @@ export const createInstance = (defaultOptions = {}, displayName = "Async") => { AsyncRejected.displayName = `${displayName}.Rejected` AsyncSettled.displayName = `${displayName}.Settled` - Async.displayName = displayName - Async.Initial = AsyncInitial - Async.Pending = AsyncPending - Async.Loading = AsyncPending // alias - Async.Fulfilled = AsyncFulfilled - Async.Resolved = AsyncFulfilled // alias - Async.Rejected = AsyncRejected - Async.Settled = AsyncSettled - - return Async + return Object.assign(Async, { + displayName: displayName, + Initial: AsyncInitial, + Pending: AsyncPending, + Loading: AsyncPending, // alias + Fulfilled: AsyncFulfilled, + Resolved: AsyncFulfilled, // alias + Rejected: AsyncRejected, + Settled: AsyncSettled, + }) } -export default createInstance() +export default createInstance() as GenericAsync diff --git a/packages/react-async/src/globalScope.js b/packages/react-async/src/globalScope.ts similarity index 72% rename from packages/react-async/src/globalScope.js rename to packages/react-async/src/globalScope.ts index 62316c15..8fd3ffbd 100644 --- a/packages/react-async/src/globalScope.js +++ b/packages/react-async/src/globalScope.ts @@ -1,5 +1,11 @@ /* istanbul ignore file */ +declare type GlobalScope = { + __REACT_ASYNC__: any + AbortController?: typeof AbortController + fetch: typeof fetch +} + /** * Universal global scope object. In the browser this is `self`, in Node.js and React Native it's `global`. * This file is excluded from coverage reporting because these globals are environment-specific so we can't test them all. @@ -9,11 +15,17 @@ const globalScope = (() => { if (typeof global === "object" && global.global === global) return global if (typeof global === "object" && global.GLOBAL === global) return global return {} // fallback that relies on imported modules to be singletons -})() +})() as GlobalScope /** * Globally available object used to connect the DevTools to all React Async instances. */ globalScope.__REACT_ASYNC__ = globalScope.__REACT_ASYNC__ || {} +export const noop = () => {} +export class MockAbortController implements AbortController { + public abort = noop + readonly signal = {} as AbortSignal +} + export default globalScope diff --git a/packages/react-async/src/helpers.js b/packages/react-async/src/helpers.js deleted file mode 100644 index 09232f99..00000000 --- a/packages/react-async/src/helpers.js +++ /dev/null @@ -1,63 +0,0 @@ -import propTypes from "./propTypes" - -const nullify = children => (children === undefined ? null : children) -const renderFn = (children, ...args) => - nullify(typeof children === "function" ? children(...args) : children) - -/** - * Renders only when no promise has started or completed yet. - * - * @prop {Function|Node} children Function (passing state) or React node - * @prop {Object} state React Async state object - * @prop {boolean} persist Show until we have data, even while pending (loading) or when an error occurred - */ -export const IfInitial = ({ children, persist, state = {} }) => - state.isInitial || (persist && !state.data) ? renderFn(children, state) : null - -/** - * Renders only while pending (promise is loading). - * - * @prop {Function|Node} children Function (passing state) or React node - * @prop {Object} state React Async state object - * @prop {boolean} initial Show only on initial load (data is undefined) - */ -export const IfPending = ({ children, initial, state = {} }) => - state.isPending && (!initial || !state.value) ? renderFn(children, state) : null - -/** - * Renders only when promise is resolved. - * - * @prop {Function|Node} children Function (passing data and state) or React node - * @prop {Object} state React Async state object - * @prop {boolean} persist Show old data while pending (promise is loading) - */ -export const IfFulfilled = ({ children, persist, state = {} }) => - state.isFulfilled || (persist && state.data) ? renderFn(children, state.data, state) : null - -/** - * Renders only when promise is rejected. - * - * @prop {Function|Node} children Function (passing error and state) or React node - * @prop {Object} state React Async state object - * @prop {boolean} persist Show old error while pending (promise is loading) - */ -export const IfRejected = ({ children, persist, state = {} }) => - state.isRejected || (persist && state.error) ? renderFn(children, state.error, state) : null - -/** - * Renders only when promise is fulfilled or rejected. - * - * @prop {Function|Node} children Function (passing state) or React node - * @prop {Object} state React Async state object - * @prop {boolean} persist Show old data or error while pending (promise is loading) - */ -export const IfSettled = ({ children, persist, state = {} }) => - state.isSettled || (persist && state.value) ? renderFn(children, state) : null - -if (propTypes) { - IfInitial.propTypes = propTypes.Initial - IfPending.propTypes = propTypes.Pending - IfFulfilled.propTypes = propTypes.Fulfilled - IfRejected.propTypes = propTypes.Rejected - IfSettled.propTypes = propTypes.Settled -} diff --git a/packages/react-async/src/helpers.spec.js b/packages/react-async/src/helpers.spec.js index 24cff478..e1c66d70 100644 --- a/packages/react-async/src/helpers.spec.js +++ b/packages/react-async/src/helpers.spec.js @@ -2,7 +2,7 @@ import "@testing-library/jest-dom/extend-expect" import React from "react" import { render, fireEvent, cleanup } from "@testing-library/react" import Async, { IfInitial, IfPending, IfFulfilled, IfRejected, IfSettled } from "./index" -import { resolveIn, resolveTo, rejectTo } from "./specs" +import { resolveIn, resolveTo, rejectTo, sleep } from "./specs" afterEach(cleanup) @@ -84,6 +84,15 @@ describe("IfFulfilled", () => { await findByText("outer inner") expect(queryByText("outer inner")).toBeInTheDocument() }) + test("renders nothing if missing state", () => { + const { queryByText } = render(Test) + expect(queryByText("Test")).not.toBeInTheDocument() + }) + test("renders without children", async () => { + const promiseFn = () => resolveTo("ok") + render({state => }) + await sleep(0) + }) }) describe("IfPending", () => { @@ -103,6 +112,10 @@ describe("IfPending", () => { await findByText("done") expect(queryByText("pending")).toBeNull() }) + test("renders nothing if missing state", () => { + const { queryByText } = render(Test) + expect(queryByText("Test")).not.toBeInTheDocument() + }) }) describe("IfInitial", () => { @@ -128,6 +141,10 @@ describe("IfInitial", () => { await findByText("done") expect(queryByText("pending")).toBeNull() }) + test("renders nothing if missing state", () => { + const { queryByText } = render(Test) + expect(queryByText("Test")).not.toBeInTheDocument() + }) }) describe("IfRejected", () => { @@ -142,6 +159,10 @@ describe("IfRejected", () => { await findByText("err") expect(queryByText("err")).toBeInTheDocument() }) + test("renders nothing if missing state", () => { + const { queryByText } = render(Test) + expect(queryByText("Test")).not.toBeInTheDocument() + }) }) describe("IfSettled", () => { @@ -191,4 +212,8 @@ describe("IfSettled", () => { fireEvent.click(getByText("reload")) await findByText("loading") }) + test("renders nothing if missing state", () => { + const { queryByText } = render(Test) + expect(queryByText("Test")).not.toBeInTheDocument() + }) }) diff --git a/packages/react-async/src/helpers.tsx b/packages/react-async/src/helpers.tsx new file mode 100644 index 00000000..9b3d06a6 --- /dev/null +++ b/packages/react-async/src/helpers.tsx @@ -0,0 +1,136 @@ +import React from "react" +import propTypes from "./propTypes" + +import { + InitialChildren, + PendingChildren, + FulfilledChildren, + RejectedChildren, + SettledChildren, + AsyncState, + AbstractState, + AsyncInitial, + AsyncFulfilled, + AsyncPending, + AsyncRejected, +} from "./types" + +/** + * Due to https://github.com/microsoft/web-build-tools/issues/1050, we need + * AbstractState imported in this file, even though it is only used implicitly. + * This _uses_ AbstractState so it is not accidentally removed by someone. + */ +declare type ImportWorkaround = + | AbstractState + | AsyncInitial + | AsyncFulfilled + | AsyncPending + | AsyncRejected + +type ChildrenFn = (...args: any[]) => React.ReactNode +const renderFn = (children: React.ReactNode | ChildrenFn, ...args: any[]) => { + if (typeof children === "function") { + const render = children as ChildrenFn + return render(...args) + } + return children +} + +/** + * Renders only when no promise has started or completed yet. + * + * @prop {Function|Node} children Function (passing state) or React node + * @prop {Object} state React Async state object + * @prop {boolean} persist Show until we have data, even while pending (loading) or when an error occurred + */ +export const IfInitial = ({ + children, + persist, + state = {} as any, +}: { + children?: InitialChildren + persist?: boolean + state: AsyncState +}) => <>{state.isInitial || (persist && !state.data) ? renderFn(children, state) : null} + +/** + * Renders only while pending (promise is loading). + * + * @prop {Function|Node} children Function (passing state) or React node + * @prop {Object} state React Async state object + * @prop {boolean} initial Show only on initial load (data is undefined) + */ +export const IfPending = ({ + children, + initial, + state = {} as any, +}: { + children?: PendingChildren + initial?: boolean + state: AsyncState +}) => <>{state.isPending && (!initial || !state.value) ? renderFn(children, state) : null} + +/** + * Renders only when promise is resolved. + * + * @prop {Function|Node} children Function (passing data and state) or React node + * @prop {Object} state React Async state object + * @prop {boolean} persist Show old data while pending (promise is loading) + */ +export const IfFulfilled = ({ + children, + persist, + state = {} as any, +}: { + children?: FulfilledChildren + persist?: boolean + state: AsyncState +}) => ( + <>{state.isFulfilled || (persist && state.data) ? renderFn(children, state.data, state) : null} +) + +/** + * Renders only when promise is rejected. + * + * @prop {Function|Node} children Function (passing error and state) or React node + * @prop {Object} state React Async state object + * @prop {boolean} persist Show old error while pending (promise is loading) + */ +export const IfRejected = ({ + children, + persist, + state = {} as any, +}: { + children?: RejectedChildren + persist?: boolean + state: AsyncState +}) => ( + <> + {state.isRejected || (persist && state.error) ? renderFn(children, state.error, state) : null} + +) + +/** + * Renders only when promise is fulfilled or rejected. + * + * @prop {Function|Node} children Function (passing state) or React node + * @prop {Object} state React Async state object + * @prop {boolean} persist Show old data or error while pending (promise is loading) + */ +export const IfSettled = ({ + children, + persist, + state = {} as any, +}: { + children?: SettledChildren + persist?: boolean + state: AsyncState +}) => <>{state.isSettled || (persist && state.value) ? renderFn(children, state) : null} + +if (propTypes) { + IfInitial.propTypes = propTypes.Initial + IfPending.propTypes = propTypes.Pending + IfFulfilled.propTypes = propTypes.Fulfilled + IfRejected.propTypes = propTypes.Rejected + IfSettled.propTypes = propTypes.Settled +} diff --git a/packages/react-async/src/index.d.ts b/packages/react-async/src/index.d.ts deleted file mode 100644 index 3e649c05..00000000 --- a/packages/react-async/src/index.d.ts +++ /dev/null @@ -1,244 +0,0 @@ -import * as React from "react" - -export type AsyncChildren = ((state: AsyncState) => React.ReactNode) | React.ReactNode -export type InitialChildren = ((state: AsyncInitial) => React.ReactNode) | React.ReactNode -export type PendingChildren = ((state: AsyncPending) => React.ReactNode) | React.ReactNode -export type FulfilledChildren = - | ((data: T, state: AsyncFulfilled) => React.ReactNode) - | React.ReactNode -export type RejectedChildren = - | ((error: Error, state: AsyncRejected) => React.ReactNode) - | React.ReactNode -export type SettledChildren = - | ((state: AsyncFulfilled | AsyncRejected) => React.ReactNode) - | React.ReactNode - -export type PromiseFn = (props: AsyncProps, controller: AbortController) => Promise -export type DeferFn = ( - args: any[], - props: AsyncProps, - controller: AbortController -) => Promise - -interface AbstractAction { - type: string - meta: { counter: number; [meta: string]: any } -} -export type Start = AbstractAction & { type: "start"; payload: () => Promise } -export type Cancel = AbstractAction & { type: "cancel" } -export type Fulfill = AbstractAction & { type: "fulfill"; payload: T } -export type Reject = AbstractAction & { type: "reject"; payload: Error; error: true } -export type AsyncAction = Start | Cancel | Fulfill | Reject - -export interface AsyncOptions { - promise?: Promise - promiseFn?: PromiseFn - deferFn?: DeferFn - watch?: any - watchFn?: (props: AsyncProps, prevProps: AsyncProps) => any - initialValue?: T - onResolve?: (data: T) => void - onReject?: (error: Error) => void - reducer?: ( - state: AsyncState, - action: AsyncAction, - internalReducer: (state: AsyncState, action: AsyncAction) => AsyncState - ) => AsyncState - dispatcher?: ( - action: AsyncAction, - internalDispatch: (action: AsyncAction) => void, - props: AsyncProps - ) => void - debugLabel?: string - suspense?: boolean - [prop: string]: any -} - -export interface AsyncProps extends AsyncOptions { - children?: AsyncChildren -} - -interface AbstractState { - initialValue?: T | Error - counter: number - promise: Promise - run: (...args: any[]) => void - reload: () => void - cancel: () => void - setData: (data: T, callback?: () => void) => T - setError: (error: Error, callback?: () => void) => Error -} - -export type AsyncInitial = AbstractState & { - initialValue?: undefined - data: undefined - error: undefined - value: undefined - startedAt: undefined - finishedAt: undefined - status: "initial" - isInitial: false - isPending: false - isLoading: false - isFulfilled: false - isResolved: false - isRejected: false - isSettled: false -} -export type AsyncPending = AbstractState & { - data: T | undefined - error: Error | undefined - value: T | Error | undefined - startedAt: Date - finishedAt: undefined - status: "pending" - isInitial: false - isPending: true - isLoading: true - isFulfilled: false - isResolved: false - isRejected: false - isSettled: false -} -export type AsyncFulfilled = AbstractState & { - data: T - error: undefined - value: T - startedAt: Date - finishedAt: Date - status: "fulfilled" - isInitial: false - isPending: false - isLoading: false - isFulfilled: true - isResolved: true - isRejected: false - isSettled: true -} -export type AsyncRejected = AbstractState & { - data: T | undefined - error: Error - value: Error - startedAt: Date - finishedAt: Date - status: "rejected" - isInitial: false - isPending: false - isLoading: false - isFulfilled: false - isResolved: false - isRejected: true - isSettled: true -} -export type AsyncState = AsyncInitial | AsyncPending | AsyncFulfilled | AsyncRejected - -export class Async extends React.Component, AsyncState> {} - -export namespace Async { - export function Initial(props: { - children?: InitialChildren - persist?: boolean - }): JSX.Element - export function Pending(props: { - children?: PendingChildren - initial?: boolean - }): JSX.Element - export function Loading(props: { - children?: PendingChildren - initial?: boolean - }): JSX.Element - export function Fulfilled(props: { - children?: FulfilledChildren - persist?: boolean - }): JSX.Element - export function Resolved(props: { - children?: FulfilledChildren - persist?: boolean - }): JSX.Element - export function Rejected(props: { - children?: RejectedChildren - persist?: boolean - }): JSX.Element - export function Settled(props: { - children?: SettledChildren - persist?: boolean - }): JSX.Element -} - -export function createInstance( - defaultOptions?: AsyncProps, - displayName?: string -): (new () => Async) & { - Initial(props: { children?: InitialChildren; persist?: boolean }): JSX.Element - Pending(props: { children?: PendingChildren; initial?: boolean }): JSX.Element - Loading(props: { children?: PendingChildren; initial?: boolean }): JSX.Element - Fulfilled(props: { children?: FulfilledChildren; persist?: boolean }): JSX.Element - Resolved(props: { children?: FulfilledChildren; persist?: boolean }): JSX.Element - Rejected(props: { children?: RejectedChildren; persist?: boolean }): JSX.Element - Settled(props: { children?: SettledChildren; persist?: boolean }): JSX.Element -} - -export function IfInitial(props: { - children?: InitialChildren - persist?: boolean - state: AsyncState -}): JSX.Element -export function IfPending(props: { - children?: PendingChildren - initial?: boolean - state: AsyncState -}): JSX.Element -export function IfFulfilled(props: { - children?: FulfilledChildren - persist?: boolean - state: AsyncState -}): JSX.Element -export function IfRejected(props: { - children?: RejectedChildren - persist?: boolean - state: AsyncState -}): JSX.Element -export function IfSettled(props: { - children?: SettledChildren - persist?: boolean - state: AsyncState -}): JSX.Element - -export function useAsync( - arg1: AsyncOptions | PromiseFn, - arg2?: AsyncOptions -): AsyncState - -export interface FetchOptions extends AsyncOptions { - defer?: boolean - json?: boolean -} - -export function useFetch( - input: RequestInfo, - init?: RequestInit, - options?: FetchOptions -): AsyncInitialWithout<"run", T> & FetchRun - -// unfortunately, we cannot just omit K from AsyncInitial as that would unbox the Discriminated Union -type AsyncInitialWithout, T> = - | Omit, K> - | Omit, K> - | Omit, K> - | Omit, K> - -type OverrideParams = { resource?: RequestInfo } & Partial - -type FetchRun = { - run(overrideParams: (params?: OverrideParams) => OverrideParams): void - run(overrideParams: OverrideParams): void - run(ignoredEvent: React.SyntheticEvent): void - run(ignoredEvent: Event): void - run(): void -} - -export class FetchError extends Error { - response: Response -} - -export default Async diff --git a/packages/react-async/src/index.js b/packages/react-async/src/index.ts similarity index 59% rename from packages/react-async/src/index.js rename to packages/react-async/src/index.ts index e9409c85..e35a81b0 100644 --- a/packages/react-async/src/index.js +++ b/packages/react-async/src/index.ts @@ -1,8 +1,9 @@ import Async from "./Async" export { default as Async, createInstance } from "./Async" -export { default as useAsync, useFetch, FetchError } from "./useAsync" +export * from "./types" +export { default as useAsync, useFetch, FetchOptions, FetchError } from "./useAsync" export default Async -export { statusTypes } from "./status" +export { StatusTypes } from "./status" export { default as globalScope } from "./globalScope" export * from "./helpers" export * from "./reducer" diff --git a/packages/react-async/src/propTypes.js b/packages/react-async/src/propTypes.ts similarity index 90% rename from packages/react-async/src/propTypes.js rename to packages/react-async/src/propTypes.ts index a44a8fe8..b6d837fe 100644 --- a/packages/react-async/src/propTypes.js +++ b/packages/react-async/src/propTypes.ts @@ -47,27 +47,27 @@ export default PropTypes && { suspense: PropTypes.bool, }, Initial: { - children: childrenFn.isRequired, + children: childrenFn, state: stateObject.isRequired, persist: PropTypes.bool, }, Pending: { - children: childrenFn.isRequired, + children: childrenFn, state: stateObject.isRequired, initial: PropTypes.bool, }, Fulfilled: { - children: childrenFn.isRequired, + children: childrenFn, state: stateObject.isRequired, persist: PropTypes.bool, }, Rejected: { - children: childrenFn.isRequired, + children: childrenFn, state: stateObject.isRequired, persist: PropTypes.bool, }, Settled: { - children: childrenFn.isRequired, + children: childrenFn, state: stateObject.isRequired, persist: PropTypes.bool, }, diff --git a/packages/react-async/src/reducer.js b/packages/react-async/src/reducer.js deleted file mode 100644 index c377b486..00000000 --- a/packages/react-async/src/reducer.js +++ /dev/null @@ -1,95 +0,0 @@ -import { getInitialStatus, getIdleStatus, getStatusProps, statusTypes } from "./status" - -// This exists to make sure we don't hold any references to user-provided functions -// The way NeverSettle extends from Promise is complicated, but can't be done differently because Babel doesn't support -// extending built-in classes. See https://babeljs.io/docs/en/caveats/#classes -function NeverSettle() {} -/* istanbul ignore next */ -if (Object.setPrototypeOf) { - Object.setPrototypeOf(NeverSettle, Promise) -} else { - NeverSettle.__proto__ = Promise -} -NeverSettle.prototype = Object.assign(Object.create(Promise.prototype), { - finally() { - return this - }, - catch() { - return this - }, - then() { - return this - }, -}) - -export const neverSettle = new NeverSettle() - -export const actionTypes = { - start: "start", - cancel: "cancel", - fulfill: "fulfill", - reject: "reject", -} - -export const init = ({ initialValue, promise, promiseFn }) => ({ - initialValue, - data: initialValue instanceof Error ? undefined : initialValue, - error: initialValue instanceof Error ? initialValue : undefined, - value: initialValue, - startedAt: promise || promiseFn ? new Date() : undefined, - finishedAt: initialValue ? new Date() : undefined, - ...getStatusProps(getInitialStatus(initialValue, promise || promiseFn)), - counter: 0, - promise: neverSettle, -}) - -export const reducer = (state, { type, payload, meta }) => { - switch (type) { - case actionTypes.start: - return { - ...state, - startedAt: new Date(), - finishedAt: undefined, - ...getStatusProps(statusTypes.pending), - counter: meta.counter, - promise: meta.promise, - } - case actionTypes.cancel: - return { - ...state, - startedAt: undefined, - finishedAt: undefined, - ...getStatusProps(getIdleStatus(state.error || state.data)), - counter: meta.counter, - promise: meta.promise, - } - case actionTypes.fulfill: - return { - ...state, - data: payload, - value: payload, - error: undefined, - finishedAt: new Date(), - ...getStatusProps(statusTypes.fulfilled), - promise: meta.promise, - } - case actionTypes.reject: - return { - ...state, - error: payload, - value: payload, - finishedAt: new Date(), - ...getStatusProps(statusTypes.rejected), - promise: meta.promise, - } - default: - return state - } -} - -export const dispatchMiddleware = dispatch => (action, ...args) => { - dispatch(action, ...args) - if (action.type === actionTypes.start && typeof action.payload === "function") { - action.payload() - } -} diff --git a/packages/react-async/src/reducer.ts b/packages/react-async/src/reducer.ts new file mode 100644 index 00000000..cd9448e5 --- /dev/null +++ b/packages/react-async/src/reducer.ts @@ -0,0 +1,127 @@ +import { getInitialStatus, getIdleStatus, getStatusProps, StatusTypes } from "./status" +import { + PromiseFn, + AsyncAction, + AsyncPending, + AsyncFulfilled, + AsyncRejected, + AsyncInitial, + AbstractState, + ReducerAsyncState, + ReducerBaseState, +} from "./types" + +/** + * Due to https://github.com/microsoft/web-build-tools/issues/1050, we need + * AbstractState imported in this file, even though it is only used implicitly. + * This _uses_ AbstractState so it is not accidentally removed by someone. + */ +declare type ImportWorkaround = AbstractState + +// This exists to make sure we don't hold any references to user-provided functions +// The way NeverSettle extends from Promise is complicated, but can't be done differently because Babel doesn't support +// extending built-in classes. See https://babeljs.io/docs/en/caveats/#classes +const NeverSettle = (function() {} as unknown) as { new (): Promise } +/* istanbul ignore next */ +if (Object.setPrototypeOf) { + Object.setPrototypeOf(NeverSettle, Promise) +} else { + ;(NeverSettle as any).__proto__ = Promise +} +NeverSettle.prototype = Object.assign(Object.create(Promise.prototype), { + finally() { + return this + }, + catch() { + return this + }, + then() { + return this + }, +}) + +export const neverSettle = new NeverSettle() + +export enum ActionTypes { + start = "start", + cancel = "cancel", + fulfill = "fulfill", + reject = "reject", +} + +export const init = ({ + initialValue, + promise, + promiseFn, +}: { + initialValue?: Error | T + promise?: Promise + promiseFn?: PromiseFn +}) => + ({ + initialValue, + data: initialValue instanceof Error ? undefined : initialValue, + error: initialValue instanceof Error ? initialValue : undefined, + value: initialValue, + startedAt: promise || promiseFn ? new Date() : undefined, + finishedAt: initialValue ? new Date() : undefined, + ...getStatusProps(getInitialStatus(initialValue, promise || promiseFn)), + counter: 0, + promise: neverSettle, + } as ReducerAsyncState) + +export const reducer = (state: ReducerAsyncState, action: AsyncAction) => { + switch (action.type) { + case ActionTypes.start: + return { + ...state, + startedAt: new Date(), + finishedAt: undefined, + ...getStatusProps(StatusTypes.pending), + counter: action.meta.counter, + promise: action.meta.promise, + } as AsyncPending> + case ActionTypes.cancel: + return { + ...state, + startedAt: undefined, + finishedAt: undefined, + ...getStatusProps(getIdleStatus(state.error || state.data)), + counter: action.meta.counter, + promise: action.meta.promise, + } as + | AsyncInitial> + | AsyncFulfilled> + | AsyncRejected> + case ActionTypes.fulfill: + return { + ...state, + data: action.payload, + value: action.payload, + error: undefined, + finishedAt: new Date(), + ...getStatusProps(StatusTypes.fulfilled), + promise: action.meta.promise, + } as AsyncFulfilled> + case ActionTypes.reject: + return { + ...state, + error: action.payload, + value: action.payload, + finishedAt: new Date(), + ...getStatusProps(StatusTypes.rejected), + promise: action.meta.promise, + } as AsyncRejected> + default: + return state + } +} + +export const dispatchMiddleware = ( + dispatch: (action: AsyncAction, ...args: any[]) => void +) => (action: AsyncAction, ...args: unknown[]) => { + dispatch(action, ...args) + if (action.type === ActionTypes.start && typeof action.payload === "function") { + action.payload() + } +} diff --git a/packages/react-async/src/status.js b/packages/react-async/src/status.js deleted file mode 100644 index 0af8fd52..00000000 --- a/packages/react-async/src/status.js +++ /dev/null @@ -1,30 +0,0 @@ -export const statusTypes = { - initial: "initial", - pending: "pending", - fulfilled: "fulfilled", - rejected: "rejected", -} - -export const getInitialStatus = (value, promise) => { - if (value instanceof Error) return statusTypes.rejected - if (value !== undefined) return statusTypes.fulfilled - if (promise) return statusTypes.pending - return statusTypes.initial -} - -export const getIdleStatus = value => { - if (value instanceof Error) return statusTypes.rejected - if (value !== undefined) return statusTypes.fulfilled - return statusTypes.initial -} - -export const getStatusProps = status => ({ - status, - isInitial: status === statusTypes.initial, - isPending: status === statusTypes.pending, - isLoading: status === statusTypes.pending, // alias - isFulfilled: status === statusTypes.fulfilled, - isResolved: status === statusTypes.fulfilled, // alias - isRejected: status === statusTypes.rejected, - isSettled: status === statusTypes.fulfilled || status === statusTypes.rejected, -}) diff --git a/packages/react-async/src/status.spec.js b/packages/react-async/src/status.spec.js index 16d9c7b1..f2f6fe6a 100644 --- a/packages/react-async/src/status.spec.js +++ b/packages/react-async/src/status.spec.js @@ -2,31 +2,31 @@ import "@testing-library/jest-dom/extend-expect" -import { getInitialStatus, getIdleStatus, statusTypes } from "./status" +import { getInitialStatus, getIdleStatus, StatusTypes } from "./status" describe("getInitialStatus", () => { test("returns 'initial' when given an undefined value", () => { - expect(getInitialStatus(undefined)).toEqual(statusTypes.initial) + expect(getInitialStatus(undefined)).toEqual(StatusTypes.initial) }) test("returns 'pending' when given only a promise", () => { - expect(getInitialStatus(undefined, Promise.resolve("foo"))).toEqual(statusTypes.pending) + expect(getInitialStatus(undefined, Promise.resolve("foo"))).toEqual(StatusTypes.pending) }) test("returns 'rejected' when given an Error value", () => { - expect(getInitialStatus(new Error("oops"))).toEqual(statusTypes.rejected) + expect(getInitialStatus(new Error("oops"))).toEqual(StatusTypes.rejected) }) test("returns 'fulfilled' when given any other value", () => { - expect(getInitialStatus(null)).toEqual(statusTypes.fulfilled) + expect(getInitialStatus(null)).toEqual(StatusTypes.fulfilled) }) }) describe("getIdleStatus", () => { test("returns 'initial' when given an undefined value", () => { - expect(getIdleStatus(undefined)).toEqual(statusTypes.initial) + expect(getIdleStatus(undefined)).toEqual(StatusTypes.initial) }) test("returns 'rejected' when given an Error value", () => { - expect(getIdleStatus(new Error("oops"))).toEqual(statusTypes.rejected) + expect(getIdleStatus(new Error("oops"))).toEqual(StatusTypes.rejected) }) test("returns 'fulfilled' when given any other value", () => { - expect(getIdleStatus(null)).toEqual(statusTypes.fulfilled) + expect(getIdleStatus(null)).toEqual(StatusTypes.fulfilled) }) }) diff --git a/packages/react-async/src/status.ts b/packages/react-async/src/status.ts new file mode 100644 index 00000000..9db4d3f4 --- /dev/null +++ b/packages/react-async/src/status.ts @@ -0,0 +1,32 @@ +import { PromiseFn } from "./types" + +export enum StatusTypes { + initial = "initial", + pending = "pending", + fulfilled = "fulfilled", + rejected = "rejected", +} + +export const getInitialStatus = (value?: T | Error, promise?: Promise | PromiseFn) => { + if (value instanceof Error) return StatusTypes.rejected + if (value !== undefined) return StatusTypes.fulfilled + if (promise) return StatusTypes.pending + return StatusTypes.initial +} + +export const getIdleStatus = (value?: T | Error) => { + if (value instanceof Error) return StatusTypes.rejected + if (value !== undefined) return StatusTypes.fulfilled + return StatusTypes.initial +} + +export const getStatusProps = (status: StatusTypes) => ({ + status, + isInitial: status === StatusTypes.initial, + isPending: status === StatusTypes.pending, + isLoading: status === StatusTypes.pending, // alias + isFulfilled: status === StatusTypes.fulfilled, + isResolved: status === StatusTypes.fulfilled, // alias + isRejected: status === StatusTypes.rejected, + isSettled: status === StatusTypes.fulfilled || status === StatusTypes.rejected, +}) diff --git a/packages/react-async/src/types.ts b/packages/react-async/src/types.ts new file mode 100644 index 00000000..20cd5d84 --- /dev/null +++ b/packages/react-async/src/types.ts @@ -0,0 +1,147 @@ +import React from "react" + +export type AsyncChildren = ((state: AsyncState) => React.ReactNode) | React.ReactNode +export type InitialChildren = ((state: AsyncInitial) => React.ReactNode) | React.ReactNode +export type PendingChildren = ((state: AsyncPending) => React.ReactNode) | React.ReactNode +export type FulfilledChildren = + | ((data: T, state: AsyncFulfilled) => React.ReactNode) + | React.ReactNode +export type RejectedChildren = + | ((error: Error, state: AsyncRejected) => React.ReactNode) + | React.ReactNode +export type SettledChildren = + | ((state: AsyncFulfilled | AsyncRejected) => React.ReactNode) + | React.ReactNode + +export type PromiseFn = (props: AsyncProps, controller: AbortController) => Promise +export type DeferFn = ( + args: any[], + props: AsyncProps, + controller: AbortController +) => Promise + +export interface AbstractAction { + type: string + meta: { counter: number; [meta: string]: any } +} +export type Meta = AbstractAction["meta"] + +export type Start = AbstractAction & { type: "start"; payload: () => Promise } +export type Cancel = AbstractAction & { type: "cancel" } +export type Fulfill = AbstractAction & { type: "fulfill"; payload: T } +export type Reject = AbstractAction & { type: "reject"; payload: Error; error: true } +export type AsyncAction = Start | Cancel | Fulfill | Reject + +export interface AsyncOptions { + promise?: Promise + promiseFn?: PromiseFn + deferFn?: DeferFn + watch?: any + watchFn?: (props: AsyncProps, prevProps: AsyncProps) => any + initialValue?: T + onResolve?: (data: T) => void + onReject?: (error: Error) => void + reducer?: ( + state: ReducerAsyncState, + action: AsyncAction, + internalReducer: (state: ReducerAsyncState, action: AsyncAction) => ReducerAsyncState + ) => AsyncState + dispatcher?: ( + action: AsyncAction, + internalDispatch: (action: AsyncAction) => void, + props: AsyncProps + ) => void + debugLabel?: string + [prop: string]: any +} + +export interface AsyncProps extends AsyncOptions { + children?: AsyncChildren +} + +export interface AbstractState { + initialValue?: T | Error + counter: number + promise: Promise + run: (...args: any[]) => void + reload: () => void + cancel: () => void + setData: (data: T, callback?: () => void) => T + setError: (error: Error, callback?: () => void) => Error +} + +export type AsyncInitial> = S & { + initialValue?: undefined + data: undefined + error: undefined + value: undefined + startedAt: undefined + finishedAt: undefined + status: "initial" + isInitial: false + isPending: false + isLoading: false + isFulfilled: false + isResolved: false + isRejected: false + isSettled: false +} +export type AsyncPending> = S & { + data: T | undefined + error: Error | undefined + value: T | Error | undefined + startedAt: Date + finishedAt: undefined + status: "pending" + isInitial: false + isPending: true + isLoading: true + isFulfilled: false + isResolved: false + isRejected: false + isSettled: false +} +export type AsyncFulfilled> = S & { + data: T + error: undefined + value: T + startedAt: Date + finishedAt: Date + status: "fulfilled" + isInitial: false + isPending: false + isLoading: false + isFulfilled: true + isResolved: true + isRejected: false + isSettled: true +} +export type AsyncRejected> = S & { + data: T | undefined + error: Error + value: Error + startedAt: Date + finishedAt: Date + status: "rejected" + isInitial: false + isPending: false + isLoading: false + isFulfilled: false + isResolved: false + isRejected: true + isSettled: true +} + +type BaseAsyncState = + | AsyncInitial + | AsyncPending + | AsyncFulfilled + | AsyncRejected + +export type ReducerBaseState = Omit< + AbstractState, + "run" | "reload" | "cancel" | "setData" | "setError" +> +export type ReducerAsyncState = BaseAsyncState> + +export type AsyncState = AbstractState> = BaseAsyncState diff --git a/packages/react-async/src/useAsync.js b/packages/react-async/src/useAsync.js deleted file mode 100644 index a43c31c5..00000000 --- a/packages/react-async/src/useAsync.js +++ /dev/null @@ -1,227 +0,0 @@ -import { useCallback, useDebugValue, useEffect, useMemo, useRef, useReducer } from "react" - -import globalScope from "./globalScope" -import { - neverSettle, - actionTypes, - init, - dispatchMiddleware, - reducer as asyncReducer, -} from "./reducer" - -const noop = () => {} - -const useAsync = (arg1, arg2) => { - const options = typeof arg1 === "function" ? { ...arg2, promiseFn: arg1 } : arg1 - - const counter = useRef(0) - const isMounted = useRef(true) - const lastArgs = useRef(undefined) - const lastOptions = useRef(undefined) - const lastPromise = useRef(neverSettle) - const abortController = useRef({ abort: noop }) - - const { devToolsDispatcher } = globalScope.__REACT_ASYNC__ - const { reducer, dispatcher = devToolsDispatcher } = options - const [state, _dispatch] = useReducer( - reducer ? (state, action) => reducer(state, action, asyncReducer) : asyncReducer, - options, - init - ) - const dispatch = useCallback( - dispatcher - ? action => dispatcher(action, dispatchMiddleware(_dispatch), lastOptions.current) - : dispatchMiddleware(_dispatch), - [dispatcher] - ) - - const { debugLabel } = options - const getMeta = useCallback( - meta => ({ counter: counter.current, promise: lastPromise.current, debugLabel, ...meta }), - [debugLabel] - ) - - const setData = useCallback( - (data, callback = noop) => { - if (isMounted.current) { - dispatch({ type: actionTypes.fulfill, payload: data, meta: getMeta() }) - callback() - } - return data - }, - [dispatch, getMeta] - ) - - const setError = useCallback( - (error, callback = noop) => { - if (isMounted.current) { - dispatch({ type: actionTypes.reject, payload: error, error: true, meta: getMeta() }) - callback() - } - return error - }, - [dispatch, getMeta] - ) - - const { onResolve, onReject } = options - const handleResolve = useCallback( - count => data => count === counter.current && setData(data, () => onResolve && onResolve(data)), - [setData, onResolve] - ) - const handleReject = useCallback( - count => err => count === counter.current && setError(err, () => onReject && onReject(err)), - [setError, onReject] - ) - - const start = useCallback( - promiseFn => { - if ("AbortController" in globalScope) { - abortController.current.abort() - abortController.current = new globalScope.AbortController() - } - counter.current++ - return (lastPromise.current = new Promise((resolve, reject) => { - if (!isMounted.current) return - const executor = () => promiseFn().then(resolve, reject) - dispatch({ type: actionTypes.start, payload: executor, meta: getMeta() }) - })) - }, - [dispatch, getMeta] - ) - - const { promise, promiseFn, initialValue } = options - const load = useCallback(() => { - const isPreInitialized = initialValue && counter.current === 0 - if (promise) { - start(() => promise) - .then(handleResolve(counter.current)) - .catch(handleReject(counter.current)) - } else if (promiseFn && !isPreInitialized) { - start(() => promiseFn(lastOptions.current, abortController.current)) - .then(handleResolve(counter.current)) - .catch(handleReject(counter.current)) - } - }, [start, promise, promiseFn, initialValue, handleResolve, handleReject]) - - const { deferFn } = options - const run = useCallback( - (...args) => { - if (deferFn) { - lastArgs.current = args - start(() => deferFn(args, lastOptions.current, abortController.current)) - .then(handleResolve(counter.current)) - .catch(handleReject(counter.current)) - } - }, - [start, deferFn, handleResolve, handleReject] - ) - - const reload = useCallback(() => { - lastArgs.current ? run(...lastArgs.current) : load() - }, [run, load]) - - const { onCancel } = options - const cancel = useCallback(() => { - onCancel && onCancel() - counter.current++ - abortController.current.abort() - isMounted.current && dispatch({ type: actionTypes.cancel, meta: getMeta() }) - }, [onCancel, dispatch, getMeta]) - - /* These effects should only be triggered on changes to specific props */ - /* eslint-disable react-hooks/exhaustive-deps */ - const { watch, watchFn } = options - useEffect(() => { - if (watchFn && lastOptions.current && watchFn(options, lastOptions.current)) load() - }) - useEffect(() => { - lastOptions.current = options - }, [options]) - useEffect(() => { - if (counter.current) cancel() - if (promise || promiseFn) load() - }, [promise, promiseFn, watch]) - useEffect(() => () => (isMounted.current = false), []) - useEffect(() => () => cancel(), []) - /* eslint-enable react-hooks/exhaustive-deps */ - - useDebugValue(state, ({ status }) => `[${counter.current}] ${status}`) - - if (options.suspense && state.isPending && lastPromise.current !== neverSettle) { - // Rely on Suspense to handle the loading state - throw lastPromise.current - } - - return useMemo( - () => ({ - ...state, - run, - reload, - cancel, - setData, - setError, - }), - [state, run, reload, cancel, setData, setError] - ) -} - -export class FetchError extends Error { - constructor(response) { - super(`${response.status} ${response.statusText}`) - /* istanbul ignore next */ - if (Object.setPrototypeOf) { - // Not available in IE 10, but can be polyfilled - Object.setPrototypeOf(this, FetchError.prototype) - } - this.response = response - } -} - -const parseResponse = (accept, json) => res => { - if (!res.ok) return Promise.reject(new FetchError(res)) - if (typeof json === "boolean") return json ? res.json() : res - return accept === "application/json" ? res.json() : res -} - -const useAsyncFetch = (resource, init, { defer, json, ...options } = {}) => { - const method = resource.method || (init && init.method) - const headers = resource.headers || (init && init.headers) || {} - const accept = headers["Accept"] || headers["accept"] || (headers.get && headers.get("accept")) - const doFetch = (resource, init) => - globalScope.fetch(resource, init).then(parseResponse(accept, json)) - const isDefer = - typeof defer === "boolean" ? defer : ["POST", "PUT", "PATCH", "DELETE"].indexOf(method) !== -1 - const fn = isDefer ? "deferFn" : "promiseFn" - const identity = JSON.stringify({ resource, init, isDefer }) - const state = useAsync({ - ...options, - [fn]: useCallback( - (arg1, arg2, arg3) => { - const [override, signal] = isDefer ? [arg1[0], arg3.signal] : [undefined, arg2.signal] - const isEvent = typeof override === "object" && "preventDefault" in override - if (!override || isEvent) { - return doFetch(resource, { signal, ...init }) - } - if (typeof override === "function") { - const { resource: runResource, ...runInit } = override({ resource, signal, ...init }) - return doFetch(runResource || resource, { signal, ...runInit }) - } - const { resource: runResource, ...runInit } = override - return doFetch(runResource || resource, { signal, ...init, ...runInit }) - }, - [identity] // eslint-disable-line react-hooks/exhaustive-deps - ), - }) - useDebugValue(state, ({ counter, status }) => `[${counter}] ${status}`) - return state -} - -/* istanbul ignore next */ -const unsupported = () => { - throw new Error( - "useAsync requires React v16.8 or up. Upgrade your React version or use the component instead." - ) -} - -export default useEffect ? useAsync : unsupported -export const useFetch = useEffect ? useAsyncFetch : unsupported diff --git a/packages/react-async/src/useAsync.tsx b/packages/react-async/src/useAsync.tsx new file mode 100644 index 00000000..dc86bfb9 --- /dev/null +++ b/packages/react-async/src/useAsync.tsx @@ -0,0 +1,340 @@ +import React, { useCallback, useDebugValue, useEffect, useMemo, useRef, useReducer } from "react" + +import globalScope, { MockAbortController, noop } from "./globalScope" +import { + neverSettle, + ActionTypes, + init, + dispatchMiddleware, + reducer as asyncReducer, +} from "./reducer" + +import { + AsyncOptions, + AsyncState, + AbstractState, + PromiseFn, + Meta, + AsyncInitial, + AsyncFulfilled, + AsyncPending, + AsyncRejected, +} from "./types" + +/** + * Due to https://github.com/microsoft/web-build-tools/issues/1050, we need + * AbstractState imported in this file, even though it is only used implicitly. + * This _uses_ AbstractState so it is not accidentally removed by someone. + */ +declare type ImportWorkaround = + | AbstractState + | AsyncInitial + | AsyncFulfilled + | AsyncPending + | AsyncRejected + +export interface FetchOptions extends AsyncOptions { + defer?: boolean + json?: boolean +} + +function useAsync(options: AsyncOptions): AsyncState +function useAsync(promiseFn: PromiseFn, options?: AsyncOptions): AsyncState + +function useAsync( + arg1: AsyncOptions | PromiseFn, + arg2?: AsyncOptions +): AsyncState { + const options: AsyncOptions = + typeof arg1 === "function" + ? { + ...arg2, + promiseFn: arg1, + } + : arg1 + + const counter = useRef(0) + const isMounted = useRef(true) + const lastArgs = useRef(undefined) + const lastOptions = useRef>(options) + const lastPromise = useRef>(neverSettle) + const abortController = useRef(new MockAbortController()) + + const { devToolsDispatcher } = globalScope.__REACT_ASYNC__ + const { reducer, dispatcher = devToolsDispatcher } = options + const [state, _dispatch] = useReducer( + reducer ? (state, action) => reducer(state, action, asyncReducer) : asyncReducer, + options, + init + ) + const dispatch = useCallback( + dispatcher + ? action => dispatcher(action, dispatchMiddleware(_dispatch), lastOptions.current) + : dispatchMiddleware(_dispatch), + [dispatcher] + ) + + const { debugLabel } = options + const getMeta: (meta?: M) => M = useCallback( + (meta?) => + ({ + counter: counter.current, + promise: lastPromise.current, + debugLabel, + ...meta, + } as any), + [debugLabel] + ) + + const setData = useCallback( + (data, callback = noop) => { + if (isMounted.current) { + dispatch({ + type: ActionTypes.fulfill, + payload: data, + meta: getMeta(), + }) + callback() + } + return data + }, + [dispatch, getMeta] + ) + + const setError = useCallback( + (error, callback = noop) => { + if (isMounted.current) { + dispatch({ + type: ActionTypes.reject, + payload: error, + error: true, + meta: getMeta(), + }) + callback() + } + return error + }, + [dispatch, getMeta] + ) + + const { onResolve, onReject } = options + const handleResolve = useCallback( + count => (data: T) => + count === counter.current && setData(data, () => onResolve && onResolve(data)), + [setData, onResolve] + ) + const handleReject = useCallback( + count => (err: Error) => + count === counter.current && setError(err, () => onReject && onReject(err)), + [setError, onReject] + ) + + const start = useCallback( + promiseFn => { + if ("AbortController" in globalScope) { + abortController.current.abort() + abortController.current = new globalScope.AbortController!() + } + counter.current++ + return (lastPromise.current = new Promise((resolve, reject) => { + if (!isMounted.current) return + const executor = () => promiseFn().then(resolve, reject) + dispatch({ + type: ActionTypes.start, + payload: executor, + meta: getMeta(), + }) + })) + }, + [dispatch, getMeta] + ) + + const { promise, promiseFn, initialValue } = options + const load = useCallback(() => { + const isPreInitialized = initialValue && counter.current === 0 + if (promise) { + start(() => promise) + .then(handleResolve(counter.current)) + .catch(handleReject(counter.current)) + } else if (promiseFn && !isPreInitialized) { + start(() => promiseFn(lastOptions.current, abortController.current)) + .then(handleResolve(counter.current)) + .catch(handleReject(counter.current)) + } + }, [start, promise, promiseFn, initialValue, handleResolve, handleReject]) + + const { deferFn } = options + const run = useCallback( + (...args) => { + if (deferFn) { + lastArgs.current = args + start(() => deferFn(args, lastOptions.current, abortController.current)) + .then(handleResolve(counter.current)) + .catch(handleReject(counter.current)) + } + }, + [start, deferFn, handleResolve, handleReject] + ) + + const reload = useCallback(() => { + lastArgs.current ? run(...lastArgs.current) : load() + }, [run, load]) + + const { onCancel } = options + const cancel = useCallback(() => { + onCancel && onCancel() + counter.current++ + abortController.current.abort() + isMounted.current && + dispatch({ + type: ActionTypes.cancel, + meta: getMeta(), + }) + }, [onCancel, dispatch, getMeta]) + + /* These effects should only be triggered on changes to specific props */ + /* eslint-disable react-hooks/exhaustive-deps */ + const { watch, watchFn } = options + useEffect(() => { + if (watchFn && lastOptions.current && watchFn(options, lastOptions.current)) load() + }) + useEffect(() => { + lastOptions.current = options + }, [options]) + useEffect(() => { + if (counter.current) cancel() + if (promise || promiseFn) load() + }, [promise, promiseFn, watch]) + useEffect( + () => () => { + isMounted.current = false + }, + [] + ) + useEffect(() => () => cancel(), []) + /* eslint-enable react-hooks/exhaustive-deps */ + + useDebugValue(state, ({ status }) => `[${counter.current}] ${status}`) + + if (options.suspense && state.isPending && lastPromise.current !== neverSettle) { + // Rely on Suspense to handle the loading state + throw lastPromise.current + } + + return useMemo( + () => + ({ + ...state, + run, + reload, + cancel, + setData, + setError, + } as AsyncState), + [state, run, reload, cancel, setData, setError] + ) +} + +export class FetchError extends Error { + constructor(public response: Response) { + super(`${response.status} ${response.statusText}`) + /* istanbul ignore next */ + if (Object.setPrototypeOf) { + // Not available in IE 10, but can be polyfilled + Object.setPrototypeOf(this, FetchError.prototype) + } + } +} + +const parseResponse = (accept: undefined | string, json: undefined | boolean) => ( + res: Response +) => { + if (!res.ok) return Promise.reject(new FetchError(res)) + if (typeof json === "boolean") return json ? res.json() : res + return accept === "application/json" ? res.json() : res +} + +type OverrideParams = { resource?: RequestInfo } & Partial + +interface FetchRun extends Omit, "run"> { + run(overrideParams: (params?: OverrideParams) => OverrideParams): void + run(overrideParams: OverrideParams): void + run(ignoredEvent: React.SyntheticEvent): void + run(ignoredEvent: Event): void + run(): void +} + +type FetchRunArgs = + | [(params?: OverrideParams) => OverrideParams] + | [OverrideParams] + | [React.SyntheticEvent] + | [Event] + | [] + +function isEvent(e: FetchRunArgs[0]): e is Event | React.SyntheticEvent { + return typeof e === "object" && "preventDefault" in e +} + +/** + * + * @param {RequestInfo} resource + * @param {RequestInit} init + * @param {FetchOptions} options + * @returns {AsyncState>} + */ +const useAsyncFetch = ( + resource: RequestInfo, + init: RequestInit, + { defer, json, ...options }: FetchOptions = {} +): AsyncState> => { + const method = (resource as Request).method || (init && init.method) + const headers: Headers & Record = + (resource as Request).headers || (init && init.headers) || {} + const accept: string | undefined = + headers["Accept"] || headers["accept"] || (headers.get && headers.get("accept")) + const doFetch = (input: RequestInfo, init: RequestInit) => + globalScope.fetch(input, init).then(parseResponse(accept, json)) + const isDefer = + typeof defer === "boolean" ? defer : ["POST", "PUT", "PATCH", "DELETE"].indexOf(method!) !== -1 + const fn = isDefer ? "deferFn" : "promiseFn" + const identity = JSON.stringify({ + resource, + init, + isDefer, + }) + const promiseFn = useCallback( + (_: AsyncOptions, { signal }: AbortController) => { + return doFetch(resource, { signal, ...init }) + }, + [identity] // eslint-disable-line react-hooks/exhaustive-deps + ) + const deferFn = useCallback( + function([override]: FetchRunArgs, _: AsyncOptions, { signal }: AbortController) { + if (!override || isEvent(override)) { + return doFetch(resource, { signal, ...init }) + } + if (typeof override === "function") { + const { resource: runResource, ...runInit } = override({ resource, signal, ...init }) + return doFetch(runResource || resource, { signal, ...runInit }) + } + const { resource: runResource, ...runInit } = override + return doFetch(runResource || resource, { signal, ...init, ...runInit }) + }, + [identity] // eslint-disable-line react-hooks/exhaustive-deps + ) + const state = useAsync({ + ...options, + [fn]: isDefer ? deferFn : promiseFn, + }) + useDebugValue(state, ({ counter, status }) => `[${counter}] ${status}`) + return state +} + +const unsupported = () => { + throw new Error( + "useAsync requires React v16.8 or up. Upgrade your React version or use the component instead." + ) +} + +export default useEffect ? useAsync : unsupported +export const useFetch = useEffect ? useAsyncFetch : unsupported diff --git a/packages/react-async/tsconfig.json b/packages/react-async/tsconfig.json new file mode 100644 index 00000000..f261ce40 --- /dev/null +++ b/packages/react-async/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2019", + "module": "esnext", + "allowJs": false, + "checkJs": false, + "jsx": "react", + "declaration": true, + "skipLibCheck": true, + "strict": true, + "esModuleInterop": true + }, + "include": ["src"] +}