diff --git a/package-lock.json b/package-lock.json index eaa7cff..cc0ac3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "suspense-service", - "version": "0.2.4", + "version": "0.2.5", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1852,9 +1852,9 @@ } }, "@types/jest": { - "version": "26.0.19", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.19.tgz", - "integrity": "sha512-jqHoirTG61fee6v6rwbnEuKhpSKih0tuhqeFbCmMmErhtu3BYlOZaXWjffgOstMM4S/3iQD31lI5bGLTrs97yQ==", + "version": "26.0.20", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.20.tgz", + "integrity": "sha512-9zi2Y+5USJRxd0FsahERhBwlcvFh6D2GLQnY2FH2BzK8J9s9omvNHIbvABwIluXa0fD8XVKMLTO0aOEuUfACAA==", "dev": true, "requires": { "jest-diff": "^26.0.0", @@ -3627,9 +3627,9 @@ } }, "eslint": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.16.0.tgz", - "integrity": "sha512-iVWPS785RuDA4dWuhhgXTNrGxHHK3a8HLSMBgbbU59ruJDubUraXN8N5rn7kb8tG6sjg74eE0RA3YWT51eusEw==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.17.0.tgz", + "integrity": "sha512-zJk08MiBgwuGoxes5sSQhOtibZ75pz0J35XTRlZOk9xMffhpA9BTbQZxoXZzOl5zMbleShbGwtw+1kGferfFwQ==", "dev": true, "requires": { "@babel/code-frame": "^7.0.0", @@ -8122,6 +8122,12 @@ "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", "dev": true }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true + }, "require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -8188,9 +8194,9 @@ } }, "rollup": { - "version": "2.35.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.35.1.tgz", - "integrity": "sha512-q5KxEyWpprAIcainhVy6HfRttD9kutQpHbeqDTWnqAFNJotiojetK6uqmcydNMymBEtC4I8bCYR+J3mTMqeaUA==", + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.36.1.tgz", + "integrity": "sha512-eAfqho8dyzuVvrGqpR0ITgEdq0zG2QJeWYh+HeuTbpcaXk8vNFc48B7bJa1xYosTCKx0CuW+447oQOW8HgBIZQ==", "dev": true, "requires": { "fsevents": "~2.1.2" @@ -9080,15 +9086,35 @@ "dev": true }, "table": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/table/-/table-6.0.4.tgz", - "integrity": "sha512-sBT4xRLdALd+NFBvwOz8bw4b15htyythha+q+DVZqy2RS08PPC8O2sZFgJYEY7bJvbCFKccs+WIZ/cd+xxTWCw==", + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/table/-/table-6.0.7.tgz", + "integrity": "sha512-rxZevLGTUzWna/qBLObOe16kB2RTnnbhciwgPbMMlazz1yZGVEgnZK762xyVdVznhqxrfCeBMmMkgOOaPwjH7g==", "dev": true, "requires": { - "ajv": "^6.12.4", + "ajv": "^7.0.2", "lodash": "^4.17.20", "slice-ansi": "^4.0.0", "string-width": "^4.2.0" + }, + "dependencies": { + "ajv": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-7.0.3.tgz", + "integrity": "sha512-R50QRlXSxqXcQP5SvKUrw8VZeypvo12i2IX0EeR5PiZ7bEKeHWgzgo264LDadUsCU42lTJVhFikTqJwNeH34gQ==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + } } }, "terminal-link": { @@ -9342,9 +9368,9 @@ } }, "typedoc": { - "version": "0.20.7", - "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.20.7.tgz", - "integrity": "sha512-HMfPatgQiEf71pvSqQRhs9Myri7Yczg3yMxlPTwNnHXb0Qw8vYE4ZWCe/NEgBCwvBTvj/tDAtHFk9/hIBYxOJw==", + "version": "0.20.14", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.20.14.tgz", + "integrity": "sha512-9bsZp5/qkl+gDSv9DRvHbfbY8Sr0tD8fKx7hNIvcluxeAFzBCEo9o0qDCdLUZw+/axbfd9TaqHvSuCVRu+YH6Q==", "dev": true, "requires": { "colors": "^1.4.0", @@ -9357,19 +9383,19 @@ "progress": "^2.0.3", "shelljs": "^0.8.4", "shiki": "^0.2.7", - "typedoc-default-themes": "0.12.0" + "typedoc-default-themes": "0.12.1" } }, "typedoc-default-themes": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/typedoc-default-themes/-/typedoc-default-themes-0.12.0.tgz", - "integrity": "sha512-0hHBxwmfxE0rkIslOiO39fJyYwaScQEhUIxcpqx3uS1BL3zhFW5oQfUaPx2cv2XLL/GXhYFxhdFLoVmNptbxEQ==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/typedoc-default-themes/-/typedoc-default-themes-0.12.1.tgz", + "integrity": "sha512-6PEvV+/kWAJeUwEtrKgIsZQSbybW5DGCr6s2mMjHsDplpgN8iBHI52UbA+2C+c2TMCxBNMK9TMS6pdeIdwsLSw==", "dev": true }, "typedoc-plugin-markdown": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-3.2.1.tgz", - "integrity": "sha512-gepVk2zFFrTGaKywLEgwz6EARYjOGcx9rHF8M8a+fqz/iTp6Zobvw+7x01BJ9V4tbuXI3M9Y2/wMYwC378/msg==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-3.4.0.tgz", + "integrity": "sha512-aHLWI4jeSpSDgMbRByinRp+b2u4kHXySiccZc7lKSExH4Md44ds21oH0g+xZ5lBv9dhZdTz7mhTCrbAm5Nh24w==", "dev": true, "requires": { "handlebars": "^4.7.6" diff --git a/package.json b/package.json index 22cf875..4c45e3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "suspense-service", - "version": "0.2.4", + "version": "0.2.5", "description": "Suspense integration library for React", "repository": "github:patrickroberts/suspense-service", "main": "dst/cjs/suspense-service.js", @@ -28,7 +28,7 @@ "@babel/preset-react": "^7.12.10", "@rollup/plugin-commonjs": "^15.1.0", "@rollup/plugin-node-resolve": "^9.0.0", - "@types/jest": "^26.0.19", + "@types/jest": "^26.0.20", "@types/react": "^16.14.2", "@types/react-dom": "^16.9.10", "@types/react-test-renderer": "^16.9.4", @@ -36,7 +36,7 @@ "@typescript-eslint/parser": "4.11.1", "@wessberg/rollup-plugin-ts": "^1.3.8", "concurrently": "^5.3.0", - "eslint": "^7.16.0", + "eslint": "^7.17.0", "eslint-config-airbnb": "^18.2.1", "eslint-plugin-import": "^2.22.1", "eslint-plugin-jest": "^24.1.3", @@ -49,11 +49,11 @@ "react-dom": "^16.14.0", "react-test-renderer": "^16.14.0", "rimraf": "^3.0.2", - "rollup": "^2.35.1", + "rollup": "^2.36.1", "rollup-plugin-terser": "^7.0.2", "ts-jest": "^26.4.4", - "typedoc": "^0.20.0", - "typedoc-plugin-markdown": "^3.2.1", + "typedoc": "^0.20.14", + "typedoc-plugin-markdown": "^3.4.0", "typedoc-plugin-sourcefile-url": "^1.0.6", "typescript": "^4.1.3" }, @@ -70,6 +70,7 @@ "library", "react", "service", + "stateful", "suspense", "typescript" ], diff --git a/src/IdContext/index.ts b/src/IdContext/index.ts index 2030480..1e70bba 100644 --- a/src/IdContext/index.ts +++ b/src/IdContext/index.ts @@ -38,7 +38,7 @@ export function createIdContext(defaultValue: T): IdContext { } /** - * Consumes a value from a {@link IdContextProvider} + * Consumes a value from an {@link IdContextProvider} * @param context the {@link IdContext} to use * @param id the {@link IdContextProviderProps.id | IdContextProvider id} to use */ diff --git a/src/StateContext/Consumer/Props.ts b/src/StateContext/Consumer/Props.ts new file mode 100644 index 0000000..329d500 --- /dev/null +++ b/src/StateContext/Consumer/Props.ts @@ -0,0 +1,16 @@ +import { Dispatch, ReactNode, SetStateAction } from 'react'; +import Id from '../../IdContext/Id'; + +export default interface StateConsumerProps { + /** + * The {@link StateProvider} to use + * @default null + */ + id?: Id; + children: (value: T, setState: Dispatch>) => ReactNode; +} + +/** @ignore */ +export const defaultProps = { + id: null, +}; diff --git a/src/StateContext/Consumer/index.tsx b/src/StateContext/Consumer/index.tsx new file mode 100644 index 0000000..e179826 --- /dev/null +++ b/src/StateContext/Consumer/index.tsx @@ -0,0 +1,32 @@ +import React, { ComponentType, memo, useCallback, useMemo } from 'react'; +import IdContext from '../../IdContext'; +import State from '../State'; +import StateContextConsumerProps, { defaultProps } from './Props'; + +type StateContextConsumer = ComponentType>; + +export default StateContextConsumer; +export { StateContextConsumerProps }; + +/** @ignore */ +export function createStateContextConsumer( + { Consumer }: IdContext>, +): StateContextConsumer { + const StateConsumer: StateContextConsumer = ({ id, children }) => { + const render = useCallback( + ([state, setState]: State) => children(state, setState), + [children], + ); + + return useMemo(() => ( + {render} + ), [id, render]); + }; + + StateConsumer.defaultProps = defaultProps; + + return memo(StateConsumer, (prev, next) => ( + Object.is(prev.id, next.id) + && Object.is(prev.children, next.children) + )); +} diff --git a/src/StateContext/Provider/Props.ts b/src/StateContext/Provider/Props.ts new file mode 100644 index 0000000..c063ea7 --- /dev/null +++ b/src/StateContext/Provider/Props.ts @@ -0,0 +1,29 @@ +import { ReactNode } from 'react'; +import Id from '../../IdContext/Id'; +import Reset from '../../State/Reset'; + +export default interface StateContextProviderProps { + /** + * The initial value to provide + */ + value: T; + /** + * The key that identifies the {@link StateContextProvider} to be consumed + * @default null + */ + id?: Id; + /** + * @default null + */ + children?: ReactNode; + /** + * The reset function when {@link StateProviderProps.value | value} updates + */ + reset?: Reset; +} + +/** @ignore */ +export const defaultProps = { + id: null, + children: null, +}; diff --git a/src/StateContext/Provider/index.tsx b/src/StateContext/Provider/index.tsx new file mode 100644 index 0000000..60dee4d --- /dev/null +++ b/src/StateContext/Provider/index.tsx @@ -0,0 +1,32 @@ +import React, { ComponentType, memo, useMemo } from 'react'; +import IdContext from '../../IdContext'; +import useResetState from '../../State/useResetState'; +import State from '../State'; +import StateContextProviderProps, { defaultProps } from './Props'; + +type StateContextProvider = ComponentType>; + +export default StateContextProvider; +export { StateContextProviderProps }; + +/** @ignore */ +export function createStateContextProvider( + StateContext: IdContext>, +): StateContextProvider { + const { Provider } = StateContext; + const StateProvider: StateContextProvider = ({ value, id, children, reset }) => { + const state = useResetState(value, reset); + + return useMemo(() => ( + {children} + ), [state, id, children]); + }; + + StateProvider.defaultProps = defaultProps; + + return memo(StateProvider, (prev, next) => ( + Object.is(prev.value, next.value) + && Object.is(prev.id, next.id) + && Object.is(prev.children, next.children) + )); +} diff --git a/src/StateContext/State.ts b/src/StateContext/State.ts new file mode 100644 index 0000000..a1c7f91 --- /dev/null +++ b/src/StateContext/State.ts @@ -0,0 +1,8 @@ +import { Dispatch, SetStateAction } from 'react'; + +/** + * A stateful value and a function to update it + */ +type State = [T, Dispatch>]; + +export default State; diff --git a/src/StateContext/index.ts b/src/StateContext/index.ts new file mode 100644 index 0000000..bcafb7f --- /dev/null +++ b/src/StateContext/index.ts @@ -0,0 +1,47 @@ +import Id from '../IdContext/Id'; +import State from './State'; +import IdContext, { createIdContext, useIdContext } from '../IdContext'; +import StateContextConsumer, { createStateContextConsumer } from './Consumer'; +import StateContextProvider, { createStateContextProvider } from './Provider'; + +/** + * A privately scoped unique symbol for accessing {@link StateContext} internal {@link State} + * @internal + */ +const kState = Symbol('kState'); + +/** + * A State Context with support for multiple keyed values + */ +export default interface StateContext { + Consumer: StateContextConsumer; + Provider: StateContextProvider; + /** @internal */ + [kState]: IdContext>; +} + +/** + * Creates a State Context for providing a stateful value and a function to update it. + * @param defaultValue the value consumed if no {@link StateContextProvider} is in scope and the + * {@link StateContextConsumerProps.id | consumer `id`} is `null` + */ +export function createStateContext(defaultValue: T): StateContext { + const StateContext = createIdContext>( + [defaultValue, () => undefined], + ); + + return { + Consumer: createStateContextConsumer(StateContext), + Provider: createStateContextProvider(StateContext), + [kState]: StateContext, + }; +} + +/** + * Consumes a stateful value from a {@link StateContextProvider}, and a function to update it + * @param context the {@link StateContext} to use + * @param id the {@link StateContextProviderProps.id | StateContextProvider id} to use + */ +export function useStateContext(context: StateContext, id: Id = null): State { + return useIdContext(context[kState], id); +} diff --git a/src/index.ts b/src/index.ts index c365f2c..1fb664b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,4 +5,8 @@ export { default as IdContext, createIdContext, useIdContext } from './IdContext export { default as ServiceConsumer, ServiceConsumerProps } from './Service/Consumer'; export { default as ServiceProvider, ServiceProviderProps } from './Service/Provider'; export { default as Service, Handler, createService, useService, useServiceState } from './Service'; +export { default as State } from './StateContext/State'; +export { default as StateContextConsumer, StateContextConsumerProps } from './StateContext/Consumer'; +export { default as StateContextProvider, StateContextProviderProps } from './StateContext/Provider'; +export { default as StateContext, createStateContext, useStateContext } from './StateContext'; export { default as Reset } from './State/Reset';