diff --git a/packages/devtools-extension/package.json b/packages/devtools-extension/package.json index 5597f1c0..583fe363 100644 --- a/packages/devtools-extension/package.json +++ b/packages/devtools-extension/package.json @@ -6,7 +6,7 @@ "cra-template": "1.1.2", "lodash": "^4.17.21", "react": "18.1.0", - "react-async-states": "1.0.0-rc-1.1", + "react-async-states": "1.0.0-rc-2", "react-dom": "18.1.0", "react-json-view": "^1.21.3", "react-scripts": "4.0.3" diff --git a/packages/example/package.json b/packages/example/package.json index e9d3237b..66992386 100644 --- a/packages/example/package.json +++ b/packages/example/package.json @@ -9,7 +9,7 @@ "lodash": "^4.17.21", "prop-types": "^15.6.2", "react": "18.1.0", - "react-async-states": "1.0.0-rc-1.1", + "react-async-states": "1.0.0-rc-2", "react-dom": "18.1.0", "react-router-dom": "6.4.0-pre.2", "react-scripts": "4.0.3" diff --git a/packages/example/src/App2.js b/packages/example/src/App2.js new file mode 100644 index 00000000..fcb07fce --- /dev/null +++ b/packages/example/src/App2.js @@ -0,0 +1,67 @@ +import React from "react"; +import { + RenderStrategy, + StateBoundary, + useCurrentState, + AsyncStateStatus +} from "react-async-states"; + +const config = { + lazy: false, + producer: async function () { + const response = await fetch('https://jsonplaceholder.typicode.com/users/12'); + if (!response.ok) { + throw new Error(response.status); + } + return response.json(); + } +} + +function Wrapper({children}) { + const [t, e] = React.useState(false); + + return ( + <> + + {t && children} + + ) +} + +function MyError() { + const {state: {data: error}} = useCurrentState(); + + return
This error is happening: {error?.toString?.()}
+} + +function MyPending() { + const {state: {props}} = useCurrentState(); + + return
PENDING WITH PROPS: {JSON.stringify(props, null, 4)}
+} + +export default function App2() { + return ( + +

Result!

+ , + [AsyncStateStatus.success]: , + }} + /> +
+ ); +} + +function CurrentState() { + const currentState = useCurrentState(); + return
+ Current state details {currentState.state.status} +
+      {JSON.stringify(currentState, null, 4)}
+    
+
+} diff --git a/packages/example/src/index.js b/packages/example/src/index.js index 42c21bea..4063510e 100644 --- a/packages/example/src/index.js +++ b/packages/example/src/index.js @@ -2,7 +2,7 @@ import React from 'react' import ReactDOM from 'react-dom/client' import './index.css' -import App from "./past/App"; +import App from "./App2"; // import App2 from './past/App2'; diff --git a/packages/react-async-states/package.json b/packages/react-async-states/package.json index be6c18cf..79df61dd 100644 --- a/packages/react-async-states/package.json +++ b/packages/react-async-states/package.json @@ -5,7 +5,7 @@ "types": "dist/react-async-states/src/index", "author": "incepter", "sideEffects": false, - "version": "1.0.0-rc-1.1", + "version": "1.0.0-rc-2", "name": "react-async-states", "repository": "incepter/react-async-states", "description": "A hooks-based lightweight React library for state management", diff --git a/packages/react-async-states/src/__tests__/react-async-state/useAsyncState/subscription/post-subscribe/index.test.tsx b/packages/react-async-states/src/__tests__/react-async-state/useAsyncState/subscription/post-subscribe/index.test.tsx index c93abb55..8e55e446 100644 --- a/packages/react-async-states/src/__tests__/react-async-state/useAsyncState/subscription/post-subscribe/index.test.tsx +++ b/packages/react-async-states/src/__tests__/react-async-state/useAsyncState/subscription/post-subscribe/index.test.tsx @@ -6,12 +6,11 @@ import {UseAsyncState} from "../../../../../types.internal"; import {AsyncStateStatus} from "../../../../../async-state"; import {mockDateNow, TESTS_TS} from "../../../utils/setup"; -jest.useFakeTimers(); mockDateNow(); - describe('should post subscribe', () => { it('should invoke post subscribe when present and run producer' + ' and run post unsubscribe', async () => { + jest.useFakeTimers(); // given const onAbort = jest.fn(); const producer = jest.fn().mockImplementation(props => { diff --git a/packages/react-async-states/src/async-state/types.ts b/packages/react-async-states/src/async-state/types.ts index 16304dff..988694ba 100644 --- a/packages/react-async-states/src/async-state/types.ts +++ b/packages/react-async-states/src/async-state/types.ts @@ -71,8 +71,9 @@ export enum ProducerType { } export enum RenderStrategy { - FetchOnRender = 0, + FetchAsYouRender = 0, FetchThenRender = 1, + RenderThenFetch = 2, } export type ProducerConfig = { diff --git a/packages/react-async-states/src/components/AsyncStateComponent.tsx b/packages/react-async-states/src/components/AsyncStateComponent.tsx deleted file mode 100644 index 9deb0379..00000000 --- a/packages/react-async-states/src/components/AsyncStateComponent.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import * as React from "react"; -import { - AsyncStateSubscriptionMode, - UseAsyncState, - UseAsyncStateConfig -} from "../types.internal"; -import {useAsyncState} from "../hooks/useAsyncState"; -import { - AsyncStateSource, - AsyncStateStatus, - RenderStrategy -} from "../async-state"; -import {readAsyncStateFromSource} from "../async-state/read-source"; - -const defaultDeps = []; - - -interface ComponentSelf { - didRenderChildren: boolean, -} - -export function AsyncStateComponent({ - config, - error = null, - suspend = false, - children = null, - fallback = null, - dependencies = defaultDeps, - strategy = RenderStrategy.FetchOnRender, -}: { - suspend?: boolean, - dependencies?: any[], - strategy?: RenderStrategy, - config: UseAsyncStateConfig, - error?: React.ReactNode | ((props) => React.ReactNode), - children?: ((props: UseAsyncState) => React.ReactNode) | null, - fallback?: React.ReactNode | ((props: { state: E, abort: ((reason?: any) => void) }) => React.ReactNode), -}): any { - const props = useAsyncState(config, dependencies); - - if ( - strategy === RenderStrategy.FetchThenRender && - props.mode !== AsyncStateSubscriptionMode.NOOP && - props.mode !== AsyncStateSubscriptionMode.WAITING - ) { - - const asyncState = readAsyncStateFromSource( - props.source as AsyncStateSource - ); - - if (asyncState.currentState.status === AsyncStateStatus.pending) { - if (suspend) { - props.read(); // will throw - } - // the fallback will only see the pending status, - // so it will receive only the state - return render(fallback, {state: props.state, abort: props.abort}); - } - - if (asyncState.currentState.status === AsyncStateStatus.error) { - return render(error, props); - } - - if (asyncState.currentState.status !== AsyncStateStatus.success) { - return render(fallback, props); - } - - return render(children, props); - } - return render(children, props); -} - -function render(create, props) { - return typeof create === "function" ? React.createElement(create, props) : create; -} diff --git a/packages/react-async-states/src/components/StateBoundary.tsx b/packages/react-async-states/src/components/StateBoundary.tsx new file mode 100644 index 00000000..b4445e02 --- /dev/null +++ b/packages/react-async-states/src/components/StateBoundary.tsx @@ -0,0 +1,126 @@ +import * as React from "react"; +import { + AsyncStateSource, + AsyncStateStatus, + RenderStrategy, + State +} from "../async-state"; +import { + AsyncStateSubscriptionMode, + StateBoundaryProps, + UseAsyncState, +} from "../types.internal"; +import {useAsyncState} from "../hooks/useAsyncState"; +import {readAsyncStateFromSource} from "../async-state/read-source"; + +const StateBoundaryContext = React.createContext(null); + +export function StateBoundary(props: StateBoundaryProps) { + return ( + + {props.children} + + ) +} + +function StateBoundaryImpl(props: StateBoundaryProps) { + if (props.strategy === RenderStrategy.FetchThenRender) { + return React.createElement(FetchThenRenderBoundary, props); + } + if (props.strategy === RenderStrategy.FetchAsYouRender) { + return React.createElement(FetchAsYouRenderBoundary, props); + } + return React.createElement(RenderThenFetchBoundary, props); +} + +function inferBoundaryChildren>( + result: UseAsyncState, + props: StateBoundaryProps +) { + if (!props.render || !result.source) { + return props.children; + } + + const asyncState = readAsyncStateFromSource(result.source); + const {status} = asyncState.currentState; + + return props.render[status] ? props.render[status] : props.children; +} + +export function RenderThenFetchBoundary(props: StateBoundaryProps) { + const result = useAsyncState(props.config, props.dependencies); + + const children = inferBoundaryChildren(result, props); + return ( + + {children} + + ); +} + +export function FetchAsYouRenderBoundary(props: StateBoundaryProps) { + const result = useAsyncState.auto(props.config, props.dependencies); + result.read(); // throws + const children = inferBoundaryChildren(result, props); + return ( + + {children} + + ); +} + +type FetchThenRenderSelf = { + didLoad: boolean, +} + +export function FetchThenRenderBoundary(props: StateBoundaryProps) { + const result = useAsyncState.auto(props.config, props.dependencies); + const self = React.useMemo(constructSelf, []); + + if (result.mode === AsyncStateSubscriptionMode.NOOP || + result.mode === AsyncStateSubscriptionMode.WAITING) { + throw new Error("FetchThenRenderBoundary is not supported with NOOP and WAITING modes"); + } + + if (!self.didLoad) { + const {source} = result; + const asyncState = readAsyncStateFromSource(source as AsyncStateSource); + + const {status} = asyncState.currentState; + + if (status === AsyncStateStatus.error || status === AsyncStateStatus.success) { + self.didLoad = true; + const children = inferBoundaryChildren(result, props); + return ( + + {children} + + ); + } + + return null; + } else { + const children = inferBoundaryChildren(result, props); + return ( + + {children} + + ); + } + + function constructSelf() { + return { + didLoad: false, + }; + } +} + +export function useCurrentState>(): UseAsyncState { + const ctxValue = React.useContext(StateBoundaryContext); + + if (ctxValue === null) { + throw new Error('You cannot use useCurrentState outside a StateBoundary'); + } + + return ctxValue; +} diff --git a/packages/react-async-states/src/index.ts b/packages/react-async-states/src/index.ts index 973e3091..e5a5af67 100644 --- a/packages/react-async-states/src/index.ts +++ b/packages/react-async-states/src/index.ts @@ -12,7 +12,13 @@ export { invalidateCache, useRunAsyncState, } from "./hooks/useRun"; -export {AsyncStateComponent} from "./components/AsyncStateComponent"; +export { + StateBoundary, + useCurrentState, + FetchThenRenderBoundary, + RenderThenFetchBoundary, + FetchAsYouRenderBoundary, +} from "./components/StateBoundary"; export {useSelector, useAsyncStateSelector} from "./hooks/useSelector"; export * from "./types"; diff --git a/packages/react-async-states/src/types.internal.ts b/packages/react-async-states/src/types.internal.ts index 3d2ef5ea..428c7e2c 100644 --- a/packages/react-async-states/src/types.internal.ts +++ b/packages/react-async-states/src/types.internal.ts @@ -1,3 +1,4 @@ +import * as React from "react"; import { AbortFn, AsyncStateInterface, @@ -9,11 +10,12 @@ import { Producer, ProducerConfig, ProducerProps, - ProducerRunEffects, + ProducerRunEffects, RenderStrategy, RunExtraProps, State, StateUpdater } from "./async-state"; +import {ReactNode} from "react"; export type Reducer = ( T, @@ -261,6 +263,18 @@ export type UseAsyncStateConfiguration> = { lane?: string, } +export type StateBoundaryProps = { + children: React.ReactNode, + config: UseAsyncStateConfig, + + dependencies?: any[], + strategy?: RenderStrategy, + + render?: StateBoundaryRenderProp, +} + +export type StateBoundaryRenderProp = Record + export type UseAsyncStateEventProps = { state: State, }; diff --git a/packages/react-async-states/src/types.ts b/packages/react-async-states/src/types.ts index 7c3a7e51..a3892571 100644 --- a/packages/react-async-states/src/types.ts +++ b/packages/react-async-states/src/types.ts @@ -14,6 +14,7 @@ export type { } from "./async-state/types"; export { + RenderStrategy, ProducerRunEffects, } from "./async-state/types"; @@ -41,4 +42,6 @@ export type { UseAsyncStateEventFn, UseAsyncStateEventProps, + StateBoundaryProps, + } from "./types.internal";