Fix dcc.Patch() disturbing pre-existing components (re-run callbacks + wiped persistence) - #3938
Conversation
* As each Patch() operation is applied, record which component ids are created and which props are written * Store one PatchAnalysis per output that returned a Patch, keyed by the output's id, on the callback result's patchedOutputs map * To be used to for tracking what the Patch actually created vs elements carried over unchanged
* Before, when a Patch added or rebuilt a container's children list,
getUnfilteredLayoutCallbacks re-fired the initial call for every
MATCH/ALL callback bound to a component, even if it already existed
before the Patch
* Now, initial calls are gated on Patch operations actually change
* patchAnalysis.ts records each patch operation and which component
ids are being created
* handleOneId suppresses the initial call only for ids the patch did
not create. This also correctly fires for a component rebuilt with
an id that was already in use, even when its new defaults happen to
coincide with the prior occupant's values
* Add regression tests test_wildcards: 11, 12, 13
* applyPersistence ran unconditionally on every component reachable from a Patch result, including ones the Patch carried over unchanged For a persisted component, that meant the just-carried-over value was treated as a fresh server default and overwrote the user's stored edit * Now skip persistence restoration, for components the patch did not create, by having persistenceMods consults the PatchAnalysis via isUntouchedByPatch, so a component genuinely carried over keeps its persisted value, while one rebuilt with a reused id still gets its persisted value restored * * Add regression tests to test_persistence: 15, 16, 17
|
| ### Fixed | ||
| - [#3916](https://github.com/plotly/dash/pull/3916) Fixed a regression where dragging multiple files into `dcc.Upload` would upload only the first file when `multiple=True` | ||
| - [#3922](https://github.com/plotly/dash/pull/3922) Fix `dcc.Input(type="number")` stepper behavior when only `min` is set. | ||
| - [#???](https://github.com/plotly/dash/pull/???) Fix `dcc.Patch()` re-running the initial callbacks of components that were already on the page, including every matching (`MATCH`/`ALL`) element, and wiping their user-edited persisted values. Fixes [#3681](https://github.com/plotly/dash/issues/3681) and [#3937](https://github.com/plotly/dash/issues/3937) |
| analysis: PatchAnalysis | undefined, | ||
| property: string | ||
| ): PatchAnalysis | undefined { | ||
| return analysis && analysis.patchedProps[property] ? analysis : undefined; |
There was a problem hiding this comment.
Prefer using optional chaining:
return analysis?.patchedProps[property] ? analysis : undefined;| if (isPatch(outputValue)) { | ||
| // One analysis per output, shared by all of its | ||
| // patched props | ||
| patchedOutputs[idStr] = | ||
| patchedOutputs[idStr] || | ||
| createPatchAnalysis(); | ||
| } | ||
| const oldProps = | ||
| path( | ||
| outputPath.concat(['props']), | ||
| currentLayout | ||
| ) || {}; | ||
| const newProps = parsePatchProps( | ||
| {[propName]: outputValue}, | ||
| oldProps | ||
| oldProps, | ||
| patchedOutputs[idStr] | ||
| ); |
There was a problem hiding this comment.
This code is duplicated from L981-1016, might be worth it now to refactor into a function.
| * Everything else in the resulting tree was carried over from the previous | ||
| * layout, whatever its `props` reference says | ||
| */ | ||
| export type PatchAnalysis = { |
There was a problem hiding this comment.
One question about how the analysis gets consumed, though:
The two consumers use different predicates:
handleOneId(callback suppression) usesisCarriedOverByPatch, which only checksfreshIdspersistenceModsusesisUntouchedByPatch, which checksfreshIdsandwrittenProps
So a component whose prop the patch wrote (but didn't recreate) counts as "carried over" on the callback side, and its initial calls get suppressed. I think that can strand a dependent component:
# both slider and badge already exist inside the patched container
p = Patch()
p[0]["props"]["children"][0]["props"]["value"] = 5 # writes slider.value
return pwith a callback Input(slider, "value") -> Output(badge, "children") where the badge is also inside the patched chunk:
- output side: badge isn't in
freshIds, soisCarriedOverByPatchsuppresses its initial call - input side: all of the callback's outputs are inside the chunk, so the existing
chunkPathfilter drops it too
On dev today the over-firing bug incidentally keeps this callback alive, so the badge updates. With this PR I believe it never fires and the badge goes stale. test_cbwc014 writes deep-input.value but nothing listens to that value, so this path isn't exercised.
The write here lands on the callback's input (slider.value), while the stranded callback outputs to badge. So the fix can't live on the output side — isUntouchedByPatch is keyed on the output id (badge), which the patch neither created nor wrote, so it would still read as carried-over. The gap is really in the input-side chunkPath filter: it drops a callback whose outputs are all inside the chunk because it assumes the chunk's initial-call path will cover it, but that path is (correctly) suppressed for the carried-over output, so nothing fires. Could the filter make an exception when the triggering input prop is in writtenProps? Either way, a test with a listener on the patch-written prop (not just a write nothing consumes, like test_cbwc014) would pin the semantics down.
This is an app to repro:
"""
Repro for the freshIds/writtenProps asymmetry question on plotly/dash#3938.
Scenario:
- `num` (an input) and `badge` both live inside `container` and exist
before any patch
- a callback listens to Input(num.value) and writes Output(badge.children);
both ends are INSIDE the patched chunk
- clicking "Patch" returns a Patch() on container.children that
1. writes num.value -> writtenProps case (component NOT recreated)
2. appends a brand-new div -> freshIds case (control: proves the
suppression isn't just total)
What to look for after clicking "Patch" once:
On dev / released dash (over-firing bug present):
num shows the patched value AND badge updates to match
(kept alive incidentally by the spurious initial-call refire)
On the PR branch, IF the asymmetry is real:
num shows the patched value but badge keeps its old text -> STALE
- output side: badge is not in freshIds -> initial call suppressed
- input side: all the callback's outputs are inside the chunk
-> the existing chunkPath filter drops it too
On the PR branch, IF something else covers it:
badge updates anyway -> soften the review comment into
"can we add a test pinning this down?"
The on-page counters make it visible without opening devtools:
- badge callback count stuck at 1 after patching = stale (only page load)
- new-div MATCH count should track patches applied (control staying healthy)
"""
import threading
from dash import ALL, Dash, Input, Output, Patch, dcc, html
lock = threading.Lock()
calls = {"badge": 0, "new_div": 0}
def bump(name):
with lock:
calls[name] += 1
app = Dash(__name__, suppress_callback_exceptions=True)
app.layout = html.Div(
[
html.Button("Patch", id="patch-btn", n_clicks=0),
html.Div(
[
dcc.Input(id="num", type="number", value=1),
html.Div("badge: (initial)", id="badge"),
],
id="container",
),
html.Hr(),
html.Div(id="badge-calls"),
html.Div(id="new-div-calls"),
html.Div("patch not applied yet", id="done"),
dcc.Interval(id="tick", interval=500),
]
)
@app.callback(
Output("container", "children"),
Output("done", "children"),
Input("patch-btn", "n_clicks"),
prevent_initial_call=True,
)
def do_patch(n):
p = Patch()
# writtenProps: write a prop on the EXISTING input (index 0 of children)
p[0]["props"]["value"] = 100 + n
# freshIds: append a genuinely new component as a control
p.append(html.Div("appended (waiting)", id={"type": "new-div", "index": n}))
return p, f"patch #{n} applied (num should now read {100 + n})"
# The callback under test: input AND output both inside the patched chunk.
@app.callback(Output("badge", "children"), Input("num", "value"))
def update_badge(value):
bump("badge")
return f"badge: num value is {value}"
# Control: initial call for the appended (fresh) components should still fire.
@app.callback(
Output({"type": "new-div", "index": ALL}, "children"),
Input({"type": "new-div", "index": ALL}, "id"),
prevent_initial_call=False,
)
def new_div_initial(ids):
bump("new_div")
return [f"appended #{i['index']} (initial call ran)" for i in ids]
@app.callback(
Output("badge-calls", "children"),
Output("new-div-calls", "children"),
Input("tick", "n_intervals"),
)
def show_counts(_):
with lock:
return (
f"badge callback fired: {calls['badge']} time(s) "
"(stuck at 1 after patching = STALE; increments = updated)",
f"new-div ALL callback fired: {calls['new_div']} time(s) "
"(should increment with each patch)",
)
if __name__ == "__main__":
app.run(debug=True)


Fixes #3681 and #3937
Problem
When a
dcc.Patch()added or rebuilt components inside a container, two separate bugs trigger,effecting components which already existed before the Patch:
MATCH/ALLoutput, so appending one newpattern matching id'd
dcc.Sliderreruns the initial callback for every preexisting sliderbecause the patch added a new sibling
I tracked both items back to the way Patch resolution distinguishes components carried over from the
pre-Patch layout from freshly built ones
Fix
Snapshot the paths table before a Patch is resolved
Callbacks can suppress the initial call for carried-over components, by detecting props
reference identity (
child.props === oldPropsRef). ramda'sassocPathis structurally immutable,so untouched nodes keep their exact
propsreference while rebuilt/replaced node gets a new oneapplyPersistencenow skips components that already existed pre-Patch, instead of re-runningmodPropand misreading the carried over value as a "server override" that clears the stored editContributor Checklist
I've been doing a lot of work with dynamic UIs and creating extensible elements. I am loving the patch system, but I found some significant drawbacks to the both in performance and usability.
At first, I thought the fix for the initial callbacks was simple and just checking the ID more carefully would be enough, but it ended up being requiring adding an 'audit trail' for patches, which also ended up fixing the persistence issues.
Javascript isn't my 'first language', so let me know if there are any things I'm doing awkwardly, or best practices I'm missing. Happy to fix those up.
Or I understand if this is too complicated and not exactly needed. I looked for simpler options, but I couldn't find a way to differentiate patches and understand their impact in any other way, but it would be great if one existed and I just didn't see it.
optionals
CHANGELOG.md