Skip to content

Commit

Permalink
Merge pull request #1842 from bloody-ux/master
Browse files Browse the repository at this point in the history
support app.replaceModel method
  • Loading branch information
sorrycc authored Aug 10, 2018
2 parents 932eecf + e6f4d99 commit c75b8f6
Show file tree
Hide file tree
Showing 6 changed files with 335 additions and 3 deletions.
10 changes: 9 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,14 @@ Register model, view [#Model](#model) for details.

Unregister model.

### `app.replaceModel(model)`

> Only available after `app.start()` got called
Replace an existing model with a new one, comparing by the namespace. If no one matches, add the new one.

After called, old `reducers`, `effects`, `subscription` will be replaced with the new ones, while original state is kept, which means it's useful for HMR.

### `app.router(({ history, app }) => RouterConfig)`

Register router config.
Expand Down Expand Up @@ -344,4 +352,4 @@ Store subscriptions in key/value Object. Subscription is used for subscribing da

`({ dispatch, history }, done) => unlistenFunction`

Notice: if we want to unregister a model with `app.unmodel()`, it's subscriptions must return unsubscribe method.
Notice: if we want to unregister a model with `app.unmodel()` or `app.replaceModel()`, it's subscriptions must return unsubscribe method.
8 changes: 8 additions & 0 deletions docs/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,14 @@ persistStore(app._store);

取消 model 注册,清理 reducers, effects 和 subscriptions。subscription 如果没有返回 unlisten 函数,使用 `app.unmodel` 会给予警告⚠️。

### `app.replaceModel(model)`

> 只在app.start()之后可用
替换model为新model,清理旧model的reducers, effects 和 subscriptions,但会保留旧的state状态,对于HMR非常有用。subscription 如果没有返回 unlisten 函数,使用 `app.unmodel` 会给予警告⚠️。

如果原来不存在相同namespace的model,那么执行`app.model`操作

### `app.router(({ history, app }) => RouterConfig)`

注册路由表。
Expand Down
50 changes: 49 additions & 1 deletion packages/dva-core/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
run as runSubscription,
unlisten as unlistenSubscription,
} from './subscription';
import { noop } from './utils';
import { noop, findIndex } from './utils';

