Skip to content

Commit adad025

Browse files
authored
Merge pull request #3756 from plotly/fix-2462-match-semantics
Fix #2462: allow MATCH Input when Output has no MATCH
2 parents 1c5b518 + 0e0774f commit adad025

6 files changed

Lines changed: 429 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
1515
- [#3690](https://github.com/plotly/dash/pull/3690) Fixes Input when min or max is set to None
1616
- [#3723](https://github.com/plotly/dash/pull/3723) Fix misaligned `dcc.Slider` marks when some labels are empty strings
1717
- [#3740](https://github.com/plotly/dash/pull/3740) Fix cannot tab into dropdowns in Safari
18+
- [#2462](https://github.com/plotly/dash/issues/2462) Allow `MATCH` in `Input`/`State` when the callback's `Output` has no wildcards (fixed-id Output, no Output, or `ALL`-only wildcard Output). `ALLSMALLER` still requires a corresponding `MATCH` in an Output.
1819

1920
## [4.1.0] - 2026-03-23
2021

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

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -434,24 +434,37 @@ function findMismatchedWildcards(outputs, inputs, state, head, dispatchError) {
434434
]);
435435
}
436436
});
437+
// When the Outputs don't carry any MATCH keys (fixed-id outputs, no
438+
// outputs, or wildcard outputs with only ALL), the Inputs/State may use
439+
// MATCH freely — each firing is identified by the triggering input's
440+
// MATCH values. ALLSMALLER still requires a MATCH reference, so that
441+
// case remains an error. See issue #2462.
442+
const outputsHaveMatch = out0MatchKeys.length > 0;
437443
[
438444
[inputs, 'Input'],
439445
[state, 'State']
440446
].forEach(([args, cls]) => {
441447
args.forEach((arg, i) => {
442448
const {matchKeys, allsmallerKeys} = findWildcardKeys(arg.id);
443-
const allWildcardKeys = matchKeys.concat(allsmallerKeys);
444-
const diff = difference(allWildcardKeys, out0MatchKeys);
449+
const diffKeys = outputsHaveMatch
450+
? matchKeys.concat(allsmallerKeys)
451+
: allsmallerKeys;
452+
const diff = difference(diffKeys, out0MatchKeys);
445453
if (diff.length) {
446454
diff.sort();
455+
const outDesc = outputs.length
456+
? `Output 0 (${combineIdAndProp(outputs[0])})`
457+
: 'the (absent) Output';
447458
dispatchError('`Input` / `State` wildcards not in `Output`s', [
448459
head,
449460
`${cls} ${i} (${combineIdAndProp(arg)})`,
450461
`has MATCH or ALLSMALLER on key(s) ${diff.join(', ')}`,
451-
`where Output 0 (${combineIdAndProp(outputs[0])})`,
462+
`where ${outDesc}`,
452463
'does not have a MATCH wildcard. Inputs and State do not',
453-
'need every MATCH from the Output(s), but they cannot have',
454-
'extras beyond the Output(s).'
464+
'need every MATCH from the Output(s), but ALLSMALLER',
465+
'requires a matching MATCH in the Output(s), and when',
466+
'the Output(s) have any MATCH, Input/State MATCH keys',
467+
'must be a subset of them.'
455468
]);
456469
}
457470
});
@@ -1043,7 +1056,7 @@ export function idMatch(
10431056
return true;
10441057
}
10451058

1046-
function getAnyVals(patternVals, vals) {
1059+
export function getAnyVals(patternVals, vals) {
10471060
const matches = [];
10481061
for (let i = 0; i < patternVals.length; i++) {
10491062
if (patternVals[i] === MATCH) {
@@ -1142,7 +1155,12 @@ function addResolvedFromOutputs(callback, outPattern, outs, matches) {
11421155
});
11431156
}
11441157

1145-
export function addAllResolvedFromOutputs(resolve, paths, matches) {
1158+
export function addAllResolvedFromOutputs(
1159+
resolve,
1160+
paths,
1161+
matches,
1162+
triggerAnyVals = ''
1163+
) {
11461164
return callback => {
11471165
const {matchKeys, firstSingleOutput, outputs} = callback;
11481166
if (matchKeys.length) {
@@ -1179,7 +1197,11 @@ export function addAllResolvedFromOutputs(resolve, paths, matches) {
11791197
});
11801198
}
11811199
} else {
1182-
const cb = makeResolvedCallback(callback, resolve, '');
1200+
// Outputs have no MATCH keys (fixed-id outputs or no output).
1201+
// Fall back to the triggering input's MATCH values so that
1202+
// separate MATCH triggers produce distinct resolvedIds and
1203+
// aren't deduplicated into a single firing. See issue #2462.
1204+
const cb = makeResolvedCallback(callback, resolve, triggerAnyVals);
11831205
matches.push(cb);
11841206
}
11851207
};

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from '../types/callbacks';
2626
import {
2727
addAllResolvedFromOutputs,
28+
getAnyVals,
2829
getUnfilteredLayoutCallbacks,
2930
idMatch,
3031
isMultiValued,
@@ -72,11 +73,18 @@ export function getCallbacksByInput(
7273
}
7374
patterns.forEach(pattern => {
7475
if (idMatch(_keys, vals, pattern.values)) {
76+
// When a callback's Outputs have no MATCH keys, the
77+
// triggering Input's MATCH values are what uniquify each
78+
// firing's resolvedId (see addAllResolvedFromOutputs).
79+
// Callbacks whose Outputs do carry MATCH keys ignore this
80+
// value since the Output pattern drives resolution.
81+
const triggerAnyVals = getAnyVals(pattern.values, vals);
7582
pattern.callbacks.forEach(
7683
addAllResolvedFromOutputs(
7784
resolveDeps(_keys, vals, pattern.values),
7885
paths,
79-
matches
86+
matches,
87+
triggerAnyVals
8088
)
8189
);
8290
}
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import {expect} from 'chai';
2+
import {beforeEach, describe, it} from 'mocha';
3+
import {computeGraphs, getAnyVals} from '../src/actions/dependencies';
4+
import {getCallbacksByInput} from '../src/actions/dependencies_ts';
5+
import {EventEmitter} from '../src/actions/utils';
6+
7+
const config = {validate_callbacks: true};
8+
9+
// Build a paths fixture that matches the layout crawling output
10+
// (paths.strs for string ids, paths.objs for wildcard ids).
11+
function makePaths(stringIds, wildcardItems) {
12+
const paths = {
13+
strs: {},
14+
objs: {},
15+
events: new EventEmitter()
16+
};
17+
stringIds.forEach(id => {
18+
paths.strs[id] = ['props', 'children', 0];
19+
});
20+
Object.entries(wildcardItems || {}).forEach(([keyStr, items]) => {
21+
paths.objs[keyStr] = items.map((values, i) => ({
22+
values,
23+
path: ['props', 'children', i]
24+
}));
25+
});
26+
return paths;
27+
}
28+
29+
describe('dependencies — MATCH validation (#2462)', () => {
30+
let errors;
31+
const dispatchError = (message, lines) => {
32+
errors.push({message, lines});
33+
};
34+
35+
beforeEach(() => {
36+
errors = [];
37+
});
38+
39+
it('permits MATCH Input with fixed-id Output', () => {
40+
computeGraphs(
41+
[
42+
{
43+
output: 'out.children',
44+
inputs: [{id: '{"id":["MATCH"]}', property: 'n_clicks'}],
45+
state: [],
46+
no_output: false
47+
}
48+
],
49+
dispatchError,
50+
config
51+
);
52+
expect(errors).to.eql([]);
53+
});
54+
55+
it('permits MATCH Input with no-output callback', () => {
56+
computeGraphs(
57+
[
58+
{
59+
output: '',
60+
inputs: [{id: '{"id":["MATCH"]}', property: 'n_clicks'}],
61+
state: [],
62+
no_output: true
63+
}
64+
],
65+
dispatchError,
66+
config
67+
);
68+
expect(errors).to.eql([]);
69+
});
70+
71+
it('permits MATCH State with fixed-id Output', () => {
72+
computeGraphs(
73+
[
74+
{
75+
output: 'out.children',
76+
inputs: [{id: '{"id":["MATCH"]}', property: 'n_clicks'}],
77+
state: [{id: '{"id":["MATCH"]}', property: 'id'}],
78+
no_output: false
79+
}
80+
],
81+
dispatchError,
82+
config
83+
);
84+
expect(errors).to.eql([]);
85+
});
86+
87+
it('permits MATCH Input with ALL-only wildcard Output', () => {
88+
computeGraphs(
89+
[
90+
{
91+
output: '{"id":["ALL"]}.children',
92+
inputs: [
93+
{
94+
id: '{"type":"btn","idx":["MATCH"]}',
95+
property: 'n_clicks'
96+
}
97+
],
98+
state: [],
99+
no_output: false
100+
}
101+
],
102+
dispatchError,
103+
config
104+
);
105+
expect(errors).to.eql([]);
106+
});
107+
108+
it('still errors on ALLSMALLER Input with fixed Output', () => {
109+
computeGraphs(
110+
[
111+
{
112+
output: 'out.children',
113+
inputs: [{id: '{"id":["ALLSMALLER"]}', property: 'value'}],
114+
state: [],
115+
no_output: false
116+
}
117+
],
118+
dispatchError,
119+
config
120+
);
121+
expect(errors).to.have.lengthOf(1);
122+
expect(errors[0].message).to.equal(
123+
'`Input` / `State` wildcards not in `Output`s'
124+
);
125+
});
126+
127+
it('still errors when Output has MATCH on different keys than Input', () => {
128+
computeGraphs(
129+
[
130+
{
131+
output: '{"a":["MATCH"]}.children',
132+
inputs: [{id: '{"b":["MATCH"]}', property: 'n_clicks'}],
133+
state: [],
134+
no_output: false
135+
}
136+
],
137+
dispatchError,
138+
config
139+
);
140+
// Should produce an error because out has MATCH on "a"
141+
// but input has MATCH on "b".
142+
expect(errors).to.have.lengthOf(1);
143+
expect(errors[0].message).to.equal(
144+
'`Input` / `State` wildcards not in `Output`s'
145+
);
146+
});
147+
148+
it('still errors on Mismatched MATCH across Outputs', () => {
149+
computeGraphs(
150+
[
151+
{
152+
output: '..{"b":["MATCH"]}.children...{"b":["ALL"],"c":1}.children..',
153+
inputs: [
154+
{id: '{"b":["MATCH"],"c":2}', property: 'children'}
155+
],
156+
state: [],
157+
no_output: false
158+
}
159+
],
160+
dispatchError,
161+
config
162+
);
163+
const msgs = errors.map(e => e.message);
164+
expect(msgs).to.include(
165+
'Mismatched `MATCH` wildcards across `Output`s'
166+
);
167+
});
168+
});
169+
170+
describe('dependencies — MATCH trigger resolvedId (#2462)', () => {
171+
it('getAnyVals picks MATCH values from trigger id', () => {
172+
// Use the same object reference for MATCH that the module uses
173+
// internally by exercising computeGraphs first.
174+
const errors = [];
175+
const graphs = computeGraphs(
176+
[
177+
{
178+
output: 'out.children',
179+
inputs: [{id: '{"id":["MATCH"]}', property: 'n_clicks'}],
180+
state: [],
181+
no_output: false
182+
}
183+
],
184+
(m, l) => errors.push({m, l}),
185+
config
186+
);
187+
expect(errors).to.eql([]);
188+
const pattern = graphs.inputPatterns.id.n_clicks[0];
189+
const anyVals = getAnyVals(pattern.values, ['btn-1']);
190+
expect(anyVals).to.equal('["btn-1"]');
191+
});
192+
193+
it('fires distinct callbacks per MATCH trigger when Output is fixed', () => {
194+
const errors = [];
195+
const graphs = computeGraphs(
196+
[
197+
{
198+
output: 'out.children',
199+
inputs: [{id: '{"id":["MATCH"]}', property: 'n_clicks'}],
200+
state: [],
201+
no_output: false
202+
}
203+
],
204+
(m, l) => errors.push({m, l}),
205+
config
206+
);
207+
expect(errors).to.eql([]);
208+
209+
const paths = makePaths(['out'], {
210+
id: [['btn-1'], ['btn-2']]
211+
});
212+
213+
const first = getCallbacksByInput(
214+
graphs,
215+
paths,
216+
{id: 'btn-1'},
217+
'n_clicks',
218+
undefined,
219+
false
220+
);
221+
const second = getCallbacksByInput(
222+
graphs,
223+
paths,
224+
{id: 'btn-2'},
225+
'n_clicks',
226+
undefined,
227+
false
228+
);
229+
230+
expect(first).to.have.lengthOf(1);
231+
expect(second).to.have.lengthOf(1);
232+
expect(first[0].resolvedId).to.not.equal(second[0].resolvedId);
233+
expect(first[0].resolvedId).to.include('btn-1');
234+
expect(second[0].resolvedId).to.include('btn-2');
235+
});
236+
});

0 commit comments

Comments
 (0)