Refuse a retained answer while something the widget measured is dirty

`draw_inner` took an answer from `try_reuse`, which checks only whether the
widget itself is marked, where `retained_answer` beside it also refused one
while anything the widget read a size from was dirty. A widget whose drawing
happened to be reusable therefore handed back the answer it gave before that
descendant changed.

Nothing puts that right afterwards. The comparison that tells a reader its
child's answer moved is in `redraw`, and a widget settled inside its parent's
own draw never goes through it -- the placing ask redraws the subtree, the
descendant's mark is cleared there, and the parent keeps a number the tree no
longer agrees with. So the check is not the optimization its comment claimed;
it is what makes the answer an answer, and both retained routes are answers,
so it is asked once in `draw_inner` rather than by one of them.

Found by the generated oracle at seed 564, depth 6, `shuffle-every-other`,
while reading a child's report as a fraction of the containing widget: that
reading lets a span overflow itself, which makes the two asks' boxes differ
far enough for the placing one to redraw.

Twenty-five rig work counters are unchanged on `cold`, `repaint`, `scroll`,
`resize` and `size`; `many` makes 18 fewer reuse attempts, 17 of which
already reported "dirty". Both long fuzzers green.
This commit is contained in:
iris-ai committed 2026-09-17 02:46:25 -04:00
1 parent ea6dbae0dc
commit 0e0d4af326
1 file changed
+19 -19
+19 -19
View File
@@ -192,14 +192,19 @@ impl UiRenderState {
diag::draw_request(id, info.parent, region, info.px, info.region_node);
}
let align = rsc.widgets().alignment(id);
let replace_answer = self.answer_invalid.remove(&id)
|| (self.replace_answers
&& (rsc.widgets().needs_redraw.contains(&id)
|| self.dirty_size_under(id, rsc.widgets())));
let retained = match replace_answer {
// Nothing this widget has is an answer while something it measured
// is dirty: settling that changes what it would report, and a widget
// settled inside its parent's draw tells nobody -- the comparison
// that marks a reader is in `redraw`, which is not what asked here.
// Both retained routes are an answer, so the question is asked once
// rather than by each of them.
let stale =
rsc.widgets().needs_redraw.contains(&id) || self.dirty_size_under(id, rsc.widgets());
let replace_answer = self.answer_invalid.remove(&id) || (self.replace_answers && stale);
let retained = match replace_answer || stale {
true => None,
false => self
.retained_answer(id, info, rsc.widgets())
.retained_answer(id, info)
.or_else(|| self.try_reuse(id, region, info, rsc)),
};
let answer = retained.unwrap_or_else(|| {
@@ -489,16 +494,9 @@ impl UiRenderState {
/// The answer to an ask can be retained independently of where its
/// drawing ended up. Alignment is exactly that case: the first box is the
/// question and the smaller placed box holds the drawing.
fn retained_answer(
&self,
id: WidgetId,
info: DrawInfo,
widgets: &Widgets,
) -> Option<(Size, [Holds; 2])> {
if widgets.needs_redraw.contains(&id) || self.dirty_size_under(id, widgets) {
return None;
}
/// question and the smaller placed box holds the drawing. Whether the
/// answer is stale at all is its caller's question, asked once there.
fn retained_answer(&self, id: WidgetId, info: DrawInfo) -> Option<(Size, [Holds; 2])> {
let active = self.active.get(&id)?;
let has_region_node = active.move_idx != active.parent_move;
if !active.drawn
@@ -512,9 +510,11 @@ impl UiRenderState {
}
/// Whether anything whose size this widget's own size was read from is
/// dirty. Not needed for the answer to come right -- a changed size
/// reaches its reader in any order -- but a reader that asks first
/// lays out once rather than twice.
/// dirty, which makes what it would answer not yet known. It also keeps
/// a reader that asks first from laying out twice, which is all it was
/// here for while a changed size was thought to reach its reader in any
/// order; it does not, where the change settles inside the reader's own
/// draw.
fn dirty_size_under(&self, id: WidgetId, widgets: &Widgets) -> bool {
self.active.get(&id).is_some_and(|active| {
active.size_deps.iter().any(|child| {