Skip to content

Commit 37782e3

Browse files
committed
Separate resolved value notifications from dirty state transitions
1 parent 6ec128f commit 37782e3

2 files changed

Lines changed: 80 additions & 16 deletions

File tree

src/objectstate/object_state.py

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -959,10 +959,9 @@ def update_parameter(self, param_name: str, value: Any) -> set[str]:
959959
changed_semantic_params,
960960
pending_parameters,
961961
)
962-
value_change_notification_fields = self.notification_fields_for_value_changes(
963-
changed_param_values,
964-
)
965-
962+
# Establish the pre-mutation resolved authority even for a state that
963+
# has not yet been displayed. Raw edits can preserve the resolved value.
964+
self._ensure_live_resolved(notify_flash=False)
966965
ObjectStateRegistry.ensure_baseline_snapshot()
967966

968967
# Update state directly (no type conversion - that's VIEW responsibility)
@@ -1063,14 +1062,11 @@ def update_parameter(self, param_name: str, value: Any) -> set[str]:
10631062
# Increment global token for LiveContextService.collect() cache invalidation
10641063
ObjectStateRegistry.increment_token(notify=False)
10651064

1066-
# Recompute live cache, then notify once from the materialized sync layer.
1067-
# First-populate states have no prior resolved cache to diff against, so
1068-
# raw changed child paths are the notification/navigation authority for
1069-
# inline dataclass editors.
1065+
# Raw edits own history/navigation; the resolved delta owns value flashes.
10701066
self._set_last_changed_values(changed_param_values)
10711067
# Sync materialized state (single point for dirty/sig_diff update + notification)
10721068
self._sync_materialized_state(
1073-
changed_value_fields=changed_paths | value_change_notification_fields
1069+
changed_value_fields=changed_paths
10741070
)
10751071

10761072
# Record snapshot for time-travel (registry-level for coherent system history)
@@ -1091,7 +1087,7 @@ def update_parameter(self, param_name: str, value: Any) -> set[str]:
10911087
if snapshot_field:
10921088
ObjectStateRegistry.record_snapshot(f"edit {snapshot_field}", self.scope_id)
10931089

1094-
return changed_paths | value_change_notification_fields
1090+
return changed_paths
10951091

10961092
def get_resolved_value(self, param_name: str) -> Any:
10971093
"""Get resolved value for a field from the bulk snapshot.
@@ -1975,16 +1971,18 @@ def _sync_materialized_state(
19751971
19761972
Correctness guarantee: All mutation paths call this ONE method.
19771973
1978-
Flash behavior: Fires on_resolved_changed for fields that changed value
1979-
and fields that changed dirty status. This ensures flash animation
1980-
triggers for edits within an already-dirty value, not just clean/dirty
1981-
transitions.
1974+
Resolved-value subscribers receive only value changes. Materialized
1975+
state subscribers also receive dirty/signature transitions, including
1976+
saving or changing explicitness without changing the resolved value.
19821977
"""
19831978
raw_dirty_changed = self._update_raw_dirty()
19841979
dirty_status_changed_fields = self._update_dirty_fields()
19851980
sig_diff_changed = self._update_signature_diff_fields()
1981+
value_notification_fields = self._most_specific_notification_fields(
1982+
changed_value_fields or set()
1983+
)
19861984
notification_fields = self._most_specific_notification_fields(
1987-
dirty_status_changed_fields | (changed_value_fields or set())
1985+
dirty_status_changed_fields | value_notification_fields
19881986
)
19891987

19901988
materialized_changed = raw_dirty_changed or bool(notification_fields) or sig_diff_changed
@@ -1993,7 +1991,7 @@ def _sync_materialized_state(
19931991

19941992
if emit_notifications:
19951993
self._notify_resolved_changed(
1996-
notification_fields,
1994+
value_notification_fields,
19971995
context="_sync_materialized_state",
19981996
)
19991997

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Resolved-value notifications exclude storage and save-baseline-only changes."""
2+
3+
from dataclasses import dataclass
4+
5+
import pytest
6+
7+
from objectstate import LazyDataclassFactory, ObjectState, ObjectStateRegistry
8+
9+
10+
@dataclass
11+
class NotificationValues:
12+
number: int = 3
13+
maybe: int | None = None
14+
15+
16+
LazyNotificationValues = LazyDataclassFactory.make_lazy_simple(NotificationValues)
17+
18+
19+
@pytest.fixture(autouse=True)
20+
def registry():
21+
ObjectStateRegistry.clear()
22+
yield
23+
ObjectStateRegistry.clear()
24+
25+
26+
def test_same_resolved_default_changes_storage_without_value_notification():
27+
state = ObjectState(LazyNotificationValues())
28+
assert state.get_resolved_value("number") == 3
29+
values = []
30+
chrome = []
31+
state.on_resolved_changed(lambda paths: values.append(set(paths)))
32+
state.on_state_changed(lambda paths: chrome.append(state.is_raw_dirty))
33+
assert state.update_parameter("number", 3) == set()
34+
assert state.parameters["number"] == 3
35+
state.reset_parameter("number")
36+
assert state.parameters["number"] is None
37+
assert state.get_resolved_value("number") == 3
38+
assert values == []
39+
assert chrome == [True, False]
40+
41+
42+
@pytest.mark.parametrize("warm", (False, True))
43+
def test_real_value_change_has_one_notification_even_without_prior_cache_read(warm):
44+
state = ObjectState(NotificationValues())
45+
if warm:
46+
assert state.get_resolved_value("number") == 3
47+
values = []
48+
state.on_resolved_changed(lambda paths: values.append(set(paths)))
49+
assert state.update_parameter("number", 8) == {"number"}
50+
assert values == [{"number"}]
51+
assert state.get_resolved_value("number") == 8
52+
53+
54+
def test_mark_saved_updates_dirty_chrome_without_value_notification():
55+
state = ObjectState(NotificationValues())
56+
state.update_parameter("number", 8)
57+
assert state.dirty_fields == {"number"}
58+
values = []
59+
chrome = []
60+
state.on_resolved_changed(lambda paths: values.append(set(paths)))
61+
state.on_state_changed(lambda paths: chrome.append(set(paths)))
62+
state.mark_saved()
63+
assert state.dirty_fields == set()
64+
assert state.get_resolved_value("number") == 8
65+
assert values == []
66+
assert chrome == [{"number"}]

0 commit comments

Comments
 (0)