// Internal model to update global state when do unmodel
const dvaModel = {
Expand Down Expand Up @@ -124,6 +124,47 @@ export function create(hooksAndOpts = {}, createOpts = {}) {
app._models = app._models.filter(model => model.namespace !== namespace);
}

/**
* Replace a model if it exsits, if not, add it to app
* Attention:
* - Only available after dva.start gets called
* - Will not check origin m is strict equal to the new one
* Useful for HMR
* @param createReducer
* @param reducers
* @param unlisteners
* @param onError
* @param m
*/
function replaceModel(createReducer, reducers, unlisteners, onError, m) {
const store = app._store;
const { namespace } = m;
const oldModelIdx = findIndex(
app._models,
model => model.namespace === namespace
);

if (~oldModelIdx) {
// Cancel effects
store.dispatch({ type: `${namespace}/@@CANCEL_EFFECTS` });

// Delete reducers
delete store.asyncReducers[namespace];
delete reducers[namespace];

// Unlisten subscrioptions
unlistenSubscription(unlisteners, namespace);

// Delete model from app._models
app._models.splice(oldModelIdx, 1);
}

// add new version model to store
app.model(m);

store.dispatch({ type: '@@dva/UPDATE' });
}

/**
* Start the app.
*
Expand Down Expand Up @@ -212,6 +253,13 @@ export function create(hooksAndOpts = {}, createOpts = {}) {
// Setup app.model and app.unmodel
app.model = injectModel.bind(app, createReducer, onError, unlisteners);
app.unmodel = unmodel.bind(app, createReducer, reducers, unlisteners);
app.replaceModel = replaceModel.bind(
app,
createReducer,
reducers,
unlisteners,
onError
);

/**
* Create global reducer for redux.
Expand Down
9 changes: 8 additions & 1 deletion packages/dva-core/src/utils.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@

export isPlainObject from 'is-plain-object';
export const isArray = Array.isArray.bind(Array);
export const isFunction = o => typeof o === 'function';
export const returnSelf = m => m;
// avoid es6 array.prototype.findIndex polyfill
export const noop = () => {};
export const findIndex = (array, predicate) => {
for (let i = 0, length = array.length; i < length; i++) {
if (predicate(array[i], i)) return i;
}

return -1;
};
227 changes: 227 additions & 0 deletions packages/dva-core/test/repalceModel.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import expect from 'expect';
import EventEmitter from 'events';
import { create } from '../src/index';

describe('app.replaceModel', () => {
it('should not be available before app.start() get called', () => {
const app = create();

expect('replaceModel' in app).toEqual(false);
});

it("should add model if it doesn't exist", () => {
const app = create();
app.start();

const oldCount = app._models.length;

app.replaceModel({
namespace: 'users',
state: [],
reducers: {
add(state, { payload }) {
return [...state, payload];
},
},
});

expect(app._models.length).toEqual(oldCount + 1);

app._store.dispatch({ type: 'users/add', payload: 'jack' });
const state = app._store.getState();
expect(state.users).toEqual(['jack']);
});

it('should run new reducers if model exists', () => {
const app = create();
app.model({
namespace: 'users',
state: ['foo'],
reducers: {
add(state, { payload }) {
return [...state, payload];
},
},
});
app.start();

const oldCount = app._models.length;

app.replaceModel({
namespace: 'users',
state: ['bar'],
reducers: {
add(state, { payload }) {
return [...state, 'world', payload];
},
clear() {
return [];
},
},
});

expect(app._models.length).toEqual(oldCount);
let state = app._store.getState();
expect(state.users).toEqual(['foo']);

app._store.dispatch({ type: 'users/add', payload: 'jack' });
state = app._store.getState();
expect(state.users).toEqual(['foo', 'world', 'jack']);

// test new added action
app._store.dispatch({ type: 'users/clear' });

state = app._store.getState();
expect(state.users).toEqual([]);
});

it('should run new effects if model exists', () => {
const app = create();
app.model({
namespace: 'users',
state: [],
reducers: {
setter(state, { payload }) {
return [...state, payload];
},
},
effects: {
*add({ payload }, { put }) {
yield put({
type: 'setter',
payload,
});
},
},
});
app.start();

app.replaceModel({
namespace: 'users',
state: [],
reducers: {
setter(state, { payload }) {
return [...state, payload];
},
},
effects: {
*add(_, { put }) {
yield put({
type: 'setter',
payload: 'mock',
});
},
},
});

app._store.dispatch({ type: 'users/add', payload: 'jack' });
const state = app._store.getState();
expect(state.users).toEqual(['mock']);
});

it('should run subscriptions after replaceModel', () => {
const app = create();
app.model({
namespace: 'users',
state: [],
reducers: {
add(state, { payload }) {
return [...state, payload];
},
},
subscriptions: {
setup({ dispatch }) {
// should return unlistener but omitted here
dispatch({ type: 'add', payload: 1 });
},
},
});
app.start();

app.replaceModel({
namespace: 'users',
state: [],
reducers: {
add(state, { payload }) {
return [...state, payload];
},
},
subscriptions: {
setup({ dispatch }) {
// should return unlistener but omitted here
dispatch({ type: 'add', payload: 2 });
},
},
});

const state = app._store.getState();
// This should be an issue but can't be avoided with dva
// To avoid, in client code, setup method should be idempotent when running multiple times
expect(state.users).toEqual([1, 2]);
});

it('should remove old subscription listeners after replaceModel', () => {
const app = create();
const emitter = new EventEmitter();
let emitterCount = 0;

app.model({
namespace: 'users',
state: [],
subscriptions: {
setup() {
emitter.on('event', () => {
emitterCount += 1;
});
return () => {
emitter.removeAllListeners();
};
},
},
});
app.start();

emitter.emit('event');

app.replaceModel({
namespace: 'users',
state: [],
});

emitter.emit('event');

expect(emitterCount).toEqual(1);
});

it('should trigger onError if error is thown after replaceModel', () => {
let triggeredError = false;
const app = create({
onError() {
triggeredError = true;
},
});
app.model({
namespace: 'users',
state: [],
});
app.start();

app.replaceModel({
namespace: 'users',
state: [],
effects: {
*add() {
yield 'fake';

throw new Error('fake error');
},
},
});

app._store.dispatch({
type: 'users/add',
});

expect(triggeredError).toEqual(true);
});
});
34 changes: 34 additions & 0 deletions packages/dva-core/test/utils.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import expect from 'expect';
import { findIndex } from '../src/utils';

describe('utils', () => {
describe('#findIndex', () => {
it('should return -1 when no item matches', () => {
const array = [1, 2, 3];
const action = i => i === 4;

expect(findIndex(array, action)).toEqual(-1);
});

it('should return index of the match item in array', () => {
const array = ['a', 'b', 'c'];
const action = i => i === 'b';

const actualValue = findIndex(array, action);
const expectedValue = 1;

expect(actualValue).toEqual(expectedValue);
});

it('should return the first match if more than one items match', () => {
const target = {
id: 1,
};

const array = [target, { id: 1 }];
const action = i => i.id === 1;

expect(findIndex(array, action)).toEqual(0);
});
});
});

0 comments on commit c75b8f6

Please sign in to comment.