Skip to content

Commit

Permalink
feat: nested provider and provider will no longer block direct childr…
Browse files Browse the repository at this point in the history
…en's render
  • Loading branch information
ddadaal committed Feb 12, 2019
1 parent 57d896f commit 2355733
Show file tree
Hide file tree
Showing 6 changed files with 118 additions and 43 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,15 @@ function AwaitedComponent() {
- Simple APIs and you just read all of them
- No dependency but React 16.8 or higher and [tslib](https://github.com/Microsoft/tslib) for TypeScript projects
- Strongly typed with TypeScript. As long as store types are specified, other types can be inferred so you don't have to `as` or `any` anymore!
- Nested providers. Inner provided stores will override outer stores.
- (WIP) Basic SSR utilities support

# Common Pitfalls

- To avoid unnecessary re-renders, `StoreProvider` is designed to only re-render when the current and previous `stores` are different. For two `Store<any>[]` *a* and *b*, `a differs from b` is equivalent to `a.length === b.length && for i from 0 to a.length-1, a[i] === b[i]`.
- Under the hood, the provided stores will be stored in a `Map` with store's constructor as the key and store instance itself as the value. To avoid unnecessary re-renders, `StoreProvider` is designed to cache inner context and only reassign context only if the provided maps are different. Two maps are different if they have different length, or contains different stores.
- Once the promise returned by `setState` resolves, it is guaranteed for components that use render props and HOC that have updated, but these using `useStore` hook will not have the guarantee. I am working on it.


## Roadmap

- [x] Store and `useStore` hook
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"clean:dist": "rimraf dist",
"watch": "rollup -cw",
"start": "rollup -cw",
"lint": "tslint 'src/**/*.{ts,tsx}' --fix",
"lint": "tslint '{src,tests}/**/*.{ts,tsx}' --fix",
"test": "jest",
"coveralls": "jest --coverage && cat ./coverage/lcov.info | coveralls",
"release": "yarn run build && standard-version"
Expand All @@ -30,13 +30,13 @@
"tslib": "^1.9.3"
},
"peerDependencies": {
"react": ">=16.8.0"
"react": ">=16.8.1"
},
"devDependencies": {
"react": ">=16.8.0",
"react": ">=16.8.1",
"@types/enzyme": "^3.1.17",
"@types/jest": "^24.0.0",
"@types/react": ">=16.8.0",
"@types/jest": "^24.0.1",
"@types/react": ">=16.8.2",
"@types/react-test-renderer": "^16.8.0",
"coveralls": "^3.0.2",
"cross-env": "^5.2.0",
Expand Down
60 changes: 37 additions & 23 deletions src/StoreProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,48 +10,62 @@ interface Props {
children: React.ReactNode;
}

// interface State {
// map: ISimstateContext;
// }
interface State {
map: ISimstateContext;
}

function arrayEquals<T>(array1: T[], array2: T[]) {
const len = array1.length;
if (len !== array2.length) {
function contextEqual(a: ISimstateContext, b: ISimstateContext) {
if (a.size !== b.size) {
return false;
}

for (let i = 0; i < len; i++) {
if (array1[i] !== array2[i]) {
for (const [key, val] of a) {
if (b.get(key) !== val) {
return false;
}
}

return true;

}

function constructMap(stores: Store<any>[]) {
const map: ISimstateContext = new Map();
stores.forEach((store) => {
map.set(store.constructor as StoreType<any>, store);
});
return map;
interface InnerProps {
currentMap: ISimstateContext;
}

export default class StoreProvider extends React.Component<Props> {
class StoreProvider extends React.Component<InnerProps, State> {

shouldComponentUpdate(prevProps: Props) {
return !arrayEquals(prevProps.stores, this.props.stores);
componentDidUpdate() {
if (!contextEqual(this.props.currentMap, this.state.map)) {
this.setState({ map: this.props.currentMap });
}
}

render() {

const map = constructMap(this.props.stores);
state = { map: this.props.currentMap };

render() {
return (
<SimstateContext.Provider value={map}>
<SimstateContext.Provider value={this.state.map}>
{this.props.children}
</SimstateContext.Provider>
);
}
}

function constructMap(prev: ISimstateContext | undefined, stores: Store<any>[]): ISimstateContext {
const map: ISimstateContext = new Map(prev!); // new Map(undefined) will work

for (const store of stores) {
map.set(store.constructor as StoreType<any>, store);
}

return map;
}

export default (props: Props) => (
<SimstateContext.Consumer>
{(map) => (
<StoreProvider currentMap={constructMap(map, props.stores)}>
{props.children}
</StoreProvider>
)}
</SimstateContext.Consumer>
);
82 changes: 70 additions & 12 deletions tests/provider.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,25 +43,27 @@ describe("Provider", () => {

expect(wrapper.find("span").text()).toEqual("43");

wrapper.setProps({ stores: [new TestStore(43), new AnotherStore() ] });
wrapper.setProps({ stores: [new TestStore(43), new AnotherStore()] });

expect(wrapper.find("span").text()).toEqual("43");
});

it("should not re-render if the input store hasn't changed", () => {

function Child() {
return (
<SimstateContext.Consumer>
{(map) => {
return (
<span>{map!.get(TestStore)!.state.value}</span>
);
}
}
</SimstateContext.Consumer>
);
class Child extends React.PureComponent {
render() {
return (
<SimstateContext.Consumer>
{(map) => {
return (
<span>{map!.get(TestStore)!.state.value}</span>
);
}}
</SimstateContext.Consumer>
);
}
}

class Component extends React.Component<{ store: Store<any> }> {
render() {
return (
Expand All @@ -85,4 +87,60 @@ describe("Provider", () => {

});

it("should support nested providers", () => {
class AnotherStore extends Store<{ text: string }> {
constructor(text: string) {
super();
this.state = { text };
}
}

function Child() {
return (
<SimstateContext.Consumer>
{(map) => (
<div>
<span id="TestStore">{map!.has(TestStore) ? map!.get(TestStore)!.state.value : "no"}</span>
<span id="AnotherStore">{map!.has(AnotherStore) ? map!.get(AnotherStore)!.state.text : "no"}</span>
</div>
)}
</SimstateContext.Consumer>
);
}

const test = (Parent: React.ComponentType, testStore: string, anotherStore: string) => {
const wrapper = mount(<Parent />);

expect(wrapper.find("#TestStore").text()).toEqual(testStore);
expect(wrapper.find("#AnotherStore").text()).toEqual(anotherStore);
};

const Parent1 = () => (
<StoreProvider stores={[new TestStore(42)]}>
<Child />
</StoreProvider>
);

test(Parent1, "42", "no");

const Parent2 = () => (
<StoreProvider stores={[new AnotherStore("hahaha")]}>
<Parent1 />
</StoreProvider>
);

test(Parent2, "42", "hahaha");

const Parent3 = () => (
<StoreProvider stores={[new AnotherStore("outter")]}>
<StoreProvider stores={[new AnotherStore("inner")]}>
<Child />
</StoreProvider>
</StoreProvider>
);

test(Parent3, "no", "inner");

});

});
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
"noUnusedLocals": true,
"strictNullChecks": true,
"esModuleInterop": true,
"downlevelIteration": true,
"sourceMap": true,
"declaration": true,
"importHelpers": true
"importHelpers": true,
},
"include": [
"src/**/*.tsx"
Expand Down
2 changes: 1 addition & 1 deletion tslint.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"member-ordering": false,
"no-empty": false,
"no-console": false,
"max-classes-per-file": [ true, 3],
"max-classes-per-file": false,
"jsx-no-multiline-js": false,
"jsx-alignment": false,
"jsx-no-lambda": false,
Expand Down

0 comments on commit 2355733

Please sign in to comment.