From 36b70f848c459eae1660d06cc04b4b12f1925acc Mon Sep 17 00:00:00 2001 From: Berend Klein Haneveld Date: Fri, 29 May 2026 11:12:49 +0200 Subject: [PATCH 1/3] Add test case that exhibits the problem with calling deepcopy on reactive objects --- tests/test_produce_reactive_deepcopy.py | 81 +++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/test_produce_reactive_deepcopy.py diff --git a/tests/test_produce_reactive_deepcopy.py b/tests/test_produce_reactive_deepcopy.py new file mode 100644 index 0000000..838f432 --- /dev/null +++ b/tests/test_produce_reactive_deepcopy.py @@ -0,0 +1,81 @@ +"""Test that produce's patch values remain usable after deepcopy. + +When produce(in_place=True) records patches on reactive state, it calls +deepcopy() on proxy values. Without observ's __deepcopy__ fix, the +deepcopy'd values are zombie proxies (unregistered in proxy_db) that +crash when inserted back into reactive state and accessed by watchers. + +Fix: https://github.com/fork-tongue/observ/pull/165 +""" + +import pytest + +try: + from observ import reactive, watch + + OBSERV_AVAILABLE = True +except ImportError: + OBSERV_AVAILABLE = False + +from patchdiff import iapply, produce + +pytestmark = pytest.mark.skipif(not OBSERV_AVAILABLE, reason="observ not installed") + + +def test_produce_in_place_undo_redo_with_deep_watcher(): + """Simulates undo/redo on reactive state with a deep watcher. + + 1. Mutates reactive state in-place (grouping items) to generate patches + 2. Applies reverse patches (undo) to restore original state + 3. Applies forward patches (redo) to re-apply the mutation + + Without the fix, step 2 already crashes: iapply calls deepcopy on the + patch values (which are reactive proxies stored during produce), creating + zombie proxies that the deep watcher tries to traverse when they are + inserted into the reactive state. + """ + state = reactive( + { + "children": [ + {"obj_id": "a", "name": "Item A", "children": []}, + {"obj_id": "b", "name": "Item B", "children": []}, + {"obj_id": "c", "name": "Item C", "children": []}, + ] + } + ) + + # Deep watcher that traverses all nested state (like collagraph's v-for does) + observed = [] + _watcher = watch( + lambda: state["children"], + lambda val: observed.append(val), + sync=True, + deep=True, + ) + + def recipe(draft): + # Group items a and b into a new group + items = [draft["children"].pop(0), draft["children"].pop(0)] + draft["children"].insert( + 0, {"obj_id": "group1", "name": "Group", "children": items} + ) + + # produce calls deepcopy on the values read from reactive state when + # recording patches. The resulting patch values are reactive proxies. + _result, patches, reverse_patches = produce(state, recipe, in_place=True) + + # After grouping: [group1, c] = 2 children + assert len(state["children"]) == 2 + + # Undo: iapply calls deepcopy on the reverse patch values (which are + # reactive proxies). Without the fix, deepcopy creates zombie proxies + # that get inserted into state. The deep watcher traverses them and + # crashes with KeyError in proxy_db.attrs(). + iapply(state, reverse_patches) + assert len(state["children"]) == 3 + + # Redo: apply forward patches — iapply calls deepcopy on patch values + # and inserts them into reactive state. The deep watcher traverses + # these values, which also crashes without the fix. + iapply(state, patches) + assert len(state["children"]) == 2 From 7ffde2ffb14fc71263dcc7168eb5e9c0f9000c02 Mon Sep 17 00:00:00 2001 From: Berend Klein Haneveld Date: Fri, 29 May 2026 11:14:53 +0200 Subject: [PATCH 2/3] Simplify produce code that makes use of the proposed fix in observ --- patchdiff/produce.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/patchdiff/produce.py b/patchdiff/produce.py index fc20947..8934303 100644 --- a/patchdiff/produce.py +++ b/patchdiff/produce.py @@ -12,12 +12,6 @@ from .pointer import Pointer -# Optional observ integration -try: - from observ import to_raw as observ_to_raw -except ImportError: # pragma: no cover - observ_to_raw = None - def _add_reader_methods(proxy_class, method_names): """Add simple pass-through reader methods to a proxy class. @@ -748,11 +742,6 @@ def produce( # Don't unwrap or copy - use the base object as-is draft = base else: - # Unwrap observ reactive objects to get the underlying data - # Use observ's to_raw() function if available - if observ_to_raw is not None: - base = observ_to_raw(base) - # Create a deep copy of the base object draft = deepcopy(base) From bab5b266b2e7bbb309678e66e2422ef13316bb4a Mon Sep 17 00:00:00 2001 From: Berend Klein Haneveld Date: Fri, 29 May 2026 12:16:59 +0200 Subject: [PATCH 3/3] Bump observ dependency version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index acea3b9..d86f766 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dev = [ "pytest-benchmark", ] observ = [ - "observ>=0.17.0", + "observ>=0.18.0", ] [tool.ruff.lint]