Skip to content

Commit de63ffc

Browse files
Fix patch asymmetry when an Input is patched but the Output is unchanged
* Update handleThisCallback running based off of if the callback was written by patch instead of just occuring in the chunk path
1 parent bd475c0 commit de63ffc

3 files changed

Lines changed: 150 additions & 16 deletions

File tree

dash/dash-renderer/src/actions/dependencies.js

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737
resolveDeps
3838
} from './dependencies_ts';
3939
import {computePaths, getPath} from './paths';
40-
import {isCarriedOverByPatch} from './patchAnalysis';
40+
import {isCarriedOverByPatch, wasWrittenByPatch} from './patchAnalysis';
4141

4242
import {crawlLayout} from './utils';
4343

@@ -1265,6 +1265,10 @@ export function getWatchedKeys(id, newProps, graphs) {
12651265
* chunk count as having changed
12661266
* opts.patchAnalysis: what the `Patch()` operations that produced this chunk
12671267
* changed. Only the components the patch created get their initial call
1268+
* It also allows an input the patch wrote directly to bypass the chunkPath
1269+
* dedup, when that input's own component was carried over (so
1270+
* its own initial call stays suppressed) but downstream callbacks still
1271+
* need to see the new value
12681272
* Absent when the chunk is not the result of a patch
12691273
*
12701274
* Returns an array of objects:
@@ -1353,23 +1357,36 @@ export function getUnfilteredLayoutCallbacks(graphs, paths, layoutChunk, opts) {
13531357
}
13541358
}
13551359
if (!outputsOnly && inIdCallbacks) {
1360+
const idStr = stringifyId(id);
13561361
const maybeAddCallback = removedArrayInputsOnly
1357-
? addCallbackIfArray(stringifyId(id))
1362+
? addCallbackIfArray(idStr)
13581363
: addCallback;
1359-
let handleThisCallback = maybeAddCallback;
1360-
if (chunkPath) {
1361-
handleThisCallback = cb => {
1362-
if (
1363-
!all(
1364-
startsWith(chunkPath),
1365-
pluck('path', flatten(cb.getOutputs(paths)))
1366-
)
1367-
) {
1368-
maybeAddCallback(cb);
1369-
}
1370-
};
1371-
}
13721364
for (const property in inIdCallbacks) {
1365+
// A callback, whose outputs are all inside the chunk, is
1366+
// normally dropped here on the assumption that the
1367+
// output handling above already covers it
1368+
// That assumption fails when the patch
1369+
// wrote a new value directly on this input without
1370+
// recreating the input's own component
1371+
// The output side stays suppressed because the output
1372+
// component was carried over, but this input's value
1373+
// genuinely changed, so the callback must still be added
1374+
let handleThisCallback = maybeAddCallback;
1375+
if (
1376+
chunkPath &&
1377+
!wasWrittenByPatch(patchAnalysis, idStr, property)
1378+
) {
1379+
handleThisCallback = cb => {
1380+
if (
1381+
!all(
1382+
startsWith(chunkPath),
1383+
pluck('path', flatten(cb.getOutputs(paths)))
1384+
)
1385+
) {
1386+
maybeAddCallback(cb);
1387+
}
1388+
};
1389+
}
13731390
getCallbacksByInput(
13741391
graphs,
13751392
paths,

dash/dash-renderer/src/actions/patchAnalysis.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,21 @@ export function isUntouchedByPatch(
9393
}
9494
return !analysis.freshIds[idStr] && !analysis.writtenProps[idStr];
9595
}
96+
97+
/*
98+
* Did the patch write directly on this prop of a component it did not
99+
* recreate? This can be true for a component whose own initial call stays
100+
* suppressed by `isCarriedOverByPatch`, it's not fresh, even though one of
101+
* its props changed, callbacks that depend on that prop as an Input
102+
* still need to run
103+
*/
104+
export function wasWrittenByPatch(
105+
analysis: PatchAnalysis | undefined,
106+
idStr: string | undefined | null,
107+
property: string
108+
): boolean {
109+
if (!analysis || !idStr) {
110+
return false;
111+
}
112+
return Boolean(analysis.writtenProps[idStr]?.[property]);
113+
}

dash/dash-renderer/tests/dependencies.test.js

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import {expect} from 'chai';
22
import {beforeEach, describe, it} from 'mocha';
3-
import {computeGraphs, getAnyVals} from '../src/actions/dependencies';
3+
import {
4+
computeGraphs,
5+
getAnyVals,
6+
getUnfilteredLayoutCallbacks
7+
} from '../src/actions/dependencies';
48
import {getCallbacksByInput} from '../src/actions/dependencies_ts';
59
import {EventEmitter} from '../src/actions/utils';
610

@@ -234,3 +238,98 @@ describe('dependencies — MATCH trigger resolvedId (#2462)', () => {
234238
expect(second[0].resolvedId).to.include('btn-2');
235239
});
236240
});
241+
242+
describe('dependencies: getUnfilteredLayoutCallbacks with a Patch (#3938)', () => {
243+
// Create a layout with two elements
244+
// num: Input which patch writes to `value` directly
245+
// badge: Output which `children` are written to, but unchanged by the patch
246+
247+
// A callback listens to Input(num.value) and writes Output(badge.children)
248+
// Both the input and the output live inside the same patched chunk
249+
function makeGraphsAndPaths() {
250+
const errors = [];
251+
const graphs = computeGraphs(
252+
[
253+
{
254+
output: 'badge.children',
255+
inputs: [{id: 'num', property: 'value'}],
256+
state: [],
257+
no_output: false
258+
}
259+
],
260+
(m, l) => errors.push({m, l}),
261+
config
262+
);
263+
expect(errors).to.eql([]);
264+
const paths = makePaths(['container', 'num', 'badge']);
265+
const layoutChunk = {
266+
props: {
267+
id: 'container',
268+
children: [
269+
{props: {id: 'num', value: 100}},
270+
{props: {id: 'badge', children: 'badge: stale'}}
271+
]
272+
}
273+
};
274+
return {graphs, paths, layoutChunk};
275+
}
276+
277+
it('keeps a callback alive when the patch wrote its Input directly, even though its Output was carried over', () => {
278+
const {graphs, paths, layoutChunk} = makeGraphsAndPaths();
279+
280+
const patchAnalysis = {
281+
patchedProps: {children: true},
282+
freshIds: {},
283+
writtenProps: {num: {value: true}}
284+
};
285+
286+
const callbacks = getUnfilteredLayoutCallbacks(
287+
graphs,
288+
paths,
289+
layoutChunk,
290+
{chunkPath: ['props', 'children'], patchAnalysis}
291+
);
292+
293+
expect(callbacks).to.have.lengthOf(1);
294+
expect(callbacks[0].resolvedId).to.equal('badge.children');
295+
});
296+
297+
it('still drops a carried over callback when neither its Input nor Output was touched by the patch', () => {
298+
const {graphs, paths, layoutChunk} = makeGraphsAndPaths();
299+
300+
const patchAnalysis = {
301+
patchedProps: {children: true},
302+
freshIds: {},
303+
writtenProps: {}
304+
};
305+
306+
const callbacks = getUnfilteredLayoutCallbacks(
307+
graphs,
308+
paths,
309+
layoutChunk,
310+
{chunkPath: ['props', 'children'], patchAnalysis}
311+
);
312+
313+
expect(callbacks).to.have.lengthOf(0);
314+
});
315+
316+
it('still runs the callback via its Output when the Output component is fresh', () => {
317+
const {graphs, paths, layoutChunk} = makeGraphsAndPaths();
318+
319+
const patchAnalysis = {
320+
patchedProps: {children: true},
321+
freshIds: {badge: true},
322+
writtenProps: {}
323+
};
324+
325+
const callbacks = getUnfilteredLayoutCallbacks(
326+
graphs,
327+
paths,
328+
layoutChunk,
329+
{chunkPath: ['props', 'children'], patchAnalysis}
330+
);
331+
332+
expect(callbacks).to.have.lengthOf(1);
333+
expect(callbacks[0].resolvedId).to.equal('badge.children');
334+
});
335+
});

0 commit comments

Comments
 (0)