Skip to content

Commit 64ea4a6

Browse files
committed
fix(watch): fix watcher callback not flushing at right timing
Resolves: #120
1 parent 32b65e3 commit 64ea4a6

File tree

5 files changed

+178
-75
lines changed

5 files changed

+178
-75
lines changed

src/apis/watch.ts

Lines changed: 85 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,17 @@ type MapSources<T> = {
2222
type FlushMode = 'pre' | 'post' | 'sync';
2323

2424
interface WatcherOption {
25-
lazy: boolean;
25+
lazy: boolean; // whether or not to delay callcack invoking
2626
deep: boolean;
2727
flush: FlushMode;
2828
}
2929

30+
export interface VueWatcher {
31+
lazy: boolean;
32+
get(): any;
33+
teardown(): void;
34+
}
35+
3036
let fallbackVM: ComponentInstance;
3137

3238
function flushPreQueue(this: any) {
@@ -84,13 +90,39 @@ function queueFlushJob(vm: any, fn: () => void, mode: Exclude<FlushMode, 'sync'>
8490
}
8591
}
8692

93+
function createVueWatcher(
94+
vm: ComponentInstance,
95+
getter: () => any,
96+
callback: (n: any, o: any) => any,
97+
options: {
98+
deep: boolean;
99+
sync: boolean;
100+
immediateInvokeCallback?: boolean;
101+
noRun?: boolean;
102+
before?: () => void;
103+
}
104+
): VueWatcher {
105+
const index = vm._watchers.length;
106+
// @ts-ignore: use undocumented options
107+
vm.$watch(getter, callback, {
108+
immediate: options.immediateInvokeCallback,
109+
deep: options.deep,
110+
lazy: options.noRun,
111+
sync: options.sync,
112+
before: options.before,
113+
});
114+
115+
return vm._watchers[index];
116+
}
117+
87118
function createWatcher(
88119
vm: ComponentInstance,
89120
source: WatcherSource<unknown> | WatcherSource<unknown>[] | SimpleEffect,
90121
cb: WatcherCallBack<any> | null,
91122
options: WatcherOption
92123
): () => void {
93124
const flushMode = options.flush;
125+
const isSync = flushMode === 'sync';
94126
let cleanup: (() => void) | null;
95127
const registerCleanup: CleanupRegistrator = (fn: () => void) => {
96128
cleanup = () => {
@@ -101,54 +133,51 @@ function createWatcher(
101133
}
102134
};
103135
};
136+
// cleanup before running getter again
137+
const runCleanup = () => {
138+
if (cleanup) {
139+
cleanup();
140+
cleanup = null;
141+
}
142+
};
143+
const createScheduler = <T extends Function>(fn: T): T => {
144+
if (isSync || /* without a current active instance, ignore pre|post mode */ vm === fallbackVM) {
145+
return fn;
146+
}
147+
return (((...args: any[]) =>
148+
queueFlushJob(
149+
vm,
150+
() => {
151+
fn(...args);
152+
},
153+
flushMode as 'pre' | 'post'
154+
)) as any) as T;
155+
};
104156

105157
// effect watch
106158
if (cb === null) {
107159
const getter = () => (source as SimpleEffect)(registerCleanup);
108-
// cleanup before running getter again
109-
const runCleanup = () => {
110-
if (cleanup) {
111-
cleanup();
112-
}
113-
};
114-
115-
if (flushMode === 'sync') {
116-
// @ts-ignore: use undocumented option "sync" ahd "before"
117-
return vm.$watch(getter, noopFn, {
118-
immediate: true,
119-
deep: options.deep,
120-
sync: true,
121-
before: runCleanup,
122-
});
123-
}
124-
125-
let stopRef: Function;
126-
let hasEnded: boolean = false;
127-
const doWatch = () => {
128-
if (hasEnded) return;
160+
const watcher = createVueWatcher(vm, getter, noopFn, {
161+
noRun: true, // take control the initial gettet invoking
162+
deep: options.deep,
163+
sync: isSync,
164+
before: runCleanup,
165+
});
129166

130-
// @ts-ignore: use undocumented option "before"
131-
stopRef = vm.$watch(getter, noopFn, {
132-
immediate: false,
133-
deep: options.deep,
134-
before: runCleanup,
135-
});
136-
};
167+
// enable the watcher update
168+
watcher.lazy = false;
137169

138-
/* without a current active instance, ignore pre|post mode */
139-
if (vm === fallbackVM) {
140-
vm.$nextTick(doWatch);
170+
const originGet = watcher.get.bind(watcher);
171+
if (isSync) {
172+
watcher.get();
141173
} else {
142-
queueFlushJob(vm, doWatch, flushMode);
174+
vm.$nextTick(originGet);
143175
}
176+
watcher.get = createScheduler(originGet);
144177

145178
return () => {
146-
hasEnded = true;
147-
if (stopRef) {
148-
stopRef();
149-
runCleanup();
150-
cleanup = null;
151-
}
179+
watcher.teardown();
180+
runCleanup();
152181
};
153182
}
154183

@@ -163,48 +192,33 @@ function createWatcher(
163192

164193
const applyCb = (n: any, o: any) => {
165194
// cleanup before running cb again
166-
if (cleanup) {
167-
cleanup();
168-
}
169-
195+
runCleanup();
170196
cb(n, o, registerCleanup);
171197
};
172-
173-
const callback =
174-
flushMode === 'sync' ||
175-
/* without a current active instance, ignore pre|post mode */
176-
vm === fallbackVM
177-
? applyCb
178-
: (n: any, o: any) =>
179-
queueFlushJob(
180-
vm,
181-
() => {
182-
applyCb(n, o);
183-
},
184-
flushMode
185-
);
186-
187-
// `shiftCallback` is used to handle dirty sync effect.
188-
// The subsequent callbacks will redirect to `callback`.
189-
let shiftCallback = (n: any, o: any) => {
190-
shiftCallback = callback;
191-
applyCb(n, o);
192-
};
198+
let callback = createScheduler(applyCb);
199+
if (!options.lazy) {
200+
const originalCallbck = callback;
201+
// `shiftCallback` is used to handle the first sync effect run.
202+
// The subsequent callbacks will redirect to `callback`.
203+
let shiftCallback = (n: any, o: any) => {
204+
shiftCallback = originalCallbck;
205+
applyCb(n, o);
206+
};
207+
callback = (n: any, o: any) => {
208+
shiftCallback(n, o);
209+
};
210+
}
193211

194212
// @ts-ignore: use undocumented option "sync"
195-
const stop = vm.$watch(getter, options.lazy ? callback : shiftCallback, {
213+
const stop = vm.$watch(getter, callback, {
196214
immediate: !options.lazy,
197215
deep: options.deep,
198-
// @ts-ignore
199-
sync: flushMode === 'sync',
216+
sync: isSync,
200217
});
201218

202219
return () => {
203220
stop();
204-
if (cleanup) {
205-
cleanup();
206-
cleanup = null;
207-
}
221+
runCleanup();
208222
};
209223
}
210224

src/env.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { VueConstructor } from 'vue';
22
import { VfaState } from './vmStateManager';
3+
import { VueWatcher } from './apis/watch';
34

45
declare global {
56
interface Window {
@@ -11,6 +12,7 @@ declare module 'vue/types/vue' {
1112
interface Vue {
1213
readonly _uid: number;
1314
readonly _data: Record<string, any>;
15+
_watchers: VueWatcher[];
1416
__secret_vfa_state__?: VfaState;
1517
}
1618

src/setup.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ function updateTemplateRef(vm: ComponentInstance) {
4646
const oldRefKeys = vmStateManager.get(vm, 'refs') || [];
4747
for (let index = 0; index < oldRefKeys.length; index++) {
4848
const key = oldRefKeys[index];
49-
if (!refs[key]) {
50-
(rawBindings[key] as Ref<any>).value = null;
49+
const setupValue = rawBindings[key];
50+
if (!refs[key] && setupValue && isRef(setupValue)) {
51+
setupValue.value = null;
5152
}
5253
}
5354

test/apis/watch.spec.js

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ describe('api/watch', () => {
147147
.then(done);
148148
});
149149

150-
it('should flush after render', done => {
150+
it('should flush after render (lazy=true)', done => {
151151
let rerenderedText;
152152
const vm = new Vue({
153153
setup() {
@@ -177,6 +177,35 @@ describe('api/watch', () => {
177177
}).then(done);
178178
});
179179

180+
it('should flush after render (lazy=false)', done => {
181+
let rerenderedText;
182+
var vm = new Vue({
183+
setup() {
184+
const a = ref(1);
185+
watch(a, (newVal, oldVal) => {
186+
spy(newVal, oldVal);
187+
if (vm) {
188+
rerenderedText = vm.$el.textContent;
189+
}
190+
});
191+
return {
192+
a,
193+
};
194+
},
195+
render(h) {
196+
return h('div', this.a);
197+
},
198+
}).$mount();
199+
expect(spy).toBeCalledTimes(1);
200+
expect(spy).toHaveBeenLastCalledWith(1, undefined);
201+
vm.a = 2;
202+
waitForUpdate(() => {
203+
expect(rerenderedText).toBe('2');
204+
expect(spy).toBeCalledTimes(2);
205+
expect(spy).toHaveBeenLastCalledWith(2, 1);
206+
}).then(done);
207+
});
208+
180209
it('should flush before render', done => {
181210
const vm = new Vue({
182211
setup() {
@@ -262,6 +291,60 @@ describe('api/watch', () => {
262291
expect(spy).toHaveBeenNthCalledWith(2, 1, 0);
263292
});
264293

294+
it('should run in a expected order', done => {
295+
const result = [];
296+
var vm = new Vue({
297+
setup() {
298+
const x = ref(0);
299+
300+
// prettier-ignore
301+
watch(() => { void x.value; result.push('sync getter'); }, { flush: 'sync' });
302+
// prettier-ignore
303+
watch(() => { void x.value; result.push('pre getter'); }, { flush: 'pre' });
304+
// prettier-ignore
305+
watch(() => { void x.value; result.push('post getter'); }, { flush: 'post' });
306+
307+
// prettier-ignore
308+
watch(x, () => { result.push('sync callback') }, { flush: 'sync' })
309+
// prettier-ignore
310+
watch(x, () => { result.push('pre callback') }, { flush: 'pre' })
311+
// prettier-ignore
312+
watch(x, () => { result.push('post callback') }, { flush: 'post' })
313+
314+
const inc = () => {
315+
result.push('before inc');
316+
x.value++;
317+
result.push('after inc');
318+
};
319+
320+
return { x, inc };
321+
},
322+
template: `<div>{{x}}</div>`,
323+
}).$mount();
324+
expect(result).toEqual(['sync getter', 'sync callback', 'pre callback', 'post callback']);
325+
result.length = 0;
326+
327+
waitForUpdate(() => {
328+
expect(result).toEqual(['pre getter', 'post getter']);
329+
result.length = 0;
330+
331+
vm.inc();
332+
})
333+
.then(() => {
334+
expect(result).toEqual([
335+
'before inc',
336+
'sync getter',
337+
'sync callback',
338+
'after inc',
339+
'pre getter',
340+
'pre callback',
341+
'post getter',
342+
'post callback',
343+
]);
344+
})
345+
.then(done);
346+
});
347+
265348
describe('simple effect', () => {
266349
let renderedText;
267350
it('should work', done => {

test/templateRefs.spec.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ describe('ref', () => {
5454
vm.value = 'foo';
5555
})
5656
.then(() => {
57-
// watcher callback gets invoked after this;
57+
// vm updated. ref update occures after updated;
58+
})
59+
.then(() => {
60+
// no render cycle, empty tick
5861
})
5962
.then(() => {
6063
expect(dummy1).toBe(null);

0 commit comments

Comments
 (0)