Skip to content

Track array-predicate model dependencies in O(1) via track_any_change - #12806

Open
tilladam wants to merge 3 commits into
slint-ui:masterfrom
tilladam:model-track-any-change
Open

Track array-predicate model dependencies in O(1) via track_any_change#12806
tilladam wants to merge 3 commits into
slint-ui:masterfrom
tilladam:model-track-any-change

Conversation

@tilladam

@tilladam tilladam commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Follow-up to the review discussion in #12790 (#12790 (comment)): model_any/model_all/model_find_index used to call track_row_data_changes for every visited row, which inserts each row into the sorted tracked_rows vector — O(n log n) lookups plus O(n²) worst-case insertion to track a whole model, per binding evaluation.

Two commits, reviewable independently.

1. C++: only record tracked model rows inside a binding evaluation

Model::track_row_data_changes recorded the row unconditionally, while the Rust ModelNotify equivalent does so only when is_currently_tracking(). Generated C++ reaches these from imperative contexts too — a callback or function body indexing into a model — and there the recorded row registers no dependency, it only makes later changes to that row dirty bindings that never asked for it.

Exports is_currently_tracking over the C ABI as slint_property_is_currently_tracking (new symbol) and guards the body with it, matching the Rust shape. Goes first so the second commit never lands the problem in a worse form.

2. Track array-predicate model dependencies in O(1) via track_any_change

Adds ModelTracker::track_any_change(row_count), registering the currently evaluated binding as a dependency of any change to the model (row count or any row's data):

  • The default implementation falls back to track_row_count_changes + per-row track_row_data_changes — exactly the previous behavior, so forwarding/third-party trackers stay correct. The row_count parameter exists to make that correct fallback possible; happy to bikeshed the signature.
  • ModelNotify overrides it with a single all_rows_tracked flag plus the two existing dirty-property reads. row_changed short-circuits the tracked_rows binary search on the flag; the flag is cleared wherever tracked_rows is cleared (row_added/row_removed/reset), after which the dirtied binding re-registers on its next evaluation. Conversely, track_row_data_changes stops recording rows individually while the flag is set, and track_any_change clears the tracked_rows it supersedes.
  • The C++ Model class gets the identical treatment, and the C++ model_any/model_all/model_find_index helpers use it instead of per-row row_data_tracked.

One deliberate trade-off: per-row tracking was precise under short-circuiting — any/find-index stop at the first match, so later rows were untracked and changes to them didn't re-evaluate. track_any_change over-approximates. Note this is not confined to the predicate: the dirty flag is the model's shared row-data property, so a change to a row past the match also dirties the other row-data bindings on that model.

Also drops the T: Default bound from model_any/model_all. Absent rows are skipped by all three rather than fed to the predicate as default values — model_all uses is_none_or, not is_some_and, so an unreadable row doesn't fail the whole model.

Tests

Rust: test_any_change_tracking, test_any_change_subsumes_row_tracking, test_predicates_skip_absent_rows.

C++ (catch2): "Track model row data changes outside a binding", "Model any-change tracking", "Model any-change tracking outside a binding", "Model any-change tracking subsumes row tracking", "Array predicates skip absent rows".

The two outside a binding cases are the ones that fail without the guards; the rest cover re-evaluation on row changes past the match point, add/remove, and reset.

Model::track_row_data_changes inserted the row into tracked_rows
unconditionally, while the Rust ModelNotify equivalent does so only when
crate::properties::is_currently_tracking(). Generated C++ reaches these
from imperative contexts too — a callback or function body indexing into
a model — and there the recorded row registers no dependency, it only
makes later changes to that row mark the model's shared row-data
property dirty, waking bindings that never asked for it.

Export is_currently_tracking over the C ABI as
slint_property_is_currently_tracking and wrap it as
slint::private_api::is_currently_tracking, then guard the whole body
with it, matching the shape of the Rust implementation.

changelog: Fixed C++ models marking bindings dirty for row changes they never depended on, when a model row was read outside a binding evaluation.
Follow-up to the review of slint-ui#12790: model_any/model_all/model_find_index
used to call track_row_data_changes for every visited row, which inserts
each row into the sorted tracked_rows vector — O(n log n) lookups plus
O(n²) worst-case insertion to track a whole model, per binding
evaluation.

Add ModelTracker::track_any_change(row_count), registering the currently
evaluated binding as a dependency of any change to the model: the row
count or any row's data. The default implementation falls back to
track_row_count_changes plus per-row track_row_data_changes — exactly
the previous behavior, so forwarding or third-party trackers stay
correct; the row_count parameter exists to make that fallback possible.
ModelNotify overrides it with a single all_rows_tracked flag plus the
two existing dirty-property reads: row_changed short-circuits the
tracked_rows binary search on the flag, and the flag is cleared wherever
tracked_rows is cleared (row_added/row_removed/reset), after which the
dirtied binding re-registers on its next evaluation. The C++ Model class
gets the identical treatment (all_rows_tracked member and a
track_any_change function), and the C++ model_any/model_all/
model_find_index helpers use it instead of per-row row_data_tracked.

Conversely, track_row_data_changes stops recording rows individually
while the flag is set, since row_changed already reports every row then,
and track_any_change clears the tracked_rows it supersedes. Otherwise a
model shared between a repeater and a predicate would keep paying the
per-row insertion this commit set out to remove, into a vector nothing
reads.

One deliberate trade-off: per-row tracking was precise under
short-circuiting — any/find-index stop at the first match, so later
rows were untracked and changes to them didn't re-evaluate.
track_any_change over-approximates: a change to any row re-evaluates
the predicate, trading occasional spurious (cheap) re-evaluations for
tracking cost independent of the model size. Note the over-approximation
is not confined to the predicate: the dirty flag is the model's shared
row-data property, so while a predicate binding is registered, a change
to a row past its match also dirties the other row-data bindings on that
model, which previously had that row in nobody's tracked_rows.

Also drops the T: Default bound from model_any/model_all, matching
model_find_index. Absent rows are now skipped by all three rather than
fed to the predicate as default values — note model_all uses is_none_or,
not is_some_and, so an unreadable row doesn't fail the whole model.

changelog: Reduced the dependency-tracking cost of the array predicates (`array.any`/`array.all`/`array.find-index`) from per-row to constant.
@tilladam
tilladam force-pushed the model-track-any-change branch from 8961deb to 48881f6 Compare August 12, 2026 17:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant