Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- [#3690](https://github.com/plotly/dash/pull/3690) Fixes Input when min or max is set to None
- [#3723](https://github.com/plotly/dash/pull/3723) Fix misaligned `dcc.Slider` marks when some labels are empty strings
- [#3740](https://github.com/plotly/dash/pull/3740) Fix cannot tab into dropdowns in Safari
- [#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.

## [4.1.0] - 2026-03-23

Expand Down
38 changes: 30 additions & 8 deletions dash/dash-renderer/src/actions/dependencies.js
Original file line number Diff line number Diff line change
Expand Up @@ -434,24 +434,37 @@ function findMismatchedWildcards(outputs, inputs, state, head, dispatchError) {
]);
}
});
// When the Outputs don't carry any MATCH keys (fixed-id outputs, no
// outputs, or wildcard outputs with only ALL), the Inputs/State may use
// MATCH freely — each firing is identified by the triggering input's
// MATCH values. ALLSMALLER still requires a MATCH reference, so that
// case remains an error. See issue #2462.
const outputsHaveMatch = out0MatchKeys.length > 0;
[
[inputs, 'Input'],
[state, 'State']
].forEach(([args, cls]) => {
args.forEach((arg, i) => {
const {matchKeys, allsmallerKeys} = findWildcardKeys(arg.id);
const allWildcardKeys = matchKeys.concat(allsmallerKeys);
const diff = difference(allWildcardKeys, out0MatchKeys);
const diffKeys = outputsHaveMatch
? matchKeys.concat(allsmallerKeys)
: allsmallerKeys;
const diff = difference(diffKeys, out0MatchKeys);
if (diff.length) {
diff.sort();
const outDesc = outputs.length
? `Output 0 (${combineIdAndProp(outputs[0])})`
: 'the (absent) Output';
dispatchError('`Input` / `State` wildcards not in `Output`s', [
head,
`${cls} ${i} (${combineIdAndProp(arg)})`,
`has MATCH or ALLSMALLER on key(s) ${diff.join(', ')}`,
`where Output 0 (${combineIdAndProp(outputs[0])})`,
`where ${outDesc}`,
'does not have a MATCH wildcard. Inputs and State do not',
'need every MATCH from the Output(s), but they cannot have',
'extras beyond the Output(s).'
'need every MATCH from the Output(s), but ALLSMALLER',
'requires a matching MATCH in the Output(s), and when',
'the Output(s) have any MATCH, Input/State MATCH keys',
'must be a subset of them.'
]);
}
});
Expand Down Expand Up @@ -1043,7 +1056,7 @@ export function idMatch(
return true;
}

function getAnyVals(patternVals, vals) {
export function getAnyVals(patternVals, vals) {
const matches = [];
for (let i = 0; i < patternVals.length; i++) {
if (patternVals[i] === MATCH) {
Expand Down Expand Up @@ -1142,7 +1155,12 @@ function addResolvedFromOutputs(callback, outPattern, outs, matches) {
});
}

export function addAllResolvedFromOutputs(resolve, paths, matches) {
export function addAllResolvedFromOutputs(
resolve,
paths,
matches,
triggerAnyVals = ''
) {
return callback => {
const {matchKeys, firstSingleOutput, outputs} = callback;
if (matchKeys.length) {
Expand Down Expand Up @@ -1179,7 +1197,11 @@ export function addAllResolvedFromOutputs(resolve, paths, matches) {
});
}
} else {
const cb = makeResolvedCallback(callback, resolve, '');
// Outputs have no MATCH keys (fixed-id outputs or no output).
// Fall back to the triggering input's MATCH values so that
// separate MATCH triggers produce distinct resolvedIds and
// aren't deduplicated into a single firing. See issue #2462.
const cb = makeResolvedCallback(callback, resolve, triggerAnyVals);
matches.push(cb);
}
};
Expand Down
10 changes: 9 additions & 1 deletion dash/dash-renderer/src/actions/dependencies_ts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from '../types/callbacks';
import {
addAllResolvedFromOutputs,
getAnyVals,
getUnfilteredLayoutCallbacks,
idMatch,
isMultiValued,
Expand Down Expand Up @@ -72,11 +73,18 @@ export function getCallbacksByInput(
}
patterns.forEach(pattern => {
if (idMatch(_keys, vals, pattern.values)) {
// When a callback's Outputs have no MATCH keys, the
// triggering Input's MATCH values are what uniquify each
// firing's resolvedId (see addAllResolvedFromOutputs).
// Callbacks whose Outputs do carry MATCH keys ignore this
// value since the Output pattern drives resolution.
const triggerAnyVals = getAnyVals(pattern.values, vals);
pattern.callbacks.forEach(
addAllResolvedFromOutputs(
resolveDeps(_keys, vals, pattern.values),
paths,
matches
matches,
triggerAnyVals
)
);
}
Expand Down
236 changes: 236 additions & 0 deletions dash/dash-renderer/tests/dependencies.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import {expect} from 'chai';
import {beforeEach, describe, it} from 'mocha';
import {computeGraphs, getAnyVals} from '../src/actions/dependencies';
import {getCallbacksByInput} from '../src/actions/dependencies_ts';
import {EventEmitter} from '../src/actions/utils';

const config = {validate_callbacks: true};

// Build a paths fixture that matches the layout crawling output
// (paths.strs for string ids, paths.objs for wildcard ids).
function makePaths(stringIds, wildcardItems) {
const paths = {
strs: {},
objs: {},
events: new EventEmitter()
};
stringIds.forEach(id => {
paths.strs[id] = ['props', 'children', 0];
});
Object.entries(wildcardItems || {}).forEach(([keyStr, items]) => {
paths.objs[keyStr] = items.map((values, i) => ({
values,
path: ['props', 'children', i]
}));
});
return paths;
}

describe('dependencies — MATCH validation (#2462)', () => {
let errors;
const dispatchError = (message, lines) => {
errors.push({message, lines});
};

beforeEach(() => {
errors = [];
});

it('permits MATCH Input with fixed-id Output', () => {
computeGraphs(
[
{
output: 'out.children',
inputs: [{id: '{"id":["MATCH"]}', property: 'n_clicks'}],
state: [],
no_output: false
}
],
dispatchError,
config
);
expect(errors).to.eql([]);
});

it('permits MATCH Input with no-output callback', () => {
computeGraphs(
[
{
output: '',
inputs: [{id: '{"id":["MATCH"]}', property: 'n_clicks'}],
state: [],
no_output: true
}
],
dispatchError,
config
);
expect(errors).to.eql([]);
});

it('permits MATCH State with fixed-id Output', () => {
computeGraphs(
[
{
output: 'out.children',
inputs: [{id: '{"id":["MATCH"]}', property: 'n_clicks'}],
state: [{id: '{"id":["MATCH"]}', property: 'id'}],
no_output: false
}
],
dispatchError,
config
);
expect(errors).to.eql([]);
});

it('permits MATCH Input with ALL-only wildcard Output', () => {
computeGraphs(
[
{
output: '{"id":["ALL"]}.children',
inputs: [
{
id: '{"type":"btn","idx":["MATCH"]}',
property: 'n_clicks'
}
],
state: [],
no_output: false
}
],
dispatchError,
config
);
expect(errors).to.eql([]);
});

it('still errors on ALLSMALLER Input with fixed Output', () => {
computeGraphs(
[
{
output: 'out.children',
inputs: [{id: '{"id":["ALLSMALLER"]}', property: 'value'}],
state: [],
no_output: false
}
],
dispatchError,
config
);
expect(errors).to.have.lengthOf(1);
expect(errors[0].message).to.equal(
'`Input` / `State` wildcards not in `Output`s'
);
});

it('still errors when Output has MATCH on different keys than Input', () => {
computeGraphs(
[
{
output: '{"a":["MATCH"]}.children',
inputs: [{id: '{"b":["MATCH"]}', property: 'n_clicks'}],
state: [],
no_output: false
}
],
dispatchError,
config
);
// Should produce an error because out has MATCH on "a"
// but input has MATCH on "b".
expect(errors).to.have.lengthOf(1);
expect(errors[0].message).to.equal(
'`Input` / `State` wildcards not in `Output`s'
);
});

it('still errors on Mismatched MATCH across Outputs', () => {
computeGraphs(
[
{
output: '..{"b":["MATCH"]}.children...{"b":["ALL"],"c":1}.children..',
inputs: [
{id: '{"b":["MATCH"],"c":2}', property: 'children'}
],
state: [],
no_output: false
}
],
dispatchError,
config
);
const msgs = errors.map(e => e.message);
expect(msgs).to.include(
'Mismatched `MATCH` wildcards across `Output`s'
);
});
});

describe('dependencies — MATCH trigger resolvedId (#2462)', () => {
it('getAnyVals picks MATCH values from trigger id', () => {
// Use the same object reference for MATCH that the module uses
// internally by exercising computeGraphs first.
const errors = [];
const graphs = computeGraphs(
[
{
output: 'out.children',
inputs: [{id: '{"id":["MATCH"]}', property: 'n_clicks'}],
state: [],
no_output: false
}
],
(m, l) => errors.push({m, l}),
config
);
expect(errors).to.eql([]);
const pattern = graphs.inputPatterns.id.n_clicks[0];
const anyVals = getAnyVals(pattern.values, ['btn-1']);
expect(anyVals).to.equal('["btn-1"]');
});

it('fires distinct callbacks per MATCH trigger when Output is fixed', () => {
const errors = [];
const graphs = computeGraphs(
[
{
output: 'out.children',
inputs: [{id: '{"id":["MATCH"]}', property: 'n_clicks'}],
state: [],
no_output: false
}
],
(m, l) => errors.push({m, l}),
config
);
expect(errors).to.eql([]);

const paths = makePaths(['out'], {
id: [['btn-1'], ['btn-2']]
});

const first = getCallbacksByInput(
graphs,
paths,
{id: 'btn-1'},
'n_clicks',
undefined,
false
);
const second = getCallbacksByInput(
graphs,
paths,
{id: 'btn-2'},
'n_clicks',
undefined,
false
);

expect(first).to.have.lengthOf(1);
expect(second).to.have.lengthOf(1);
expect(first[0].resolvedId).to.not.equal(second[0].resolvedId);
expect(first[0].resolvedId).to.include('btn-1');
expect(second[0].resolvedId).to.include('btn-2');
});
});
Loading
Loading