Skip to content

Commit 4b8917e

Browse files
authored
Merge pull request #3424 from BSd3v/patch-sideupdates
brings patch to side-updates and allows for use in clientside
2 parents bac3f36 + bee2154 commit 4b8917e

File tree

7 files changed

+1243
-28
lines changed

7 files changed

+1243
-28
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
88
- [#3395](https://github.com/plotly/dash/pull/3396) Add position argument to hooks.devtool
99
- [#3403](https://github.com/plotly/dash/pull/3403) Add app_context to get_app, allowing to get the current app in routes.
1010
- [#3407](https://github.com/plotly/dash/pull/3407) Add `hidden` to callback arguments, hiding the callback from appearing in the devtool callback graph.
11+
- [#3424](https://github.com/plotly/dash/pull/3424) Adds support for `Patch` on clientside callbacks class `dash_clientside.Patch`, as well as supporting side updates, eg: (Running, SetProps).
1112
- [#3347](https://github.com/plotly/dash/pull/3347) Added 'api_endpoint' to `callback` to expose api endpoints at the provided path for use to be executed directly without dash.
1213

1314
## Fixed

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

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {createAction, Action} from 'redux-actions';
4141
import {addHttpHeaders} from '../actions';
4242
import {notifyObservers, updateProps} from './index';
4343
import {CallbackJobPayload} from '../reducers/callbackJobs';
44-
import {handlePatch, isPatch} from './patch';
44+
import {parsePatchProps} from './patch';
4545
import {computePaths, getPath} from './paths';
4646

4747
import {requestDependencies} from './requestDependencies';
@@ -419,22 +419,31 @@ function sideUpdate(outputs: SideUpdateOutput, cb: ICallbackPayload) {
419419
}, [] as any[])
420420
.forEach(([id, idProps]) => {
421421
const state = getState();
422-
dispatch(updateComponent(id, idProps, cb));
423422

424423
const componentPath = getPath(state.paths, id);
424+
let oldComponent = {props: {}};
425+
if (componentPath) {
426+
oldComponent = getComponentLayout(componentPath, state);
427+
}
428+
429+
const oldProps = oldComponent?.props || {};
430+
431+
const patchedProps = parsePatchProps(idProps, oldProps);
432+
433+
dispatch(updateComponent(id, patchedProps, cb));
434+
425435
if (!componentPath) {
426436
// Component doesn't exist, doesn't matter just allow the
427437
// callback to continue.
428438
return;
429439
}
430-
const oldComponent = getComponentLayout(componentPath, state);
431440

432441
dispatch(
433442
setPaths(
434443
computePaths(
435444
{
436445
...oldComponent,
437-
props: {...oldComponent.props, ...idProps}
446+
props: {...oldComponent.props, ...patchedProps}
438447
},
439448
[...componentPath],
440449
state.paths,
@@ -809,12 +818,37 @@ export function executeCallback(
809818

810819
if (clientside_function) {
811820
try {
812-
const data = await handleClientside(
821+
let data = await handleClientside(
813822
dispatch,
814823
clientside_function,
815824
config,
816825
payload
817826
);
827+
// Patch methodology: always run through parsePatchProps for each output
828+
const currentLayout = getState().layout;
829+
flatten(outputs).forEach((out: any) => {
830+
const propName = cleanOutputProp(out.property);
831+
const outputPath = getPath(paths, out.id);
832+
const dataPath = [stringifyId(out.id), propName];
833+
const outputValue = path(dataPath, data);
834+
if (outputValue === undefined) {
835+
return;
836+
}
837+
const oldProps =
838+
path(
839+
outputPath.concat(['props']),
840+
currentLayout
841+
) || {};
842+
const newProps = parsePatchProps(
843+
{[propName]: outputValue},
844+
oldProps
845+
);
846+
data = assocPath(
847+
dataPath,
848+
newProps[propName],
849+
data
850+
);
851+
});
818852
return {data, payload};
819853
} catch (error: any) {
820854
return {error, payload};
@@ -873,26 +907,31 @@ export function executeCallback(
873907
dispatch(addHttpHeaders(newHeaders));
874908
}
875909
// Layout may have changed.
910+
// DRY: Always run through parsePatchProps for each output
876911
const currentLayout = getState().layout;
877912
flatten(outputs).forEach((out: any) => {
878913
const propName = cleanOutputProp(out.property);
879914
const outputPath = getPath(paths, out.id);
880-
const previousValue = path(
881-
outputPath.concat(['props', propName]),
882-
currentLayout
883-
);
884915
const dataPath = [stringifyId(out.id), propName];
885916
const outputValue = path(dataPath, data);
886-
if (isPatch(outputValue)) {
887-
if (previousValue === undefined) {
888-
throw new Error('Cannot patch undefined');
889-
}
890-
data = assocPath(
891-
dataPath,
892-
handlePatch(previousValue, outputValue),
893-
data
894-
);
917+
if (outputValue === undefined) {
918+
return;
895919
}
920+
const oldProps =
921+
path(
922+
outputPath.concat(['props']),
923+
currentLayout
924+
) || {};
925+
const newProps = parsePatchProps(
926+
{[propName]: outputValue},
927+
oldProps
928+
);
929+
930+
data = assocPath(
931+
dataPath,
932+
newProps[propName],
933+
data
934+
);
896935
});
897936

898937
if (dynamic_creator) {

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

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,143 @@ function getLocationPath(location: LocationIndex[], obj: any) {
4444
return current;
4545
}
4646

47+
export class PatchBuilder {
48+
private operations: PatchOperation[] = [];
49+
50+
assign(location: LocationIndex[], value: any) {
51+
this.operations.push({
52+
operation: 'Assign',
53+
location,
54+
params: {value}
55+
});
56+
return this;
57+
}
58+
59+
merge(location: LocationIndex[], value: any) {
60+
this.operations.push({
61+
operation: 'Merge',
62+
location,
63+
params: {value}
64+
});
65+
return this;
66+
}
67+
68+
extend(location: LocationIndex[], value: any) {
69+
this.operations.push({
70+
operation: 'Extend',
71+
location,
72+
params: {value}
73+
});
74+
return this;
75+
}
76+
77+
delete(location: LocationIndex[]) {
78+
this.operations.push({
79+
operation: 'Delete',
80+
location,
81+
params: {}
82+
});
83+
return this;
84+
}
85+
86+
insert(location: LocationIndex[], index: number, value: any) {
87+
this.operations.push({
88+
operation: 'Insert',
89+
location,
90+
params: {index, value}
91+
});
92+
return this;
93+
}
94+
95+
append(location: LocationIndex[], value: any) {
96+
this.operations.push({
97+
operation: 'Append',
98+
location,
99+
params: {value}
100+
});
101+
return this;
102+
}
103+
104+
prepend(location: LocationIndex[], value: any) {
105+
this.operations.push({
106+
operation: 'Prepend',
107+
location,
108+
params: {value}
109+
});
110+
return this;
111+
}
112+
113+
add(location: LocationIndex[], value: any) {
114+
this.operations.push({
115+
operation: 'Add',
116+
location,
117+
params: {value}
118+
});
119+
return this;
120+
}
121+
122+
sub(location: LocationIndex[], value: any) {
123+
this.operations.push({
124+
operation: 'Sub',
125+
location,
126+
params: {value}
127+
});
128+
return this;
129+
}
130+
131+
mul(location: LocationIndex[], value: any) {
132+
this.operations.push({
133+
operation: 'Mul',
134+
location,
135+
params: {value}
136+
});
137+
return this;
138+
}
139+
140+
div(location: LocationIndex[], value: any) {
141+
this.operations.push({
142+
operation: 'Div',
143+
location,
144+
params: {value}
145+
});
146+
return this;
147+
}
148+
149+
clear(location: LocationIndex[]) {
150+
this.operations.push({
151+
operation: 'Clear',
152+
location,
153+
params: {}
154+
});
155+
return this;
156+
}
157+
158+
reverse(location: LocationIndex[]) {
159+
this.operations.push({
160+
operation: 'Reverse',
161+
location,
162+
params: {}
163+
});
164+
return this;
165+
}
166+
167+
remove(location: LocationIndex[], value: any) {
168+
this.operations.push({
169+
operation: 'Remove',
170+
location,
171+
params: {value}
172+
});
173+
return this;
174+
}
175+
176+
build() {
177+
return {
178+
__dash_patch_update: '__dash_patch_update',
179+
operations: this.operations
180+
};
181+
}
182+
}
183+
47184
const patchHandlers: {[k: string]: PatchHandler} = {
48185
Assign: (previous, patchOperation) => {
49186
const {params, location} = patchOperation;
@@ -166,3 +303,29 @@ export function handlePatch<T>(previousValue: T, patchValue: any): T {
166303

167304
return reducedValue;
168305
}
306+
307+
export function parsePatchProps(
308+
props: any,
309+
previousProps: any
310+
): Record<string, any> {
311+
if (!is(Object, props)) {
312+
return props;
313+
}
314+
315+
const patchedProps: any = {};
316+
317+
for (const key of Object.keys(props)) {
318+
const val = props[key];
319+
if (isPatch(val)) {
320+
const previousValue = previousProps[key];
321+
if (previousValue === undefined) {
322+
throw new Error('Cannot patch undefined');
323+
}
324+
patchedProps[key] = handlePatch(previousValue, val);
325+
} else {
326+
patchedProps[key] = val;
327+
}
328+
}
329+
330+
return patchedProps;
331+
}

dash/dash-renderer/src/utils/clientsideFunctions.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {updateProps, notifyObservers, setPaths} from '../actions/index';
2+
import {parsePatchProps, PatchBuilder} from '../actions/patch';
23
import {computePaths, getPath} from '../actions/paths';
34
import {getComponentLayout} from '../wrapper/wrapping';
45
import {getStores} from './stores';
@@ -23,6 +24,12 @@ function set_props(
2324
} else {
2425
componentPath = idOrPath;
2526
}
27+
const oldComponent = getComponentLayout(componentPath, state);
28+
29+
// Handle any patch props
30+
props = parsePatchProps(props, oldComponent?.props || {});
31+
32+
// Update the props
2633
dispatch(
2734
updateProps({
2835
props,
@@ -31,7 +38,10 @@ function set_props(
3138
})
3239
);
3340
dispatch(notifyObservers({id: idOrPath, props}));
34-
const oldComponent = getComponentLayout(componentPath, state);
41+
42+
if (!oldComponent) {
43+
return;
44+
}
3545

3646
dispatch(
3747
setPaths(
@@ -77,3 +87,4 @@ const dc = ((window as any).dash_clientside =
7787
(window as any).dash_clientside || {});
7888
dc['set_props'] = set_props;
7989
dc['clean_url'] = dc['clean_url'] === undefined ? clean_url : dc['clean_url'];
90+
dc['Patch'] = PatchBuilder;

tests/async_tests/test_async_callbacks.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ async def set_path(n):
376376
dash_duo.wait_for_text_to_equal("#out", '[{"a": "/2:a"}] - /2')
377377

378378

379+
@flaky.flaky(max_runs=3)
379380
def test_async_cbsc008_wildcard_prop_callbacks(dash_duo):
380381
if not is_dash_async():
381382
return
@@ -384,7 +385,7 @@ def test_async_cbsc008_wildcard_prop_callbacks(dash_duo):
384385
app = Dash(__name__)
385386
app.layout = html.Div(
386387
[
387-
dcc.Input(id="input", value="initial value"),
388+
dcc.Input(id="input", value="initial value", debounce=False),
388389
html.Div(
389390
html.Div(
390391
[
@@ -427,6 +428,7 @@ async def update_text(data):
427428
for key in "hello world":
428429
with lock:
429430
input1.send_keys(key)
431+
time.sleep(0.05) # allow some time for debounced callback to be sent
430432

431433
dash_duo.wait_for_text_to_equal("#output-1", "hello world")
432434
assert dash_duo.find_element("#output-1").get_attribute("data-cb") == "hello world"

0 commit comments

Comments
 (0)