diff --git a/CHANGELOG.md b/CHANGELOG.md index 962b384db7..53947090a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/dash/dash-renderer/src/actions/dependencies.js b/dash/dash-renderer/src/actions/dependencies.js index 2ce6d0d577..7b5d1665f0 100644 --- a/dash/dash-renderer/src/actions/dependencies.js +++ b/dash/dash-renderer/src/actions/dependencies.js @@ -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.' ]); } }); @@ -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) { @@ -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) { @@ -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); } }; diff --git a/dash/dash-renderer/src/actions/dependencies_ts.ts b/dash/dash-renderer/src/actions/dependencies_ts.ts index 75cbdc66dd..33f968cf91 100644 --- a/dash/dash-renderer/src/actions/dependencies_ts.ts +++ b/dash/dash-renderer/src/actions/dependencies_ts.ts @@ -25,6 +25,7 @@ import { } from '../types/callbacks'; import { addAllResolvedFromOutputs, + getAnyVals, getUnfilteredLayoutCallbacks, idMatch, isMultiValued, @@ -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 ) ); } diff --git a/dash/dash-renderer/tests/dependencies.test.js b/dash/dash-renderer/tests/dependencies.test.js new file mode 100644 index 0000000000..8051e4f010 --- /dev/null +++ b/dash/dash-renderer/tests/dependencies.test.js @@ -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'); + }); +}); diff --git a/tests/integration/callbacks/test_wildcards.py b/tests/integration/callbacks/test_wildcards.py index 9fd7337040..c151e8c277 100644 --- a/tests/integration/callbacks/test_wildcards.py +++ b/tests/integration/callbacks/test_wildcards.py @@ -6,7 +6,18 @@ from dash.testing import wait import dash -from dash import Dash, Input, Output, State, ALL, ALLSMALLER, MATCH, html, dcc +from dash import ( + Dash, + Input, + Output, + State, + ALL, + ALLSMALLER, + MATCH, + html, + dcc, + set_props, +) from tests.assets.todo_app import todo_app from tests.assets.grouping_app import grouping_app @@ -619,3 +630,84 @@ def on_click(_) -> str: assert not dash_duo.find_element("#buttons button:nth-child(2)").get_attribute( "disabled" ) + + +def test_cbwc009_match_input_fixed_output(dash_duo): + # Issue #2462: allow MATCH in Input with a fixed-id Output. + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button( + "Alpha", + id={"type": "btn", "index": "alpha"}, + ), + html.Button( + "Beta", + id={"type": "btn", "index": "beta"}, + ), + html.Div("initial", id="out"), + ] + ) + + @app.callback( + Output("out", "children"), + Input({"type": "btn", "index": MATCH}, "n_clicks"), + State({"type": "btn", "index": MATCH}, "id"), + prevent_initial_call=True, + ) + def show_clicked(_, id_): + return f"clicked {id_['index']}" + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#out", "initial") + + dash_duo.find_element( + '[id=\\{\\"index\\"\\:\\"alpha\\"\\,\\"type\\"\\:\\"btn\\"\\}]' + ).click() + dash_duo.wait_for_text_to_equal("#out", "clicked alpha") + + dash_duo.find_element( + '[id=\\{\\"index\\"\\:\\"beta\\"\\,\\"type\\"\\:\\"btn\\"\\}]' + ).click() + dash_duo.wait_for_text_to_equal("#out", "clicked beta") + + assert dash_duo.get_logs() == [] + + +def test_cbwc010_match_input_no_output(dash_duo): + # Issue #2462: allow MATCH in Input with no Output (set_props). + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button( + "One", + id={"type": "btn", "index": 1}, + ), + html.Button( + "Two", + id={"type": "btn", "index": 2}, + ), + html.Div("initial", id="out"), + ] + ) + + @app.callback( + Input({"type": "btn", "index": MATCH}, "n_clicks"), + State({"type": "btn", "index": MATCH}, "id"), + prevent_initial_call=True, + ) + def announce(_, id_): + set_props("out", {"children": f"clicked index={id_['index']}"}) + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#out", "initial") + + dash_duo.find_element('[id=\\{\\"index\\"\\:1\\,\\"type\\"\\:\\"btn\\"\\}]').click() + dash_duo.wait_for_text_to_equal("#out", "clicked index=1") + + dash_duo.find_element('[id=\\{\\"index\\"\\:2\\,\\"type\\"\\:\\"btn\\"\\}]').click() + dash_duo.wait_for_text_to_equal("#out", "clicked index=2") + + assert dash_duo.get_logs() == [] diff --git a/tests/integration/devtools/test_callback_validation.py b/tests/integration/devtools/test_callback_validation.py index 59b874012e..eaee814980 100644 --- a/tests/integration/devtools/test_callback_validation.py +++ b/tests/integration/devtools/test_callback_validation.py @@ -1,7 +1,18 @@ import flask import pytest -from dash import Dash, Input, Output, State, MATCH, ALL, ALLSMALLER, html, dcc +from dash import ( + Dash, + Input, + Output, + State, + MATCH, + ALL, + ALLSMALLER, + html, + dcc, + set_props, +) from dash.testing import wait debugging = dict( @@ -815,3 +826,51 @@ def c2(children): ] ] check_errors(dash_duo, specs) + + +def test_dvcv017_match_input_permitted_no_output_match(dash_duo): + # Issue #2462: when Outputs don't carry MATCH wildcards, Inputs/State + # may use MATCH freely. These callbacks should load without errors. + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button("btn", id={"type": "btn", "idx": 1}), + html.Div(id="out-a"), + html.Div(id="out-b"), + html.Div(id={"type": "wild", "idx": 1}), + html.Div(id={"type": "wild", "idx": 2}), + ] + ) + + # fixed Output + MATCH Input + @app.callback( + Output("out-a", "children"), + Input({"type": "btn", "idx": MATCH}, "n_clicks"), + prevent_initial_call=True, + ) + def a(_): + return "a" + + # no-Output + MATCH Input + @app.callback( + Input({"type": "btn", "idx": MATCH}, "n_clicks"), + prevent_initial_call=True, + ) + def b(_): + set_props("out-b", {"children": "b"}) + + # ALL-only wildcard Output + MATCH Input on a different key + @app.callback( + Output({"type": "wild", "idx": ALL}, "children"), + Input({"type": "btn", "idx": MATCH}, "n_clicks"), + prevent_initial_call=True, + ) + def c(_): + return ["c", "c"] + + dash_duo.start_server(app, **debugging) + + # All three callbacks should register without validation errors. + wait.until(lambda: ~dash_duo.redux_state_is_loading, 2) + dash_duo.wait_for_no_elements(dash_duo.devtools_error_count_locator) + assert dash_duo.get_logs() == []