Skip to content

Commit 86309d0

Browse files
committed
fix model mutation in client
The client-side model was being mutated on each update in order to try and be more performant. This is a problem though because React relies on checking the identity of some parts of the model to see if things have changed. No we create a copy of the model each time it changed
1 parent 384bd93 commit 86309d0

File tree

5 files changed

+73
-45
lines changed

5 files changed

+73
-45
lines changed

src/client/package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/client/packages/idom-client-react/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "idom-client-react",
33
"description": "A client for IDOM implemented in React",
4-
"version": "0.9.1",
4+
"version": "0.9.2",
55
"author": "Ryan Morshead",
66
"license": "MIT",
77
"type": "module",

src/client/packages/idom-client-react/src/component.js

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import htm from "htm";
44

55
import serializeEvent from "./event-to-object.js";
66

7-
import { applyPatchInplace, joinUrl } from "./utils.js";
7+
import { useJsonPatchCallback } from "./utils.js";
88

99
const html = htm.bind(React.createElement);
1010
export const LayoutConfigContext = React.createContext({
@@ -13,7 +13,7 @@ export const LayoutConfigContext = React.createContext({
1313
});
1414

1515
export function Layout({ saveUpdateHook, sendEvent, loadImportSource }) {
16-
const [model, patchModel] = useInplaceJsonPatch({});
16+
const [model, patchModel] = useJsonPatchCallback({});
1717

1818
React.useEffect(() => saveUpdateHook(patchModel), [patchModel]);
1919

@@ -181,33 +181,3 @@ function loadImportSource(config, importSource) {
181181
}
182182
});
183183
}
184-
185-
function useInplaceJsonPatch(doc) {
186-
const ref = React.useRef(doc);
187-
const forceUpdate = useForceUpdate();
188-
189-
const applyPatch = React.useCallback(
190-
(path, patch) => {
191-
applyPatchInplace(ref.current, path, patch);
192-
forceUpdate();
193-
},
194-
[ref, forceUpdate]
195-
);
196-
197-
return [ref.current, applyPatch];
198-
}
199-
200-
function useForceUpdate() {
201-
const [, updateState] = React.useState();
202-
return React.useCallback(() => updateState({}), []);
203-
}
204-
205-
function useConst(func) {
206-
const ref = React.useRef();
207-
208-
if (!ref.current) {
209-
ref.current = func();
210-
}
211-
212-
return ref.current;
213-
}
Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,35 @@
1+
import React from "react";
12
import jsonpatch from "fast-json-patch";
23

3-
export function applyPatchInplace(doc, pathPrefix, patch) {
4-
if (pathPrefix) {
5-
patch = patch.map((op) =>
6-
Object.assign({}, op, { path: pathPrefix + op.path })
7-
);
8-
}
9-
jsonpatch.applyPatch(doc, patch, false, true);
4+
export function useJsonPatchCallback(initial) {
5+
const model = React.useRef(initial);
6+
const forceUpdate = useForceUpdate();
7+
8+
const applyPatch = React.useCallback(
9+
(pathPrefix, patch) => {
10+
if (pathPrefix) {
11+
patch = patch.map((op) =>
12+
Object.assign({}, op, { path: pathPrefix + op.path })
13+
);
14+
}
15+
// Always return a newDocument because React checks some attributes of the model
16+
// (e.g. model.attributes.style is checked for identity)
17+
model.current = jsonpatch.applyPatch(
18+
model.current,
19+
patch,
20+
false,
21+
false,
22+
true
23+
).newDocument;
24+
forceUpdate();
25+
},
26+
[model]
27+
);
28+
29+
return [model.current, applyPatch];
1030
}
1131

12-
export function joinUrl(base, tail) {
13-
return tail.startsWith("./")
14-
? (base.endsWith("/") ? base.slice(0, -1) : base) + tail.slice(1)
15-
: tail;
32+
function useForceUpdate() {
33+
const [, updateState] = React.useState();
34+
return React.useCallback(() => updateState({}), []);
1635
}

tests/test_client.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,42 @@ def NewComponent():
4343
# check that we can resume normal operation
4444
set_state.current(1)
4545
driver.find_element_by_id("new-component-1")
46+
47+
48+
def test_style_can_be_changed(display, driver, driver_wait):
49+
"""This test was introduced to verify the client does not mutate the model
50+
51+
A bug was introduced where the client-side model was mutated and React was relying
52+
on the model to have been copied in order to determine if something had changed.
53+
54+
See for more info: https://github.com/idom-team/idom/issues/480
55+
"""
56+
57+
@idom.component
58+
def ButtonWithChangingColor():
59+
color_toggle, set_color_toggle = idom.hooks.use_state(True)
60+
color = "red" if color_toggle else "blue"
61+
return idom.html.button(
62+
{
63+
"id": "my-button",
64+
"onClick": lambda event: set_color_toggle(not color_toggle),
65+
"style": {"backgroundColor": color, "color": "white"},
66+
},
67+
f"color: {color}",
68+
)
69+
70+
display(ButtonWithChangingColor)
71+
72+
button = driver.find_element_by_id("my-button")
73+
74+
assert get_style(button)["background-color"] == "red"
75+
76+
for color in ["blue", "red"] * 2:
77+
button.click()
78+
driver_wait.until(lambda _: get_style(button)["background-color"] == color)
79+
80+
81+
def get_style(element):
82+
items = element.get_attribute("style").split(";")
83+
pairs = [item.split(":", 1) for item in map(str.strip, items) if item]
84+
return {key.strip(): value.strip() for key, value in pairs}

0 commit comments

Comments
 (0)