diff --git a/package.json b/package.json index 8685cab..603117c 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "test:watch": "eslint --fix && jest --updateSnapshot --watchAll" }, "types": "dist/index.d.ts", - "version": "6.0.0-rc.4.1", + "version": "6.0.0-rc.5", "dependencies": { "@webkrafters/auto-immutable": "^2.0.0-rc.10" } diff --git a/src/main/index.test.tsx b/src/main/index.test.tsx index d02d568..ee169db 100644 --- a/src/main/index.test.tsx +++ b/src/main/index.test.tsx @@ -1090,7 +1090,7 @@ describe( 'ReactObservableContext', () => { expect( ( renderCount.current.TallyDisplay as RenderCountField ).value ).toBe( 1 ); const currentState = storeRef.current!.getState(); storeRef.current!.setState({ price: 45 }); - const newState = { ...state, price: 45 }; + let newState = { ...state, price: 45 }; await wait(() => {}); await new Promise( resolve => setTimeout( resolve, 50 ) ); expect( ( renderCount.current.TallyDisplay as RenderCountField ).value ).toBe( 2 ); @@ -1100,9 +1100,27 @@ describe( 'ReactObservableContext', () => { await wait(() => {}); await new Promise( resolve => setTimeout( resolve, 50 ) ); expect( ( renderCount.current.TallyDisplay as RenderCountField ).value ).toBe( 3 ); - const currentState2 = storeRef.current!.getState(); + let currentState2 = storeRef.current!.getState(); expect( currentState2 ).toStrictEqual( state ); expect( currentState2 ).toStrictEqual( currentState ); + // alter internal state to ready for default reset feature + storeRef.current!.setState({ price: 300 }); + currentState2 = storeRef.current!.getState(); + await wait(() => {}); + await new Promise( resolve => setTimeout( resolve, 50 ) ); + newState = { ...state, price: 300 }; + expect( currentState2 ).toEqual( newState ); + expect( currentState2 ).not.toEqual( state ); + expect( ( renderCount.current.TallyDisplay as RenderCountField ).value ).toBe( 4 ); + // default reset results in no-operation + storeRef.current!.resetState(); + const currentState3 = storeRef.current!.getState(); + await wait(() => {}); + await new Promise( resolve => setTimeout( resolve, 50 ) ); + expect( ( renderCount.current.TallyDisplay as RenderCountField ).value ).toBe( 4 ); + expect( newState ).toEqual( currentState3 ); + expect( state ).not.toEqual( currentState3 ); + expect( currentState2 ).toBe( currentState3 ); cleanupPerfTest(); }, 3e4 ); test( 'subscribes to state changes', async () => { diff --git a/src/main/index.tsx b/src/main/index.tsx index 02b23f9..5064348 100644 --- a/src/main/index.tsx +++ b/src/main/index.tsx @@ -62,9 +62,31 @@ import useStore from './hooks/use-store'; const __CTX_SYM__ = Symbol( 'Context Symbol' ); -const reportNonReactUsage : NonReactUsageReport = () => { - throw new UsageError( 'Detected usage outside of this context\'s Provider component tree. Please apply the exported Provider component' ); -}; +const connRegister : Record> = {}; + +const ChildMemo : FC<{ child: ReactNode }> = (() => { + + const useNodeMemo = ( node : ReactNode ) : ReactNode => { + const nodeRef = useRef( node ); + if( !isEqual( + omit( nodeRef.current, '_owner' ), + omit( node, '_owner' ) + ) ) { nodeRef.current = node } + return nodeRef.current; + }; + + const ChildMemo = memo<{ child: ReactNode }>(({ child }) => ( <>{ child } )); + ChildMemo.displayName = 'ObservableContext.Provider.Internal.Guardian.ChildMemo'; + + const Guardian : FC<{ child: ReactNode }> = ({ child }) => ( + + ); + Guardian.displayName = 'ObservableContext.Provider.Internal.Guardian'; + + return Guardian; +})(); + +const defaultPrehooks : Readonly> = Object.freeze({}); export class ObservableContext { private cxt : React.Context; @@ -83,12 +105,63 @@ export class ObservableContext { get Provider() { return this.provider } } +const reportNonReactUsage : NonReactUsageReport = () => { + throw new UsageError( 'Detected usage outside of this context\'s Provider component tree. Please apply the exported Provider component' ); +}; + +export class UsageError extends Error {}; + +/** + * Provides an HOC function for connecting its WrappedComponent argument to the context store. + * + * The HOC function automatically memoizes any un-memoized WrappedComponent argument. + * + * @param context - Refers to the PublicObservableContext type of the ObservableContext + * @param [selectorMap] - Key:value pairs where `key` => arbitrary key given to a Store.data property holding a state slice and `value` => property path to a state slice used by this component: see examples below. May add a mapping for a certain arbitrary key='state' and value='@@STATE' to indicate a desire to obtain the entire state object and assign to a `state` property of Store.data. A change in any of the referenced properties results in this component render. When using '@@STATE', note that any change within the state object will result in this component render. + * @see {useContext} for selectorMap sample + */ +export function connect< + STATE extends State = State, + SELECTOR_MAP extends SelectorMap = SelectorMap +>( + context : ObservableContext, + selectorMap? : SELECTOR_MAP +) { + function connector< + P extends ExtractInjectedProps + >( + WrappedComponent : ElementType> + ) : ConnectedComponent

; + function connector< + P extends ExtractInjectedProps + >( + WrappedComponent : NamedExoticComponent> + ) : ConnectedComponent

; + function connector< + P extends ExtractInjectedProps + >( WrappedComponent ) : ConnectedComponent

{ + const Wrapped = ( + !( isPlainObject( WrappedComponent ) && 'compare' in WrappedComponent as {} ) + ? memo( WrappedComponent ) + : WrappedComponent + ); + const ConnectedComponent = memo( forwardRef< + P extends IProps ? P["ref"] : never, + Omit + >(( ownProps, ref ) => { + const store = useContext( context, selectorMap ); + return ( ); + }) ); + ConnectedComponent.displayName = 'ObservableContext.Connected'; + return ConnectedComponent as ConnectedComponent

; + } + return connector; +} + export function createContext() { return new ObservableContext(); }; -const connRegister : Record> = {}; - function getConnectionFrom( connKey : MutableRefObject, cache : Immutable> @@ -105,6 +178,86 @@ function getConnectionFrom( return connRegister[ connKey.current ] as Connection; } +function getStoreRef( + store : StoreInternal, + connection: Connection +) : StoreRef { + return { + getState: ( propertyPaths = [] ) => { + if( !propertyPaths.length || propertyPaths.indexOf( constants.FULL_STATE_SELECTOR ) !== -1 ) { + return connection.get( constants.GLOBAL_SELECTOR )[ constants.GLOBAL_SELECTOR ]; + } + const data = connection.get( ...propertyPaths ); + const state = {} as T; + for( const d in data ) { set( state, d, data[ d ] ) } + return mkReadonly( state ); + }, + resetState: ( propertyPaths = [] ) => store.resetState( connection, propertyPaths ), + setState: changes => store.setState( connection, changes ), + subscribe: store.subscribe + }; +} + +function makeObservable( Provider : Provider ) { + const Observable : ObservableProvider = forwardRef< + StoreRef, + ProviderProps + >(({ + children = null, + prehooks = defaultPrehooks as Readonly>, + storage = null, + value + }, storeRef ) => { + const connKey = useRef(); + const store = useStore( prehooks, value, storage ); + const [ connection ] = useState(() => getConnectionFrom( connKey, store.cache )); + useImperativeHandle( storeRef, () => ({ + ...( storeRef as MutableRefObject> )?.current ?? {}, + ...getStoreRef( store, connection ) + }), [ ( storeRef as MutableRefObject> )?.current ] ); + useEffect(() => () => { + connection.disconnect(); + delete connRegister[ connKey.current ]; + connKey.current = undefined; + }, []); + return ( + + { memoizeImmediateChildTree( children ) } + + ); + } ); + Observable.displayName = 'ObservableContext.Provider'; + return Observable; +} + +export function mkReadonly( v : any ) { + if( Object.isFrozen( v ) ) { return v } + if( isPlainObject( v ) || Array.isArray( v ) ) { + for( const k in v ) { v[ k ] = mkReadonly( v[ k ] ) } + } + return Object.freeze( v ); +} + +function memoizeImmediateChildTree( children : ReactNode ) : ReactNode { + return Children.map( children, _child => { + let child = _child as JSX.Element; + if( !( child?.type ) || ( // skip memoized or non element(s) + typeof child.type === 'object' && + child.type.$$typeof?.toString() === 'Symbol(react.memo)' + ) ) { + return child; + } + if( child.props?.children ) { + child = cloneElement( + child, + omit( child.props, 'children' ), + memoizeImmediateChildTree( child.props.children ) + ); + } + return ( ); + } ); +} + /** * Actively monitors the store and triggers component re-render if any of the watched keys in the state objects changes * @@ -260,158 +413,4 @@ export function useContext< () => ({ data, resetState, setState }), [ data ] ); -}; - -/** - * Provides an HOC function for connecting its WrappedComponent argument to the context store. - * - * The HOC function automatically memoizes any un-memoized WrappedComponent argument. - * - * @param context - Refers to the PublicObservableContext type of the ObservableContext - * @param [selectorMap] - Key:value pairs where `key` => arbitrary key given to a Store.data property holding a state slice and `value` => property path to a state slice used by this component: see examples below. May add a mapping for a certain arbitrary key='state' and value='@@STATE' to indicate a desire to obtain the entire state object and assign to a `state` property of Store.data. A change in any of the referenced properties results in this component render. When using '@@STATE', note that any change within the state object will result in this component render. - * @see {useContext} for selectorMap sample - */ -export function connect< - STATE extends State = State, - SELECTOR_MAP extends SelectorMap = SelectorMap ->( - context : ObservableContext, - selectorMap? : SELECTOR_MAP -) { - function connector< - P extends ExtractInjectedProps - >( - WrappedComponent : ElementType> - ) : ConnectedComponent

; - function connector< - P extends ExtractInjectedProps - >( - WrappedComponent : NamedExoticComponent> - ) : ConnectedComponent

; - function connector< - P extends ExtractInjectedProps - >( WrappedComponent ) : ConnectedComponent

{ - - const Wrapped = ( - !( isPlainObject( WrappedComponent ) && 'compare' in WrappedComponent as {} ) - ? memo( WrappedComponent ) - : WrappedComponent - ); - - const ConnectedComponent = memo( forwardRef< - P extends IProps ? P["ref"] : never, - Omit - >(( ownProps, ref ) => { - const store = useContext( context, selectorMap ); - return ( ); - }) ); - ConnectedComponent.displayName = 'ObservableContext.Connected'; - - return ConnectedComponent as ConnectedComponent

; - - } - - return connector; - -} - -export class UsageError extends Error {}; - -const ChildMemo : FC<{ child: ReactNode }> = (() => { - - const useNodeMemo = ( node : ReactNode ) : ReactNode => { - const nodeRef = useRef( node ); - if( !isEqual( - omit( nodeRef.current, '_owner' ), - omit( node, '_owner' ) - ) ) { nodeRef.current = node } - return nodeRef.current; - }; - - const ChildMemo = memo<{ child: ReactNode }>(({ child }) => ( <>{ child } )); - ChildMemo.displayName = 'ObservableContext.Provider.Internal.Guardian.ChildMemo'; - - const Guardian : FC<{ child: ReactNode }> = ({ child }) => ( - - ); - Guardian.displayName = 'ObservableContext.Provider.Internal.Guardian'; - - return Guardian; -})(); - -const defaultPrehooks : Readonly> = Object.freeze({}); - -function makeObservable( Provider : Provider ) { - const Observable : ObservableProvider = forwardRef< - StoreRef, - ProviderProps - >(({ - children = null, - prehooks = defaultPrehooks, - storage = null, - value - }, storeRef ) => { - const connKey = useRef(); - const store = useStore( prehooks, value, storage ); - const [ connection ] = useState(() => getConnectionFrom( connKey, store.cache )); - const getState = useCallback["getState"]>( - ( propertyPaths = [] ) => { - if( !propertyPaths.length || propertyPaths.indexOf( constants.FULL_STATE_SELECTOR ) !== -1 ) { - return connection.get( constants.GLOBAL_SELECTOR )[ constants.GLOBAL_SELECTOR ] as T; - } - const data = connection.get( ...propertyPaths ); - const state = {} as T; - for( const d in data ) { set( state, d, data[ d ] ) } - return mkReadonly( state ); - }, - [] - ); - useImperativeHandle( storeRef, () => ({ - ...( storeRef as MutableRefObject> )?.current ?? {}, - getState, - resetState: propertyPaths => store.resetState( connection, propertyPaths ), - setState: changes => store.setState( connection, changes ), - subscribe: store.subscribe - }), [ ( storeRef as MutableRefObject> )?.current ] ); - useEffect(() => () => { - connection.disconnect(); - delete connRegister[ connKey.current ]; - connKey.current = undefined; - }, []); - return ( - - { memoizeImmediateChildTree( children ) } - - ); - } ); - Observable.displayName = 'ObservableContext.Provider'; - return Observable; -} - -export function mkReadonly( v : any ) { - if( Object.isFrozen( v ) ) { return v } - if( isPlainObject( v ) || Array.isArray( v ) ) { - for( const k in v ) { v[ k ] = mkReadonly( v[ k ] ) } - } - return Object.freeze( v ); -} - -function memoizeImmediateChildTree( children : ReactNode ) : ReactNode { - return Children.map( children, _child => { - let child = _child as JSX.Element; - if( !( child?.type ) || ( // skip memoized or non element(s) - typeof child.type === 'object' && - child.type.$$typeof?.toString() === 'Symbol(react.memo)' - ) ) { - return child; - } - if( child.props?.children ) { - child = cloneElement( - child, - omit( child.props, 'children' ), - memoizeImmediateChildTree( child.props.children ) - ); - } - return ( ); - } ); } diff --git a/tsconfig.json b/tsconfig.json index 82b0d8b..1cefa30 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,7 @@ "moduleResolution": "NodeNext", "module": "NodeNext", "outDir": "dist", - "removeComments": false, + "removeComments": true, "skipLibCheck": true, "target": "ES2020" },