Skip to content

Commit 2f0bb98

Browse files
authored
Fix: Accidental mutation of old model causes invalid JSON Patch (#802)
* fix #801 accidental mutation of old parent model caused invalid JSON patches * add build_py to cmdclass distutils has also been deprecated
1 parent f593ee3 commit 2f0bb98

File tree

10 files changed

+208
-201
lines changed

10 files changed

+208
-201
lines changed

docs/source/about/changelog.rst

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Unreleased
2727

2828
- :issue:`789` - Conditionally rendered components cannot use contexts
2929
- :issue:`773` - Use strict equality check for text, numeric, and binary types in hooks
30+
- :issue:`801` - Accidental mutation of old model causes invalid JSON Patch
3031

3132
**Changed**
3233

noxfile.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ def example(session: Session) -> None:
9898
def docs(session: Session) -> None:
9999
"""Build and display documentation in the browser (automatically reloads on change)"""
100100
install_requirements_file(session, "build-docs")
101-
install_idom_dev(session, extras="all")
101+
install_idom_dev(session)
102102
session.run(
103103
"python",
104104
"scripts/live_docs.py",
@@ -188,7 +188,7 @@ def test_python_suite(session: Session) -> None:
188188
session.install(".[all]")
189189
else:
190190
posargs += ["--cov=src/idom", "--cov-report", "term"]
191-
install_idom_dev(session, extras="all")
191+
install_idom_dev(session)
192192

193193
session.run("pytest", *posargs)
194194

@@ -234,7 +234,7 @@ def test_python_build(session: Session) -> None:
234234
def test_docs(session: Session) -> None:
235235
"""Verify that the docs build and that doctests pass"""
236236
install_requirements_file(session, "build-docs")
237-
install_idom_dev(session, extras="all")
237+
install_idom_dev(session)
238238
session.run(
239239
"sphinx-build",
240240
"-a", # re-write all output files
@@ -370,7 +370,8 @@ def install_requirements_file(session: Session, name: str) -> None:
370370
session.install("-r", str(file_path))
371371

372372

373-
def install_idom_dev(session: Session, extras: str = "stable") -> None:
373+
def install_idom_dev(session: Session, extras: str = "all") -> None:
374+
session.run("pip", "--version")
374375
if "--no-install" not in session.posargs:
375376
session.install("-e", f".[{extras}]")
376377
else:

requirements/pkg-extras.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ uvicorn[standard] >=0.13.4
1212

1313
# extra=flask
1414
flask
15-
markupsafe<2.1
15+
markupsafe>=1.1.1,<2.1
1616
flask-cors
1717
flask-sock
1818

setup.py

+20-4
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,12 @@
55
import subprocess
66
import sys
77
import traceback
8-
from distutils import log
9-
from distutils.command.build import build # type: ignore
10-
from distutils.command.sdist import sdist # type: ignore
8+
from logging import StreamHandler, getLogger
119
from pathlib import Path
1210

1311
from setuptools import find_packages, setup
1412
from setuptools.command.develop import develop
13+
from setuptools.command.sdist import sdist
1514

1615

1716
if sys.platform == "win32":
@@ -22,6 +21,15 @@ def list2cmdline(cmd_list):
2221
return " ".join(map(pipes.quote, cmd_list))
2322

2423

24+
log = getLogger()
25+
log.addHandler(StreamHandler(sys.stdout))
26+
27+
28+
# -----------------------------------------------------------------------------
29+
# Basic Constants
30+
# -----------------------------------------------------------------------------
31+
32+
2533
# the name of the project
2634
NAME = "idom"
2735

@@ -167,10 +175,18 @@ def run(self):
167175

168176
package["cmdclass"] = {
169177
"sdist": build_javascript_first(sdist),
170-
"build": build_javascript_first(build),
171178
"develop": build_javascript_first(develop),
172179
}
173180

181+
if sys.version_info < (3, 10, 6):
182+
from distutils.command.build import build
183+
184+
package["cmdclass"]["build"] = build_javascript_first(build)
185+
else:
186+
from setuptools.command.build_py import build_py
187+
188+
package["cmdclass"]["build_py"] = build_javascript_first(build_py)
189+
174190

175191
# -----------------------------------------------------------------------------
176192
# Install It

src/idom/core/layout.py

+11-4
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,17 @@ def _render_component(
230230
else:
231231
key, index = new_state.key, new_state.index
232232
parent.children_by_key[key] = new_state
233-
# need to do insertion in case where old_state is None and we're appending
234-
parent.model.current["children"][index : index + 1] = [
235-
new_state.model.current
236-
]
233+
# need to add this model to parent's children without mutating parent model
234+
old_parent_model = parent.model.current
235+
old_parent_children = old_parent_model["children"]
236+
parent.model.current = {
237+
**old_parent_model,
238+
"children": [
239+
*old_parent_children[:index],
240+
new_state.model.current,
241+
*old_parent_children[index + 1 :],
242+
],
243+
}
237244
finally:
238245
# avoid double render
239246
self._rendering_queue.remove_if_pending(life_cycle_state.id)

tests/test_client.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
from playwright.async_api import Browser
66

77
import idom
8+
from idom.backend.utils import find_available_port
89
from idom.testing import BackendFixture, DisplayFixture
910

1011

1112
JS_DIR = Path(__file__).parent / "js"
1213

1314

1415
async def test_automatic_reconnect(browser: Browser):
16+
port = find_available_port("localhost")
1517
page = await browser.new_page()
1618

1719
# we need to wait longer here because the automatic reconnect is not instant
@@ -22,7 +24,7 @@ def OldComponent():
2224
return idom.html.p({"id": "old-component"}, "old")
2325

2426
async with AsyncExitStack() as exit_stack:
25-
server = await exit_stack.enter_async_context(BackendFixture(port=8000))
27+
server = await exit_stack.enter_async_context(BackendFixture(port=port))
2628
display = await exit_stack.enter_async_context(
2729
DisplayFixture(server, driver=page)
2830
)
@@ -43,7 +45,7 @@ def NewComponent():
4345
return idom.html.p({"id": f"new-component-{state}"}, f"new-{state}")
4446

4547
async with AsyncExitStack() as exit_stack:
46-
server = await exit_stack.enter_async_context(BackendFixture(port=8000))
48+
server = await exit_stack.enter_async_context(BackendFixture(port=port))
4749
display = await exit_stack.enter_async_context(
4850
DisplayFixture(server, driver=page)
4951
)

tests/test_core/test_hooks.py

+28-26
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,10 @@
1111
current_hook,
1212
strictly_equal,
1313
)
14-
from idom.core.layout import Layout
15-
from idom.core.serve import render_json_patch
14+
from idom.core.layout import Layout, LayoutUpdate
1615
from idom.testing import DisplayFixture, HookCatcher, assert_idom_did_log, poll
1716
from idom.testing.logs import assert_idom_did_not_log
1817
from idom.utils import Ref
19-
from tests.tooling.asserts import assert_same_items
2018

2119

2220
async def test_must_be_rendering_in_layout_to_use_hooks():
@@ -42,31 +40,35 @@ def SimpleStatefulComponent():
4240
sse = SimpleStatefulComponent()
4341

4442
async with idom.Layout(sse) as layout:
45-
patch_1 = await render_json_patch(layout)
46-
assert patch_1.path == ""
47-
assert_same_items(
48-
patch_1.changes,
49-
[
50-
{"op": "add", "path": "/tagName", "value": ""},
51-
{
52-
"op": "add",
53-
"path": "/children",
54-
"value": [{"children": ["0"], "tagName": "div"}],
55-
},
56-
],
43+
update_1 = await layout.render()
44+
assert update_1 == LayoutUpdate(
45+
path="",
46+
old=None,
47+
new={
48+
"tagName": "",
49+
"children": [{"tagName": "div", "children": ["0"]}],
50+
},
5751
)
5852

59-
patch_2 = await render_json_patch(layout)
60-
assert patch_2.path == ""
61-
assert patch_2.changes == [
62-
{"op": "replace", "path": "/children/0/children/0", "value": "1"}
63-
]
64-
65-
patch_3 = await render_json_patch(layout)
66-
assert patch_3.path == ""
67-
assert patch_3.changes == [
68-
{"op": "replace", "path": "/children/0/children/0", "value": "2"}
69-
]
53+
update_2 = await layout.render()
54+
assert update_2 == LayoutUpdate(
55+
path="",
56+
old=update_1.new,
57+
new={
58+
"tagName": "",
59+
"children": [{"tagName": "div", "children": ["1"]}],
60+
},
61+
)
62+
63+
update_3 = await layout.render()
64+
assert update_3 == LayoutUpdate(
65+
path="",
66+
old=update_2.new,
67+
new={
68+
"tagName": "",
69+
"children": [{"tagName": "div", "children": ["2"]}],
70+
},
71+
)
7072

7173

7274
async def test_set_state_callback_identity_is_preserved():

0 commit comments

Comments
 (0)