Skip to content

Commit

Permalink
feat(react): add connect React HOC
Browse files Browse the repository at this point in the history
  • Loading branch information
tlaundal committed Apr 2, 2020
1 parent 21b3ff9 commit 42e5767
Show file tree
Hide file tree
Showing 2 changed files with 175 additions and 0 deletions.
141 changes: 141 additions & 0 deletions src/react/connect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import testUntyped from 'ava';
import { act, create, ReactTestRenderer } from 'react-test-renderer';
import React from 'react';
import { empty, of, BehaviorSubject } from 'rxjs';
import { connect } from './connect';
import { spy, SinonSpy } from 'sinon';
import { TestInterface } from 'ava';

type Props = { msg: string; num?: number };
const initialProps = { msg: 'hello' };
const secondProps = { msg: 'second' };
const additionalProps = { num: 3 };
const secondAdditionalProps = { num: 7 };
const WrappedComponent = ({ msg }: Props) => React.createElement('p', {}, msg);

type Context = {
WrappedComponentSpy: SinonSpy<[Props], ReturnType<typeof WrappedComponent>>;
};

const test = testUntyped as TestInterface<Context>;

test.beforeEach((t) => {
t.context.WrappedComponentSpy = spy(({ msg }: Props) =>
React.createElement('p', {}, msg)
);
});

test.afterEach((t) => t.context.WrappedComponentSpy.resetHistory());

test('connect should render null on first render', (t) => {
const { WrappedComponentSpy } = t.context;
const HOComponent = connect(WrappedComponentSpy, empty());

let component: ReactTestRenderer;
act(() => {
component = create(React.createElement(HOComponent));
});

t.assert(WrappedComponentSpy.notCalled);
t.deepEqual(component!.toJSON(), null);
});

test('connect should immediately send props to wrapped component', (t) => {
const { WrappedComponentSpy } = t.context;
const HOComponent = connect(WrappedComponentSpy, of(initialProps));

act(() => {
create(React.createElement(HOComponent));
});

t.deepEqual(WrappedComponentSpy.firstCall.args[0], initialProps);
});

test('connect should re-render wrapped component on emitted props', (t) => {
const { WrappedComponentSpy } = t.context;
const props$ = new BehaviorSubject<Props>(initialProps);
const HOComponent = connect(WrappedComponentSpy, props$);

act(() => {
create(React.createElement(HOComponent));
});
t.deepEqual(WrappedComponentSpy.firstCall.args[0], initialProps);

act(() => {
props$.next(secondProps);
});
t.deepEqual(WrappedComponentSpy.secondCall.args[0], secondProps);
});

test('connect should unsubscribe stream when unmounted', async (t) => {
const props$ = new BehaviorSubject<Props>(initialProps);
const HOComponent = connect(WrappedComponent, props$);

let component: ReactTestRenderer;
act(() => {
component = create(React.createElement(HOComponent));
});

t.notDeepEqual(props$.observers, []);

act(() => {
component.unmount();
});

t.deepEqual(props$.observers, []);
});

test('connect should forward props', async (t) => {
const { WrappedComponentSpy } = t.context;
const HOComponent = connect(WrappedComponentSpy, of(initialProps));

act(() => {
create(React.createElement(HOComponent, additionalProps));
});

t.deepEqual(WrappedComponentSpy.firstCall?.args[0], {
...additionalProps,
...initialProps,
});
});

test('connect give streamed props precedence over forwarded props', (t) => {
const { WrappedComponentSpy } = t.context;
const HOComponent = connect(WrappedComponentSpy, of(initialProps));

act(() => {
create(
React.createElement(HOComponent, {
...additionalProps,
msg: 'overriding should not work',
})
);
});

t.deepEqual(WrappedComponentSpy.firstCall?.args[0], {
...additionalProps,
...initialProps,
});
});

test('connect should propagate changes to forwarded props', async (t) => {
const { WrappedComponentSpy } = t.context;
const HOComponent = connect(WrappedComponentSpy, of(initialProps));

let component: ReactTestRenderer;
act(() => {
component = create(React.createElement(HOComponent, additionalProps));
});
act(() => {
component.update(React.createElement(HOComponent, secondAdditionalProps));
});

t.deepEqual(WrappedComponentSpy.firstCall?.args[0], {
...additionalProps,
...initialProps,
});
t.deepEqual(WrappedComponentSpy.secondCall?.args[0], {
...secondAdditionalProps,
...initialProps,
});
});
34 changes: 34 additions & 0 deletions src/react/connect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useStream, NOT_YET_EMITTED } from './useStream';
import { Observable } from 'rxjs';
import React, { ComponentType } from 'react';

/**
* Higher order component for connecting a component to a stream
*
* The stream does not need to provide all the properties the component expects,
* any missing properties will be required by the component returned from this
* component.
*
* Typescript does, in certain cases, allow for extra properties to be unpacked
* on a component. This means it may be possible to send props which would be
* provided by the stream. In these cases the values from the stream will
* override any values passed as props.
*
* @param WrappedComponent Component that will recieve props from the stream
* @param stream$ Stream that will provide props to the component
* @see useStream
*/
export const connect = <Props, Observed extends Partial<Props>>(
Component: ComponentType<Props>,
stream$: Observable<Observed>
) => (props: Omit<Props, keyof Observed>) => {
const value = useStream(stream$);

if (value === NOT_YET_EMITTED) return null;

// Typescript doesn't recognize this as Observed & Props for some reason
// Question on StackOverflow: https://stackoverflow.com/q/60758084/1104307
const newProps = { ...props, ...value } as Props & Observed;

return React.createElement(Component, newProps);
};

0 comments on commit 42e5767

Please sign in to comment.