From 0e0d4af3261a7922b4b88d54bb80cb9114374cbf Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Thu, 17 Sep 2026 02:46:25 -0400 Subject: [PATCH] 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. --- core/src/ui/render_state.rs | 38 ++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index 3d4ac64..c23956b 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -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| {