From 9431645c9c0641838bb8e50a50d8bd1870cd2b0d Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 8 Sep 2026 01:42:35 +0300 Subject: [PATCH 1/5] perf(w-div): resolve h-full at the render layer, which also makes it intrinsic-safe `h-full` has to answer a question only layout can answer: is the incoming height bounded. The widget-layer way to ask it is a `LayoutBuilder`, and that carried two costs. The documented one. A `LayoutBuilder` cannot answer an intrinsic query, so any `IntrinsicHeight` or `IntrinsicWidth` above `h-full` asserted `LayoutBuilder does not support returning intrinsic dimensions`. That limitation was written down on five surfaces with an escape hatch ("use explicit `h-*` instead") rather than fixed. The measured one. A `LayoutBuilder` defers its whole subtree into a second layout pass, and `h-full` is common on a scrolling screen: a consumer measured 1056 of them in one eight-scroll session against 258 widget builds, one per element carrying the class, re-run every frame. `WindFullHeightBox` reads `constraints` directly, so it needs neither. It is the same move `WindCrossStretch`, `WindMainExtentProvider` and `WindMinWidthBox` already make for their own sizing questions, and it leaves `grid` as the only `LayoutBuilder` path in the package. Intrinsics are left to `RenderProxyBox`, which forwards them to the child, and that is the honest answer for a box whose job is to take what it is given. The first version overrode them to report the fallback height, reasoning that a fill box "wants" the screen; under an `IntrinsicHeight` beside a 60 pixel sibling that made the row 600 rather than 60. Behaviour is otherwise unchanged, and that is asserted rather than asserted-to. `test/widgets/w_div/full_height_sizing_test.dart` was written against the OLD implementation first and pins every branch the old code had: bounded, unbounded, with and without a width factor, the outer box's own size, and the fractions that never went through this path. Ten passed before the change and pass after. Two existing tests moved from white-box to behaviour. One asserted a `FractionallySizedBox` carrying two factors, the other a `LayoutBuilder` descendant; both pinned a composition rather than anything a user can observe, and the second was asserting the exact thing this commit removes. They now assert the resulting sizes. The two remaining cases in the new file are skipped, with the reproduction and the reason inline: `h-full max-h-*` discards the cap when the parent bounds the height, because the cap arrives as a `ConstrainedBox` inside the sizing wrapper and `BoxConstraints.enforce` computes `clamp(120, 400, 400)`. That is pre-existing on 1.5.1, unaffected either way by this change, and its fix is a wrapping-order change rather than anything this class does. Post-change sync: `doc/layout/sizing.md` (the Intrinsic Sizing Limitation section is now about `grid` alone), `doc/layout/flexbox.md`, `SKILL.md` plus three references, and `CHANGELOG.md` under Fixed, Changed and Known. No `README.md` change: no new widget, token family or theme field, and the roster is unchanged. No `example/` change: no doc x-preview moved. Gates: `dart analyze` clean, `dart format .` no diff, 1772 tests green, `./tool/coverage.sh 90` at 94.5%, `tool/check-docs.py` 0 issues. --- CHANGELOG.md | 14 ++ doc/layout/flexbox.md | 2 +- doc/layout/sizing.md | 13 +- lib/src/widgets/w_div.dart | 92 ++------ lib/src/widgets/wind_full_height.dart | 213 +++++++++++++++++ skills/wind-ui/SKILL.md | 4 +- skills/wind-ui/references/layouts.md | 2 +- .../wind-ui/references/tailwind-divergence.md | 2 +- skills/wind-ui/references/tokens.md | 2 +- test/flex/intrinsic_safe_layout_test.dart | 72 ++++++ .../w_div/full_height_sizing_test.dart | 219 ++++++++++++++++++ test/widgets/w_div/sizing_test.dart | 34 ++- 12 files changed, 582 insertions(+), 87 deletions(-) create mode 100644 lib/src/widgets/wind_full_height.dart create mode 100644 test/widgets/w_div/full_height_sizing_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 475de847..3d1f1bd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. --- +## [Unreleased] + +### Fixed + +- **`h-full` no longer throws under an `IntrinsicHeight`.** It resolved through a `LayoutBuilder`, which cannot answer an intrinsic query, so any `IntrinsicHeight` / `IntrinsicWidth` above it asserted `LayoutBuilder does not support returning intrinsic dimensions`. The limitation was documented on five surfaces with an escape hatch ("use explicit `h-*` instead") rather than fixed. `h-full` is now the `WindFullHeightBox` render object, which answers intrinsics by forwarding to its child, so it renders under an `IntrinsicHeight`, in a `Table` cell and in an `items-stretch` grid cell, and matches the tallest sibling rather than reporting the screen height. `grid` is the one remaining `LayoutBuilder` path. + +### Changed + +- **`h-full` resolves at the render layer instead of through a `LayoutBuilder`.** The question it asks ("is the incoming height bounded") is only answerable during layout, and a `LayoutBuilder` was the widget-layer way to ask it; a `LayoutBuilder` also defers its whole subtree into a second layout pass. A consumer measured 1056 of them in one eight-scroll session against 258 widget builds, one per element carrying the class, re-run every frame. `WindFullHeightBox` reads `constraints` directly and needs neither. Behaviour is otherwise unchanged, pinned by twelve characterisation tests written against the old implementation first. + +### Known + +- **`h-full max-h-*` discards the cap when the parent bounds the height.** `max-h-*` arrives as a `ConstrainedBox` INSIDE the sizing wrapper, and `BoxConstraints.enforce` clamps an additional constraint into the incoming range: handed a tight 400 it computes `clamp(120, 400, 400)` and yields 400. The unbounded branch does honour the cap, so the same className means two different things depending on the parent. Pre-existing on 1.5.1 and unchanged here; the fix is a wrapping-order change (apply the cap outside the sizing box, so it narrows what the box then fills). Two skipped tests in `test/widgets/w_div/full_height_sizing_test.dart` carry the reproduction. + ## [1.5.1] - 2026-09-07 ### Fixed diff --git a/doc/layout/flexbox.md b/doc/layout/flexbox.md index a49d5d58..80d48217 100644 --- a/doc/layout/flexbox.md +++ b/doc/layout/flexbox.md @@ -212,7 +212,7 @@ WDiv(className: 'flex items-center h-20') > **Column cross-axis stretch (default AND explicit `items-stretch`).** A `flex flex-col` with no explicit `items-*` token, OR with an explicit `items-stretch`, stretches each `WDiv`, `WAnchor` (any child), and `WButton` child that does not control its own width to the column width, matching CSS `align-items: stretch`. Explicit `items-stretch` therefore equalizes child widths (every eligible child fills the column), closing the asymmetry with `grid ... items-stretch`. For `WAnchor`: when the anchor wraps a `WDiv`, the inner `WDiv`'s className decides (so `WAnchor > WDiv(w-32)` keeps 128 px; a `WAnchor > WDiv` with a self-flex token is excluded just as a direct self-flexing `WDiv` is); when the anchor wraps a `WText` or raw widget, the anchor stretches by policy so its tap surface fills the column. Left untouched: children with an explicit width (`w-*` / `min-w-*` / `max-w-*` / `w-full`, in any state/breakpoint variant), children that self-wrap in `Expanded`/`Flexible` (`grow`, `flex-grow`, `flex-auto`, `flex-initial`, `shrink`, `flex-shrink`, `flex-N`), `basis-*` children, absolute children, bare `WText` leaves, and raw Flutter widgets. `shrink-0` / `flex-none` children still stretch on the cross axis (`flex-shrink` is main-axis only, matching CSS). Add `items-start` / `items-center` / `items-end` to turn stretch off and let children size to content. Rows are never auto-stretched on the cross axis. When the column itself sits in an unbounded-width context (a bare `Row` slot, `UnconstrainedBox`, horizontal scroll), the stretch safely falls back to content-sized children instead of forcing an infinite width. -> **Layout stability: wind's flex is intrinsic-safe.** Flutter's `IntrinsicHeight` and `IntrinsicWidth` perform an intrinsic-dimension pass that reads child sizes mid-layout, and a `LayoutBuilder` on that path asserts `LayoutBuilder does not support returning intrinsic dimensions`. Wind's flex uses NO `LayoutBuilder`: column cross-axis stretch is a real render object (`WindCrossStretch`), and `basis-*` resolves against the flex's own extent via a `WindMainExtentProvider` (see `lib/src/widgets/w_div.dart` and `wind_equal_height_row.dart`). So a `flex flex-col` (with or without `basis-*`) renders correctly inside an `items-stretch` grid cell, under an `IntrinsicHeight`/`IntrinsicWidth`, or in a `Table` cell without asserting. For a connector, rail, or divider that must fill the cross axis to match the tallest sibling, prefer a `Stack` with a `Positioned(top: 0, bottom: 0)` line, or use wind's own `items-stretch` column (also intrinsic-free and animation-safe). +> **Layout stability: wind's flex is intrinsic-safe.** Flutter's `IntrinsicHeight` and `IntrinsicWidth` perform an intrinsic-dimension pass that reads child sizes mid-layout, and a `LayoutBuilder` on that path asserts `LayoutBuilder does not support returning intrinsic dimensions`. Wind's flex uses NO `LayoutBuilder`, and neither does `h-full` since it became the `WindFullHeightBox` render object: column cross-axis stretch is a real render object (`WindCrossStretch`), and `basis-*` resolves against the flex's own extent via a `WindMainExtentProvider` (see `lib/src/widgets/w_div.dart` and `wind_equal_height_row.dart`). So a `flex flex-col` (with or without `basis-*`) renders correctly inside an `items-stretch` grid cell, under an `IntrinsicHeight`/`IntrinsicWidth`, or in a `Table` cell without asserting. For a connector, rail, or divider that must fill the cross axis to match the tallest sibling, prefer a `Stack` with a `Positioned(top: 0, bottom: 0)` line, or use wind's own `items-stretch` column (also intrinsic-free and animation-safe). > > ```dart > // Safe connector pattern: Stack + Positioned, no IntrinsicHeight diff --git a/doc/layout/sizing.md b/doc/layout/sizing.md index 56eceb9f..d3ea0c88 100644 --- a/doc/layout/sizing.md +++ b/doc/layout/sizing.md @@ -177,16 +177,17 @@ Wrapping Wind content in `IntrinsicHeight` or `IntrinsicWidth` (or placing it in LayoutBuilder does not support returning intrinsic dimensions. ``` -**Why.** To resolve some sizes against the parent's real constraints, Wind introduces a `LayoutBuilder` (see `lib/src/widgets/w_div.dart`): `h-full` adds one around the cell only when the incoming height is unbounded, and a flex `basis-*` adds a single one around the surrounding flex when any direct child uses `basis-*`. `LayoutBuilder` runs during the layout phase, not the intrinsic-sizing phase, so an intrinsic-dimension query that passes through one of those `LayoutBuilder` paths asserts. This is a fundamental Flutter constraint (`LayoutBuilder` genuinely cannot answer intrinsics), not a Wind bug. Wind content that hits none of Wind's `LayoutBuilder` paths (`h-full` in an unbounded height, `basis-*`, or the column cross-axis stretch above) carries no `LayoutBuilder` and is safe to wrap. +**Why.** To resolve a size against the parent's real constraints, Wind used to introduce a `LayoutBuilder`, and a `LayoutBuilder` runs during the layout phase rather than the intrinsic-sizing phase, so any intrinsic query passing through one asserts. That is a Flutter constraint, not a Wind bug. -**What triggers it.** A `WDiv` (or any W-widget) whose `className` resolves `h-full` or a flex `basis-*`, anywhere inside the subtree you wrap in `IntrinsicHeight` / `IntrinsicWidth`. A `Row` of cards that you try to equalize with `IntrinsicHeight` is the common case. +**What still triggers it: `grid` only.** `grid-cols-*` composes a `Wrap` inside a `LayoutBuilder` (it needs the available width to compute a column width), so a `grid` anywhere inside the subtree you wrap in `IntrinsicHeight` / `IntrinsicWidth` still asserts. -**Escape hatches.** +**What no longer does.** `h-full` and flex `basis-*` are both intrinsic-safe now. `h-full` resolves through the `WindFullHeightBox` render object, `basis-*` through `WindMainExtentProvider`, and the column cross-axis stretch through `WindCrossStretch`; a render object answers intrinsic queries, so all three render under an `IntrinsicHeight`, in a `Table` cell, or in an `items-stretch` grid cell without throwing. If you carry an escape hatch for `h-full` from an earlier version, you can drop it. -- Prefer explicit sizing: give the cells a fixed `h-*` (or `size-*`) instead of `h-full` + `IntrinsicHeight`. -- Do not wrap Wind content that uses `h-full` / `basis-*` in `IntrinsicHeight` / `IntrinsicWidth`. +**Escape hatches, for the `grid` case that remains.** + +- Prefer explicit sizing: give the cells a fixed `h-*` (or `size-*`) instead of a `grid` + `IntrinsicHeight`. - For an equal-height row, use a `Stack` with a `Positioned(top: 0, bottom: 0)` element for the part that must fill, or reserve equal content so natural heights already match. -- Wind's own column cross-axis stretch (`items-stretch`, the `flex flex-col` default) equalizes width WITHOUT you wrapping it in `IntrinsicHeight`. It uses a `LayoutBuilder` + `SizedBox(width: double.infinity)` internally (gated on a bounded width), so treat it as a REPLACEMENT for `IntrinsicHeight`, not something to nest inside one. +- Wind's own `items-stretch` grid equalizes row heights with real layout rather than `IntrinsicHeight`, so reach for it INSTEAD of wrapping. ```dart // Throws if a card resolves h-full / basis-* internally: diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index b6f1bac8..a27a4ca1 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -5,6 +5,7 @@ import '../parser/wind_style.dart'; import '../utils/wind_logger.dart'; import '../utils/wind_perf_counters.dart'; import 'wind_animation_wrapper.dart'; +import 'wind_full_height.dart'; import '../state/wind_anchor_state_provider.dart'; import '../state/wind_flex_overflow_scope.dart'; import '../state/wind_min_width_scroll_scope.dart'; @@ -1718,31 +1719,18 @@ class WDiv extends StatelessWidget { // Vertical axis is often unbounded (ScrollView/Column), so we need // LayoutBuilder only for h-full in unbounded contexts. if (isFullHeight) { - // h-full needs LayoutBuilder to handle unbounded vertical axis - widgetToBuild = LayoutBuilder( - builder: (context, constraints) { - if (!constraints.hasBoundedHeight) { - final screenHeight = MediaQuery.of(context).size.height; - Widget result = SizedBox( - height: screenHeight, - child: innerChild, - ); - if (hasMaxHeightConstraint) { - result = ConstrainedBox( - constraints: BoxConstraints( - maxHeight: styles.constraints!.maxHeight, - ), - child: result, - ); - } - return result; - } - // Bounded context: use FractionallySizedBox - return FractionallySizedBox( - heightFactor: 1.0, - child: innerChild, - ); - }, + // h-full resolves at the render layer, no LayoutBuilder. + // + // The question is only answerable during layout ("is the incoming + // height bounded"), and asking it with a LayoutBuilder defers this + // whole subtree into a second layout pass. A consumer measured 1056 + // of them in one eight-scroll session against 258 widget builds. + logger.wrapWith("WindFullHeightBox", "h-full"); + widgetToBuild = WindFullHeightBox( + fallbackHeight: MediaQuery.of(context).size.height, + maxHeight: + hasMaxHeightConstraint ? styles.constraints!.maxHeight : null, + child: innerChild, ); } else { // h-1/2, h-1/3, etc: FractionallySizedBox (no LayoutBuilder) @@ -1763,48 +1751,18 @@ class WDiv extends StatelessWidget { } else { // Both width and height factors (e.g., w-full h-full, w-1/2 h-1/2) // Use LayoutBuilder only when needed for unbounded axis - final bool needsLayoutBuilder = isFullHeight; // h-full may be unbounded - if (needsLayoutBuilder) { - widgetToBuild = LayoutBuilder( - builder: (context, constraints) { - final bool heightUnbounded = !constraints.hasBoundedHeight; - final double? effectiveHeight = heightUnbounded - ? MediaQuery.of(context).size.height * - (styles.heightFactor ?? 1.0) - : null; - - Widget result; - if (effectiveHeight != null) { - // Unbounded height: use calculated size - result = SizedBox( - width: isFullWidth ? double.infinity : null, - height: effectiveHeight, - child: innerChild, - ); - } else { - // Bounded context: FractionallySizedBox handles both axes - result = FractionallySizedBox( - widthFactor: styles.widthFactor, - heightFactor: styles.heightFactor, - child: innerChild, - ); - } - - if (hasMaxWidthConstraint || hasMaxHeightConstraint) { - result = ConstrainedBox( - constraints: BoxConstraints( - maxWidth: hasMaxWidthConstraint - ? styles.constraints!.maxWidth - : double.infinity, - maxHeight: hasMaxHeightConstraint - ? styles.constraints!.maxHeight - : double.infinity, - ), - child: result, - ); - } - return result; - }, + if (isFullHeight) { + // Both axes, height full: the same render-layer box as the + // height-only path, carrying the width factor too. + logger.wrapWith("WindFullHeightBox", "w+h-full"); + widgetToBuild = WindFullHeightBox( + fallbackHeight: MediaQuery.of(context).size.height, + widthFactor: styles.widthFactor, + maxWidth: + hasMaxWidthConstraint ? styles.constraints!.maxWidth : null, + maxHeight: + hasMaxHeightConstraint ? styles.constraints!.maxHeight : null, + child: innerChild, ); } else { // Both fractional, neither h-full: FractionallySizedBox handles it diff --git a/lib/src/widgets/wind_full_height.dart b/lib/src/widgets/wind_full_height.dart new file mode 100644 index 00000000..f28d3b13 --- /dev/null +++ b/lib/src/widgets/wind_full_height.dart @@ -0,0 +1,213 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Fills the incoming height, falling back to [fallbackHeight] when there is +/// none to fill. +/// +/// This is what `h-full` composes to. The class exists because the question it +/// answers, "is the incoming height bounded", is only answerable during layout, +/// and the widget-layer way to ask it is a [LayoutBuilder]. A [LayoutBuilder] +/// defers its whole subtree into a second layout pass, and `h-full` is common +/// enough on a scrolling screen for that to show: a consumer measured 1056 of +/// them in one eight-scroll session against 258 widget builds, one per element +/// carrying the class, re-run on every frame. +/// +/// A render object reads `constraints` directly, so it needs no deferral and no +/// second pass. It is the same move [WindFractionBasis] and [WindMinWidthBox] +/// already make for their own sizing questions. +/// +/// [fallbackHeight] is passed in rather than read here because a render object +/// has no [BuildContext] and so cannot reach [MediaQuery]. The caller reads it +/// during build, which is where that lookup belongs anyway: it registers the +/// dependency, so a rotation or a window resize rebuilds and updates it. +class WindFullHeightBox extends SingleChildRenderObjectWidget { + /// The height to take when the incoming constraints do not bound one. + /// + /// The screen height, in every current caller. A `Column` child and a sliver + /// both offer an unbounded height, and "as tall as it wants" is not a size, + /// so `h-full` has to name a number instead. + final double fallbackHeight; + + /// `1.0` for `w-full`, null to leave the width to the incoming constraints. + /// + /// Only ever 1.0 today: every caller reaches this class through + /// `heightFactor == 1.0`, and a fractional width alongside it comes through + /// the same `w-full` flag. Kept nullable rather than a bool because the value + /// is what the arithmetic wants. + final double? widthFactor; + + /// `max-w-*`, or null when the class is absent. + final double? maxWidth; + + /// `max-h-*`, or null when the class is absent. + final double? maxHeight; + + /// Creates a [WindFullHeightBox]. + const WindFullHeightBox({ + super.key, + required this.fallbackHeight, + this.widthFactor, + this.maxWidth, + this.maxHeight, + required super.child, + }); + + @override + RenderObject createRenderObject(BuildContext context) => _RenderFullHeight( + fallbackHeight, + widthFactor, + maxWidth, + maxHeight, + ); + + @override + void updateRenderObject( + BuildContext context, + covariant RenderObject renderObject, + ) { + (renderObject as _RenderFullHeight) + ..fallbackHeight = fallbackHeight + ..widthFactor = widthFactor + ..maxWidth = maxWidth + ..maxHeight = maxHeight; + } +} + +class _RenderFullHeight extends RenderProxyBox { + _RenderFullHeight( + this._fallbackHeight, + this._widthFactor, + this._maxWidth, + this._maxHeight, + ); + + double _fallbackHeight; + set fallbackHeight(double value) { + if (value == _fallbackHeight) return; + _fallbackHeight = value; + markNeedsLayout(); + } + + double? _widthFactor; + set widthFactor(double? value) { + if (value == _widthFactor) return; + _widthFactor = value; + markNeedsLayout(); + } + + double? _maxWidth; + set maxWidth(double? value) { + if (value == _maxWidth) return; + _maxWidth = value; + markNeedsLayout(); + } + + double? _maxHeight; + set maxHeight(double? value) { + if (value == _maxHeight) return; + _maxHeight = value; + markNeedsLayout(); + } + + /// The height this box resolves to under [constraints]. + /// + /// The bounded and unbounded cases differ only in where the number comes + /// from, and `max-h-*` clamps both. That last part is a behaviour change + /// rather than a port: the widget-layer version applied the clamp on the + /// unbounded branch and not on the bounded one, so `h-full max-h-[120px]` + /// inside a 400 pixel parent rendered 400 and inside a `Column` rendered 120. + double _heightFor(BoxConstraints constraints) { + final double available = + constraints.hasBoundedHeight ? constraints.maxHeight : _fallbackHeight; + final double capped = + _maxHeight == null ? available : math.min(available, _maxHeight!); + + return constraints.constrainHeight(capped); + } + + /// The width constraints to hand the child. + /// + /// Untouched unless `w-full` asked for the whole width, which is what the + /// widget-layer `SizedBox(width: double.infinity)` and + /// `FractionallySizedBox(widthFactor: 1)` both did in their own branches. + (double, double) _widthRangeFor(BoxConstraints constraints) { + double minWidth = constraints.minWidth; + double maxWidth = constraints.maxWidth; + + if (_widthFactor != null && constraints.hasBoundedWidth) { + final double target = constraints.maxWidth * _widthFactor!; + minWidth = target; + maxWidth = target; + } + + if (_maxWidth != null && maxWidth > _maxWidth!) { + maxWidth = _maxWidth!; + if (minWidth > maxWidth) minWidth = maxWidth; + } + + return (minWidth, maxWidth); + } + + @override + void performLayout() { + final double height = _heightFor(constraints); + final (double minWidth, double maxWidth) = _widthRangeFor(constraints); + + final RenderBox? target = child; + if (target == null) { + size = constraints.constrain(Size(minWidth, height)); + return; + } + + target.layout( + BoxConstraints( + minWidth: minWidth, + maxWidth: maxWidth, + minHeight: height, + maxHeight: height, + ), + parentUsesSize: true, + ); + + // The width comes from the child so an unconstrained axis still hugs, the + // height from the resolution above so a bounded parent still gets the full + // fill it asked for. + size = constraints.constrain(Size(target.size.width, height)); + } + + // Intrinsics are deliberately left to `RenderProxyBox`, which forwards them + // to the child. + // + // That is the honest answer for a box whose job is to take what it is given: + // its natural height is its content's, and the fill happens against whatever + // the parent then offers. The first version overrode both to report + // [fallbackHeight], reasoning that a fill box "wants" the screen. Under an + // `IntrinsicHeight` beside a 60 pixel sibling that made the row 600 rather + // than 60, which is the opposite of what `h-full` means. + // + // Answering at all is the change. The old `LayoutBuilder` could not, so an + // `IntrinsicHeight` anywhere above `h-full` threw, and that limitation was + // documented rather than fixed. + + @override + Size computeDryLayout(BoxConstraints constraints) { + final double height = _heightFor(constraints); + final (double minWidth, double maxWidth) = _widthRangeFor(constraints); + + final RenderBox? target = child; + if (target == null) return constraints.constrain(Size(minWidth, height)); + + final Size childSize = target.getDryLayout( + BoxConstraints( + minWidth: minWidth, + maxWidth: maxWidth, + minHeight: height, + maxHeight: height, + ), + ); + + return constraints.constrain(Size(childSize.width, height)); + } +} diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 6ed5eae0..fbe0496e 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -2,7 +2,7 @@ name: wind-ui description: "fluttersdk_wind 1.5: utility-first Flutter styling with Tailwind-syntax className strings. 27 W-prefix widgets (WDiv, WText, WButton, WInput, WSelect, WDatePicker, WPopover, WCard, WTabs, plus five WForm* wrappers) parse className into a cached immutable WindStyle; WindRecipe and WindSlotRecipe compose variant classNames. Prefixes stack freely (dark: / hover: / focus: / md: / ios: / selected: / disabled: / custom), the last class in a family wins, an unrecognized token drops with a one-time kDebugMode hint, and every color token carries a dark: peer in the same className. TRIGGER when: writing or editing UI in a Flutter app that depends on fluttersdk_wind; any className string; any W-prefix widget; any WindTheme or WindThemeData reference; the user mentions Tailwind for Flutter, utility-first, className, or wind-ui. DO NOT TRIGGER when: backend, API, or state-management work that never touches a widget tree; a Flutter project without fluttersdk_wind in pubspec.yaml; Material-only widgets (Scaffold, AppBar, Dialog) with no Wind content inside." when_to_use: "Any task that produces, modifies, or audits Wind-styled UI: composing a className, picking the right W-widget, wiring a Form field, customizing WindThemeData, pairing dark-mode classes, debugging a layout or a RenderFlex overflow, building a popover, rendering a JSON tree via WDynamic, or composing a WindRecipe. Load it before the first line of new UI, and equally when auditing UI that already exists." -version: 2.13.2 +version: 2.14.0 --- @@ -231,7 +231,7 @@ Wind hides most boilerplate but never changes Flutter's "constraints down, sizes `items-stretch` inside a `SingleChildScrollView` needs an `IntrinsicHeight` wrapper from native Flutter; Wind has no token for it. Rare; reach for it when row children inside a scroll must match heights. -**Intrinsic sizing limitation.** Wrapping Wind content in `IntrinsicHeight` / `IntrinsicWidth` (or a `Row`/`Column` that needs child intrinsic heights for equal-height columns) throws `LayoutBuilder does not support returning intrinsic dimensions` WHEN that content triggers Wind's internal `LayoutBuilder` paths: `h-full` (only in an unbounded-height context) or a flex `basis-*` (a single `LayoutBuilder` around the surrounding flex). `LayoutBuilder` cannot answer intrinsic queries (a Flutter constraint, not a Wind bug). Escape hatches: use explicit `h-*` / `size-*` instead of `h-full`; do not wrap such content in `IntrinsicHeight`; for equal-height rows use a `Stack` + `Positioned(top:0,bottom:0)`. Wind's own `items-stretch` column equalizes cross-axis size without you adding `IntrinsicHeight` (it uses its own `LayoutBuilder` internally, so reach for it INSTEAD of `IntrinsicHeight`, not nested inside one). +**Intrinsic sizing limitation.** Wrapping Wind content in `IntrinsicHeight` / `IntrinsicWidth` (or a `Row`/`Column` that needs child intrinsic heights for equal-height columns) throws `LayoutBuilder does not support returning intrinsic dimensions` WHEN that content contains a `grid`, which composes a `Wrap` inside a `LayoutBuilder` to compute its column width. `LayoutBuilder` cannot answer intrinsic queries (a Flutter constraint, not a Wind bug). `h-full` and flex `basis-*` are NO LONGER triggers: both resolve through render objects (`WindFullHeightBox`, `WindMainExtentProvider`), and a render object answers intrinsics. Escape hatches for the `grid` case: explicit `h-*` / `size-*` cells, or a `Stack` + `Positioned(top:0,bottom:0)`; Wind's own `items-stretch` grid equalizes row heights with real layout, so reach for it INSTEAD of `IntrinsicHeight`. ## 7. className formatting diff --git a/skills/wind-ui/references/layouts.md b/skills/wind-ui/references/layouts.md index 1e2ae4e6..de8c4f3b 100644 --- a/skills/wind-ui/references/layouts.md +++ b/skills/wind-ui/references/layouts.md @@ -387,7 +387,7 @@ WDiv( > **Column width-stretch is intrinsic-free (no `IntrinsicHeight` needed).** The reverse case, a `flex flex-col items-stretch` (equal child WIDTHS), needs no `IntrinsicHeight`: Wind's column cross-axis stretch is a real render object (`WindCrossStretch`), and `basis-*` resolves without a `LayoutBuilder`. So a `flex flex-col` (with or without `basis-*`) renders under an `IntrinsicHeight`, a `Table`, or an `items-stretch` grid cell without asserting. Only the ROW height-match above still uses `IntrinsicHeight`. -> **Caveat: `h-full` still fails through an `IntrinsicHeight`.** A child that resolves `h-full` in an unbounded-height context still uses a Wind internal `LayoutBuilder`, so under an `IntrinsicHeight` it throws `LayoutBuilder does not support returning intrinsic dimensions` (a Flutter constraint, not a Wind bug). Keep children explicitly sized under an `IntrinsicHeight` and avoid `h-full` there. (Fractional `basis-*` no longer triggers this: it resolves against the flex's own extent via a real render object.) For equal-height cards outside a scroll, prefer fixed `h-*` cells or a `Stack` + `Positioned(top:0,bottom:0)`. +> **`h-full` is intrinsic-safe now.** It used to resolve through a Wind-internal `LayoutBuilder` and throw `LayoutBuilder does not support returning intrinsic dimensions` under an `IntrinsicHeight`; it is the `WindFullHeightBox` render object now, so it renders there, in a `Table` cell, and in an `items-stretch` grid cell without asserting, and it reports its child's intrinsic height so it matches the tallest sibling rather than the screen. The one remaining `LayoutBuilder` path is `grid`. For equal-height cards outside a scroll, `h-full` under an `IntrinsicHeight` now works; a `Stack` + `Positioned(top:0,bottom:0)` remains the cheaper shape. --- diff --git a/skills/wind-ui/references/tailwind-divergence.md b/skills/wind-ui/references/tailwind-divergence.md index f3e12cdb..3602f3b4 100644 --- a/skills/wind-ui/references/tailwind-divergence.md +++ b/skills/wind-ui/references/tailwind-divergence.md @@ -44,7 +44,7 @@ The base rule: Wind aims for syntactic familiarity, not semantic equivalence. Mo | `w-full` inside a flex Row | Works (max-width: 100%) | A bare `w-full` row child is treated as `flex-1` (wrapped in `Expanded`) and fills the row; `flex-1` is the clearer, idiomatic choice. `md:w-full` is not auto-expanded. | | `h-full` inside a vertical scroll | Works (max-height: 100%) | Raises an actionable dev assert ("use `flex-1` inside a `flex flex-col` instead of `h-full` inside a vertical scroll"); the scrollable parent threads a vertical-unbounded flag so the child can fail fast. Stripped in release. | | `overflow-x-auto` + `w-full min-w-[Npx]` (shadcn Table pattern) | Fills container, scrolls when content exceeds it | Same behavior, composed from existing tokens (no new token). `w-full` inside a horizontal scroll is sized to `max(viewport, min-w-*)` via the threaded viewport width instead of asserting on the scroll's unbounded width: fills on wide, scrolls on narrow. | -| `IntrinsicHeight` / `IntrinsicWidth` (Flutter widget, not a token) | N/A | Wind's own flex is intrinsic-safe: a `flex flex-col` (smart stretch, explicit `items-stretch`, or `basis-*`) uses NO `LayoutBuilder` (column stretch is the `WindCrossStretch` render object; `basis-*` resolves against the flex extent via `WindMainExtentProvider`), so it renders under an `IntrinsicHeight`/`Table`/grid cell without asserting. Still avoid wrapping ARBITRARY subtrees in `IntrinsicHeight`/`IntrinsicWidth` inside sheet/route open animations (they can raise `RenderBox was not laid out`); for a connector or rail prefer a `Stack` + `Positioned(top: 0, bottom: 0)` line or wind's `items-stretch` column. Wind itself uses no `IntrinsicHeight` or `IntrinsicWidth`. | +| `IntrinsicHeight` / `IntrinsicWidth` (Flutter widget, not a token) | N/A | Wind's own flex is intrinsic-safe, and so is `h-full` (the `WindFullHeightBox` render object): a `flex flex-col` (smart stretch, explicit `items-stretch`, or `basis-*`) uses NO `LayoutBuilder` (column stretch is the `WindCrossStretch` render object; `basis-*` resolves against the flex extent via `WindMainExtentProvider`), so it renders under an `IntrinsicHeight`/`Table`/grid cell without asserting. Still avoid wrapping ARBITRARY subtrees in `IntrinsicHeight`/`IntrinsicWidth` inside sheet/route open animations (they can raise `RenderBox was not laid out`); for a connector or rail prefer a `Stack` + `Positioned(top: 0, bottom: 0)` line or wind's `items-stretch` column. Wind itself uses no `IntrinsicHeight` or `IntrinsicWidth`. | | `overflow-y-auto` | Native browser scrollbar | Renders Flutter scroll view; needs constructor `scrollPrimary: true` for iOS tap-to-top | | `bg-red-500/50` | Color with 50% opacity (v3.x+) | Same syntax, same semantics | | `dark:bg-gray-900` | Active only when dark mode is enabled | Active only when `WindThemeData.brightness == Brightness.dark`; required to pair every color | diff --git a/skills/wind-ui/references/tokens.md b/skills/wind-ui/references/tokens.md index b56de159..ec21832c 100644 --- a/skills/wind-ui/references/tokens.md +++ b/skills/wind-ui/references/tokens.md @@ -79,7 +79,7 @@ Inline color escape hatches that bypass the cache key: | `align-content-start` / `-end` / `-center` / `-between` / `-around` / `-evenly` / `-stretch` | Wrap-only, `WrapAlignment` for runs | | `align-self-start` / `-end` / `-center` / `-stretch` / `-auto` (or the `self-*` shorthand) | Per-child cross-axis override | | `axis-min` / `axis-max` | Wind-only: `MainAxisSize.min` / `.max` on the parent flex | -| `grid-cols-N` | N columns (any integer); renders as `Wrap` with computed column widths. Add `items-stretch` for equal-height rows: each row is a real-layout equal-height row (measures each cell, re-lays to at least the tallest via a MIN height, never a tight squeeze), NOT `IntrinsicHeight`, so cells containing a `flex flex-col`, `h-full`, or `basis-*` (LayoutBuilder-bearing) stretch without asserting or overflowing (#139, #141) | +| `grid-cols-N` | N columns (any integer); renders as `Wrap` with computed column widths. Add `items-stretch` for equal-height rows: each row is a real-layout equal-height row (measures each cell, re-lays to at least the tallest via a MIN height, never a tight squeeze), NOT `IntrinsicHeight`, so cells containing a `flex flex-col`, `h-full`, or `basis-*` stretch without asserting or overflowing (#139, #141); none of those three carries a `LayoutBuilder` any more | | `order-0` through `order-12` | Child order index | | `order-first` / `order-last` / `order-none` | Sentinel order (first=-9999, last=9999, none=0) | | `order-[N]` | Arbitrary signed integer (e.g. `order-[-5]`) | diff --git a/test/flex/intrinsic_safe_layout_test.dart b/test/flex/intrinsic_safe_layout_test.dart index 3ce12332..898471c4 100644 --- a/test/flex/intrinsic_safe_layout_test.dart +++ b/test/flex/intrinsic_safe_layout_test.dart @@ -363,4 +363,76 @@ void main() { expect(error.toString(), contains('flex-1')); }); }); + + group('(e) h-full is intrinsic-safe', () { + // It was not, and the limitation was documented rather than fixed: `h-full` + // resolved through a `LayoutBuilder`, which cannot answer an intrinsic + // query, so any `IntrinsicHeight` above it threw. `SKILL.md`, three + // reference pages and `doc/layout/sizing.md` all carried the caveat and the + // escape hatch ("use explicit h-* instead"). + // + // `h-full` is a render object now (`WindFullHeightBox`), and a render + // object answers intrinsics. Verified as an A/B against master, which + // throws `LayoutBuilder does not support returning intrinsic dimensions` on + // exactly this tree. + testWidgets('h-full under an IntrinsicHeight no longer throws', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Column( + children: [ + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: const [ + SizedBox(width: 80, height: 60), + WDiv(className: 'h-full w-[40px] bg-red-500'), + ], + ), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('and it matches the tallest sibling rather than collapsing', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Column( + children: [ + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: const [ + SizedBox(width: 80, height: 60), + WDiv( + className: 'h-full w-[40px]', + child: SizedBox.expand(), + ), + ], + ), + ), + ], + ), + ), + ), + ); + + // The 60 pixel sibling sets the row height; `h-full` fills it. Asserting + // the number rather than just the absence of a throw, because a box that + // silently collapsed to zero would also not throw. + expect(tester.getSize(find.byType(SizedBox).last).height, 60); + }); + }); } diff --git a/test/widgets/w_div/full_height_sizing_test.dart b/test/widgets/w_div/full_height_sizing_test.dart new file mode 100644 index 00000000..ee66f601 --- /dev/null +++ b/test/widgets/w_div/full_height_sizing_test.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// Characterisation tests for `h-full`, pinned before its implementation moves +/// off `LayoutBuilder`. +/// +/// `h-full` has to answer a question only layout can answer: is the incoming +/// height bounded. When it is, the element fills it; when it is not (a `Column` +/// child, a sliver), there is nothing to fill and the element falls back to the +/// screen height. That branch is why the path is wrapped in a `LayoutBuilder`, +/// and a `LayoutBuilder` defers its whole subtree into a second layout pass: +/// measured in a consumer at 1056 of them in one eight-scroll session against +/// 258 widget builds. +/// +/// Every case below passes against the `LayoutBuilder` implementation. They are +/// written first precisely so that the replacement can be judged by whether +/// they all still pass, rather than by reading two layout algorithms side by +/// side and hoping. +/// +/// The combinations that matter are boundedness, the presence of a width +/// factor, and `max-h-*`, because those are the three things the old code +/// branched on. +/// +/// Everything is measured on a keyed CHILD rather than on the `WDiv` itself. +/// `FractionallySizedBox` is an overflow box: it takes the space it is offered +/// and applies the factor to its child, so `getSize(find.byType(WDiv))` reports +/// the offered space in every case and cannot tell `h-1/2` from `h-full`. The +/// first version of this file measured there and read four passing behaviours +/// as failures. +void main() { + setUp(WindParser.clearCache); + + /// Pumps [child] inside a box of a known size, so `h-full` sees a BOUNDED + /// height of exactly 400. + Future pumpBounded(WidgetTester tester, Widget child) { + return tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Center( + child: SizedBox(width: 300, height: 400, child: child), + ), + ), + ), + ); + } + + /// Pumps [child] where the vertical axis is UNBOUNDED, which is what a + /// `Column` hands its children. + Future pumpUnbounded(WidgetTester tester, Widget child) { + return tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [SizedBox(width: 300, child: child)], + ), + ), + ), + ); + } + + group('h-full in a bounded height', () { + testWidgets('fills the incoming height', (tester) async { + await pumpBounded( + tester, const WDiv(className: 'h-full', child: _Probe())); + + expect(tester.getSize(find.byType(_Probe)).height, 400); + }); + + testWidgets('with w-full fills both axes', (tester) async { + await pumpBounded( + tester, const WDiv(className: 'w-full h-full', child: _Probe())); + + expect(tester.getSize(find.byType(_Probe)), const Size(300, 400)); + }); + + testWidgets('max-h-* clamps the fill', (tester) async { + // Skipped: this fails on master too, so it is a pre-existing defect + // rather than a regression from the render-layer rewrite, and fixing it + // is a different change from this one. + // + // `max-h-*` reaches the element as a `ConstrainedBox` applied INSIDE the + // sizing wrapper, and `BoxConstraints.enforce` clamps an additional + // constraint into the incoming range: handed a tight 400 it computes + // `clamp(120, 400, 400)` and yields 400, so the cap is discarded. The old + // `LayoutBuilder` had the same shape and the same result. The fix is to + // apply the cap OUTSIDE the sizing box so it narrows the constraints the + // box then fills, which is a wrapping-order change to `w_div.dart` rather + // than anything this class does. + // + // The asymmetry is what makes it a defect rather than a decision: the + // unbounded branch DOES honour `max-h-*` (see the case below), so the + // same className means two different things depending on the parent. + await pumpBounded( + tester, + const WDiv(className: 'h-full max-h-[120px]', child: _Probe()), + ); + + expect(tester.getSize(find.byType(_Probe)).height, 120); + // See the note above: pre-existing on master, and the fix is a + // wrapping-order change in `w_div.dart` rather than anything here. + }, skip: true); + + testWidgets('with w-full and max-h-* clamps only the height', ( + tester, + ) async { + await pumpBounded( + tester, + const WDiv(className: 'w-full h-full max-h-[120px]', child: _Probe()), + ); + + expect(tester.getSize(find.byType(_Probe)), const Size(300, 120)); + // See the note above: pre-existing on master, and the fix is a + // wrapping-order change in `w_div.dart` rather than anything here. + }, skip: true); + + testWidgets('a child is laid out against the filled height', ( + tester, + ) async { + await pumpBounded( + tester, + const WDiv( + className: 'h-full', + child: WDiv(className: 'h-full bg-red-500'), + ), + ); + + // Both boxes fill: the inner one sees the outer's 400 as its own bound. + for (final Size size in tester.widgetList(find.byType(WDiv)).map( + (WDiv w) => tester.getSize(find.byWidget(w)), + )) { + expect(size.height, 400); + } + }); + }); + + group('h-full in an unbounded height', () { + testWidgets('falls back to the screen height rather than asserting', ( + tester, + ) async { + await pumpUnbounded( + tester, const WDiv(className: 'h-full', child: _Probe())); + + final double screen = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect(tester.getSize(find.byType(_Probe)).height, screen); + }); + + testWidgets('max-h-* clamps the screen-height fallback', (tester) async { + await pumpUnbounded( + tester, + const WDiv(className: 'h-full max-h-[120px]', child: _Probe()), + ); + + expect(tester.getSize(find.byType(_Probe)).height, 120); + }); + + testWidgets('with w-full still fills the bounded width', (tester) async { + await pumpUnbounded( + tester, const WDiv(className: 'w-full h-full', child: _Probe())); + + expect(tester.getSize(find.byType(_Probe)).width, 300); + }); + }); + + group('the outer box, which the child measurements do not see', () { + // Pinned because the replacement must not change what the element itself + // reports to ITS parent, only how it gets there. `FractionallySizedBox` is + // an overflow box and takes the space it is offered; the unbounded branch + // is a `SizedBox` and hugs the height it chose. + testWidgets('takes the offered space when the height is bounded', ( + tester, + ) async { + await pumpBounded( + tester, const WDiv(className: 'h-full', child: _Probe())); + + expect(tester.getSize(find.byType(WDiv)), const Size(300, 400)); + }); + + testWidgets('hugs the fallback height when there is none to fill', ( + tester, + ) async { + await pumpUnbounded( + tester, const WDiv(className: 'h-full', child: _Probe())); + + final double screen = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect(tester.getSize(find.byType(WDiv)).height, screen); + }); + }); + + group('h-fraction is unaffected, in either context', () { + testWidgets('h-1/2 takes half a bounded height', (tester) async { + await pumpBounded( + tester, const WDiv(className: 'h-1/2', child: _Probe())); + + expect(tester.getSize(find.byType(_Probe)).height, 200); + }); + + testWidgets('w-1/2 h-1/2 takes half of both', (tester) async { + await pumpBounded( + tester, const WDiv(className: 'w-1/2 h-1/2', child: _Probe())); + + expect(tester.getSize(find.byType(_Probe)), const Size(150, 200)); + }); + }); +} + +/// A child that fills whatever it is given, so its measured size IS the box the +/// sizing classes produced. +class _Probe extends StatelessWidget { + const _Probe(); + + @override + Widget build(BuildContext context) => const SizedBox.expand(); +} diff --git a/test/widgets/w_div/sizing_test.dart b/test/widgets/w_div/sizing_test.dart index d77a9f64..f5da6704 100644 --- a/test/widgets/w_div/sizing_test.dart +++ b/test/widgets/w_div/sizing_test.dart @@ -101,12 +101,18 @@ void main() { ), ); - expect(find.byType(FractionallySizedBox), findsOneWidget); - final FractionallySizedBox box = tester.widget( - find.byType(FractionallySizedBox), - ); - expect(box.widthFactor, 0.5); - expect(box.heightFactor, 1.0); + // Asserted on the resulting SIZE rather than on the widget that + // produced it. The previous version looked for a `FractionallySizedBox` + // carrying the two factors, which pinned one composition rather than the + // behaviour: `h-full` now resolves through `WindFullHeightBox` and the + // element sizes identically. A white-box assertion here fails on a change + // that a user cannot see, and passes on one they can. + final Size size = tester.getSize(find.text('Test')); + final Size screen = + tester.view.physicalSize / tester.view.devicePixelRatio; + + expect(size.width, screen.width / 2); + expect(size.height, screen.height); }); group('Sizing Optimization Tests', () { @@ -200,9 +206,21 @@ void main() { ), ); - final wDivFinder = find.byType(WDiv); + // The height it RESOLVED TO, not the widget it used to get there. + // This asserted a `LayoutBuilder` descendant, which is the thing the + // render-layer rewrite removed on purpose: `h-full` in an unbounded + // column still falls back to the screen height, and that fallback is + // the behaviour worth pinning. + final double screenHeight = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect(tester.getSize(find.text('Test')).height, screenHeight); + + // Deliberately no assertion about WHICH widget produced that height. + // The old one named `LayoutBuilder`, and naming its replacement would + // repeat the mistake: the next rewrite would fail this test without + // changing anything a user can observe. expect( - find.descendant(of: wDivFinder, matching: find.byType(LayoutBuilder)), + find.byType(WDiv), findsOneWidget, reason: 'h-full in unbounded parent requires LayoutBuilder to check constraints', From 937336e5911b392d7ba117bb7c2ff4f13ea0823d Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 8 Sep 2026 02:15:58 +0300 Subject: [PATCH 2/5] test(w-div): cover every branch of WindFullHeightBox codecov/patch flagged 32 uncovered lines on the new render object while the project gate passed, because the project gate is a total and the patch gate is about the lines a PR adds. All four groups were real behaviour rather than lines to pad. `updateRenderObject` and the four setters are what make a className change or a rotation re-resolve rather than keep the size the box first computed; the `max-w-*` clamp had no test at all; a childless element still has to report a size; and a dry layout that disagrees with the layout it precedes gives a measuring parent one answer and then renders another. The two null-child branches are unreachable from `WDiv`, which is the only construction site and passes the accumulated subtree rather than null. They stay because a RenderProxyBox has to survive a null child, and carry a block-form ignore with that reason: the line form does not survive `dart format`, which moves the statement onto a continuation line and leaves the pragma behind. Coverage 94.5% to 95.2%, and nothing in the new file is uncovered. --- CHANGELOG.md | 4 + lib/src/widgets/wind_full_height.dart | 12 +- .../w_div/full_height_sizing_test.dart | 114 ++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d1f1bd0..e342064e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. - **`h-full` resolves at the render layer instead of through a `LayoutBuilder`.** The question it asks ("is the incoming height bounded") is only answerable during layout, and a `LayoutBuilder` was the widget-layer way to ask it; a `LayoutBuilder` also defers its whole subtree into a second layout pass. A consumer measured 1056 of them in one eight-scroll session against 258 widget builds, one per element carrying the class, re-run every frame. `WindFullHeightBox` reads `constraints` directly and needs neither. Behaviour is otherwise unchanged, pinned by twelve characterisation tests written against the old implementation first. +### Quality + +- Nineteen tests for `WindFullHeightBox` covering every branch it has: bounded and unbounded, with and without a width factor, `max-w-*` and `max-h-*`, the outer box's own reported size, in-place updates through all four setters (including a screen-size change, which is what a rotation is), a childless element, and the dry-layout contract agreeing with the size actually laid out. Line coverage 94.5% to 95.2%; the two null-child branches carry a block ignore with the reason, since `WDiv` is the only construction site and always passes a real subtree. + ### Known - **`h-full max-h-*` discards the cap when the parent bounds the height.** `max-h-*` arrives as a `ConstrainedBox` INSIDE the sizing wrapper, and `BoxConstraints.enforce` clamps an additional constraint into the incoming range: handed a tight 400 it computes `clamp(120, 400, 400)` and yields 400. The unbounded branch does honour the cap, so the same className means two different things depending on the parent. Pre-existing on 1.5.1 and unchanged here; the fix is a wrapping-order change (apply the cap outside the sizing box, so it narrows what the box then fills). Two skipped tests in `test/widgets/w_div/full_height_sizing_test.dart` carry the reproduction. diff --git a/lib/src/widgets/wind_full_height.dart b/lib/src/widgets/wind_full_height.dart index f28d3b13..f8e98a97 100644 --- a/lib/src/widgets/wind_full_height.dart +++ b/lib/src/widgets/wind_full_height.dart @@ -156,10 +156,15 @@ class _RenderFullHeight extends RenderProxyBox { final (double minWidth, double maxWidth) = _widthRangeFor(constraints); final RenderBox? target = child; + // Unreachable from `WDiv`, which is the only construction site and always + // passes the accumulated tree, never null. Kept because a `RenderProxyBox` + // has to survive a null child, which is how `_RenderCrossStretch` treats it. + // coverage:ignore-start if (target == null) { size = constraints.constrain(Size(minWidth, height)); return; } + // coverage:ignore-end target.layout( BoxConstraints( @@ -197,7 +202,12 @@ class _RenderFullHeight extends RenderProxyBox { final (double minWidth, double maxWidth) = _widthRangeFor(constraints); final RenderBox? target = child; - if (target == null) return constraints.constrain(Size(minWidth, height)); + // Unreachable from `WDiv`, as above. + // coverage:ignore-start + if (target == null) { + return constraints.constrain(Size(minWidth, height)); + } + // coverage:ignore-end final Size childSize = target.getDryLayout( BoxConstraints( diff --git a/test/widgets/w_div/full_height_sizing_test.dart b/test/widgets/w_div/full_height_sizing_test.dart index ee66f601..d8ea9943 100644 --- a/test/widgets/w_div/full_height_sizing_test.dart +++ b/test/widgets/w_div/full_height_sizing_test.dart @@ -207,6 +207,120 @@ void main() { expect(tester.getSize(find.byType(_Probe)), const Size(150, 200)); }); }); + + group('h-full updates in place', () { + // `updateRenderObject` and the four setters. A `WDiv` that keeps its + // identity while its className changes must re-lay-out rather than keep the + // size it resolved the first time, and the same applies when the screen + // itself changes: `fallbackHeight` is read during build, so a rotation is + // an update rather than a rebuild from scratch. + testWidgets('a changed max-h-* re-resolves the height', (tester) async { + await pumpUnbounded( + tester, + const WDiv(className: 'h-full max-h-[120px]', child: _Probe()), + ); + expect(tester.getSize(find.byType(_Probe)).height, 120); + + await pumpUnbounded( + tester, + const WDiv(className: 'h-full max-h-[200px]', child: _Probe()), + ); + expect(tester.getSize(find.byType(_Probe)).height, 200); + }); + + testWidgets('a changed width factor re-resolves the width', (tester) async { + await pumpBounded( + tester, + const WDiv(className: 'w-full h-full', child: _Probe()), + ); + expect(tester.getSize(find.byType(_Probe)).width, 300); + + await pumpBounded( + tester, + const WDiv(className: 'w-1/2 h-full', child: _Probe()), + ); + expect(tester.getSize(find.byType(_Probe)).width, 150); + }); + + testWidgets('a changed max-w-* re-resolves the width', (tester) async { + await pumpBounded( + tester, + const WDiv(className: 'w-full h-full max-w-[200px]', child: _Probe()), + ); + expect(tester.getSize(find.byType(_Probe)).width, 200); + + await pumpBounded( + tester, + const WDiv(className: 'w-full h-full max-w-[100px]', child: _Probe()), + ); + expect(tester.getSize(find.byType(_Probe)).width, 100); + }); + + testWidgets('a changed screen height re-resolves the fallback', ( + tester, + ) async { + addTearDown(tester.view.reset); + + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + await pumpUnbounded( + tester, const WDiv(className: 'h-full', child: _Probe())); + expect(tester.getSize(find.byType(_Probe)).height, 800); + + tester.view.physicalSize = const Size(400, 500); + await pumpUnbounded( + tester, const WDiv(className: 'h-full', child: _Probe())); + expect(tester.getSize(find.byType(_Probe)).height, 500); + }); + }); + + group('h-full with no child', () { + // A childless `WDiv` is a real shape (a divider, a rule, a spacer), and the + // box still has to report a size rather than reaching for a child that is + // not there. + testWidgets('still fills a bounded height', (tester) async { + await pumpBounded(tester, const WDiv(className: 'h-full w-[40px]')); + + expect(tester.getSize(find.byType(WDiv)).height, 400); + }); + }); + + group('h-full answers a dry layout', () { + // Asked directly, because the widget trees that route through + // `computeDryLayout` do so from inside their own layout and are awkward to + // build on purpose. The contract is what matters: a dry layout has to agree + // with the real one, or a parent that measures before laying out gets a + // different answer than the one it then renders. + testWidgets('and agrees with the size it then lays out', (tester) async { + await pumpBounded( + tester, + const WDiv(className: 'w-full h-full', child: _Probe()), + ); + + final RenderBox box = tester.renderObject( + find.byType(WDiv), + ); + const BoxConstraints incoming = BoxConstraints( + maxWidth: 300, + maxHeight: 400, + ); + + expect(box.getDryLayout(incoming), box.size); + }); + + testWidgets('with no child, and with an unbounded height', (tester) async { + await pumpUnbounded(tester, const WDiv(className: 'h-full w-[40px]')); + + final RenderBox box = tester.renderObject( + find.byType(WDiv), + ); + + expect( + box.getDryLayout(const BoxConstraints(maxWidth: 300)).height, + box.size.height, + ); + }); + }); } /// A child that fills whatever it is given, so its measured size IS the box the From dcdc164b214c6aab3ebe49d5acf4060dc9d119b6 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 8 Sep 2026 02:29:58 +0300 Subject: [PATCH 3/5] fix(w-div): apply max-h-* and max-w-* to an h-full element, and correct the story Two review rounds, and the second one is where I had this wrong rather than merely incomplete. I filed `h-full max-h-*` as a pre-existing defect and skipped two tests against it. Measured as an A/B against master, it is two different things: Under a LOOSE bounded height the cap was discarded and now applies. Master's bounded branch wrapped no `ConstrainedBox` at all, so `h-full max-h-[120px]` under a `ConstrainedBox(maxHeight: 400)` rendered 400; it renders 120 here. That is a fix this PR already contained and did not claim. Under a TIGHT one it still yields the parent's height, and that is correct rather than the same bug: a tight constraint is the parent stating an exact size, and no className overrides it. The two skipped tests asserted 120 there and were simply wrong. They are gone, replaced by one case for each parent. The same mechanism was hiding a second discard on the other axis, which the review flagged as a possible regression. It is the reverse: `w-1/2 h-full max-w-[100px]` in a 300 pixel parent renders 150 on master and 100 here. Master put its `ConstrainedBox` outside the `FractionallySizedBox`, but `enforce` clamps an additional constraint into the incoming range, and against the tight width the fraction had already produced it computed `clamp(100, 150, 150)`. The arithmetic in the review was right and the direction was not, which took an A/B to establish rather than a reading. Also corrected, all of it review-found: The two `coverage:ignore` blocks are gone. Their stated reason, that a null child is unreachable from `WDiv`, is false: a childless `WDiv` carrying only `h-full` builds no core structure and reaches the box with null. The two tests that looked like they covered it carried `w-[40px]`, which gives the box a child, so they covered nothing; one of them was vacuous besides, asserting a height its own wrapper had already fixed. Both branches are now genuinely covered and the file has no uncovered line. `widthFactor`'s doc claimed 1.0 was the only value it ever takes, which a test in this same PR contradicts (`w-1/2 h-full` arrives with 0.5). `doc/layout/grid.md` still said `h-full` and `basis-*` carry a `LayoutBuilder`; it was the sixth surface and the first sync pass missed it. Two strategy comments in `w_div.dart` still described the deleted approach, and a `reason:` string in `sizing_test.dart` still named `LayoutBuilder` on an assertion rewritten not to care. `CHANGELOG`'s `### Known` is not one of the subsections `CLAUDE.md` allows, and the mechanism it described was master's rather than this branch's. Gone; the caps are under `Fixed` where they belong. Gates: `dart analyze` clean, `dart format .` no diff, 1782 tests green with one pre-existing skip and none of mine, `./tool/coverage.sh 90` at 95.2%, `tool/check-docs.py` 0 issues. --- CHANGELOG.md | 7 +- doc/layout/grid.md | 2 +- lib/src/widgets/w_div.dart | 5 +- lib/src/widgets/wind_full_height.dart | 44 ++++++---- .../w_div/full_height_sizing_test.dart | 86 ++++++++++++------- test/widgets/w_div/sizing_test.dart | 2 +- 6 files changed, 89 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e342064e..20401bee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ### Fixed +- **`max-h-*` and `max-w-*` now apply to an `h-full` element.** Both were discarded, by the same mechanism in two places: the cap arrived as a `ConstrainedBox` whose additional constraint `BoxConstraints.enforce` clamps into the incoming range, and the incoming range was already tight. `h-full max-h-[120px]` under a `ConstrainedBox(maxHeight: 400)` rendered 400 and now renders 120; `w-1/2 h-full max-w-[100px]` in a 300 pixel parent rendered 150 and now renders 100. A TIGHT parent still wins over `max-h-*`, which is correct rather than the same bug: a tight constraint is the parent stating an exact size. - **`h-full` no longer throws under an `IntrinsicHeight`.** It resolved through a `LayoutBuilder`, which cannot answer an intrinsic query, so any `IntrinsicHeight` / `IntrinsicWidth` above it asserted `LayoutBuilder does not support returning intrinsic dimensions`. The limitation was documented on five surfaces with an escape hatch ("use explicit `h-*` instead") rather than fixed. `h-full` is now the `WindFullHeightBox` render object, which answers intrinsics by forwarding to its child, so it renders under an `IntrinsicHeight`, in a `Table` cell and in an `items-stretch` grid cell, and matches the tallest sibling rather than reporting the screen height. `grid` is the one remaining `LayoutBuilder` path. ### Changed @@ -18,11 +19,7 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ### Quality -- Nineteen tests for `WindFullHeightBox` covering every branch it has: bounded and unbounded, with and without a width factor, `max-w-*` and `max-h-*`, the outer box's own reported size, in-place updates through all four setters (including a screen-size change, which is what a rotation is), a childless element, and the dry-layout contract agreeing with the size actually laid out. Line coverage 94.5% to 95.2%; the two null-child branches carry a block ignore with the reason, since `WDiv` is the only construction site and always passes a real subtree. - -### Known - -- **`h-full max-h-*` discards the cap when the parent bounds the height.** `max-h-*` arrives as a `ConstrainedBox` INSIDE the sizing wrapper, and `BoxConstraints.enforce` clamps an additional constraint into the incoming range: handed a tight 400 it computes `clamp(120, 400, 400)` and yields 400. The unbounded branch does honour the cap, so the same className means two different things depending on the parent. Pre-existing on 1.5.1 and unchanged here; the fix is a wrapping-order change (apply the cap outside the sizing box, so it narrows what the box then fills). Two skipped tests in `test/widgets/w_div/full_height_sizing_test.dart` carry the reproduction. +- Twenty tests for `WindFullHeightBox`, none skipped, covering: bounded and unbounded, with and without a width factor, `max-w-*` and `max-h-*`, the outer box's own reported size, in-place updates through all four setters (including a screen-size change, which is what a rotation is), a childless element, and the dry-layout contract agreeing with the size actually laid out. Line coverage 94.5% to 95.2%. Every line of the new file is covered, including both null-child branches: a childless `WDiv` carrying only `h-full` builds no core structure and reaches the box with a null child, so an earlier `coverage:ignore` on those lines rested on a false premise. ## [1.5.1] - 2026-09-07 diff --git a/doc/layout/grid.md b/doc/layout/grid.md index edb86ffa..27df0bf2 100644 --- a/doc/layout/grid.md +++ b/doc/layout/grid.md @@ -79,7 +79,7 @@ WDiv( ) ``` -The equal-height rows are laid out for real (each cell is measured with a loose height, then re-laid to at least the row's tallest via a **min** height, never a tight squeeze), NOT via `IntrinsicHeight`, so cells whose content is itself a `flex flex-col`, or that use `h-full` / `basis-*` (which carry a `LayoutBuilder`), stretch correctly instead of asserting `LayoutBuilder does not support returning intrinsic dimensions`. Because a cell is never forced below its own content height, a stretched cell also produces no residual `RenderFlex overflowed` warning (#141). +The equal-height rows are laid out for real (each cell is measured with a loose height, then re-laid to at least the row's tallest via a **min** height, never a tight squeeze), NOT via `IntrinsicHeight`, so cells whose content is itself a `flex flex-col`, or that use `h-full` / `basis-*` (neither of which carries a `LayoutBuilder` any more), stretch correctly instead of asserting `LayoutBuilder does not support returning intrinsic dimensions`. Because a cell is never forced below its own content height, a stretched cell also produces no residual `RenderFlex overflowed` warning (#141). ## Responsive diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index a27a4ca1..0367231f 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -1637,11 +1637,14 @@ class WDiv extends StatelessWidget { // markNeedsLayout (e.g. textScaler change), it causes a debug assertion: // _debugRelayoutBoundaryAlreadyMarkedNeedsLayout() is not true // + // No path below carries one any more: `h-full` is the `WindFullHeightBox` + // render object. `grid` is the last LayoutBuilder in the widget. + // // Strategy: // - w-full: SizedBox(width: infinity), no LayoutBuilder needed // - w-full + max-w-*: ConstrainedBox + SizedBox, no LayoutBuilder needed // - w-1/2, w-1/3 etc: FractionallySizedBox, no LayoutBuilder needed - // - h-full: LayoutBuilder only when vertical axis is unbounded + // - h-full: WindFullHeightBox, a render object, on both axes if (styles.widthFactor != null || styles.heightFactor != null) { final innerChild = widgetToBuild; diff --git a/lib/src/widgets/wind_full_height.dart b/lib/src/widgets/wind_full_height.dart index f8e98a97..c808fa11 100644 --- a/lib/src/widgets/wind_full_height.dart +++ b/lib/src/widgets/wind_full_height.dart @@ -32,10 +32,9 @@ class WindFullHeightBox extends SingleChildRenderObjectWidget { /// `1.0` for `w-full`, null to leave the width to the incoming constraints. /// - /// Only ever 1.0 today: every caller reaches this class through - /// `heightFactor == 1.0`, and a fractional width alongside it comes through - /// the same `w-full` flag. Kept nullable rather than a bool because the value - /// is what the arithmetic wants. + /// Any fraction, not just 1.0: `w-1/2 h-full` reaches this class with 0.5. + /// An earlier version of this doc claimed 1.0 was the only value, which the + /// suite contradicts. final double? widthFactor; /// `max-w-*`, or null when the class is absent. @@ -114,10 +113,18 @@ class _RenderFullHeight extends RenderProxyBox { /// The height this box resolves to under [constraints]. /// /// The bounded and unbounded cases differ only in where the number comes - /// from, and `max-h-*` clamps both. That last part is a behaviour change - /// rather than a port: the widget-layer version applied the clamp on the - /// unbounded branch and not on the bounded one, so `h-full max-h-[120px]` - /// inside a 400 pixel parent rendered 400 and inside a `Column` rendered 120. + /// from, and `max-h-*` clamps both. That is narrower than it sounds, and the + /// closing `constrainHeight` is why. + /// + /// Under a LOOSE bounded height the cap now applies where it used to be + /// discarded: the widget-layer version wrapped no `ConstrainedBox` on its + /// bounded branch at all, so `h-full max-h-[120px]` under a + /// `ConstrainedBox(maxHeight: 400)` rendered 400 and now renders 120. + /// + /// Under a TIGHT one it still yields the parent's height, and that is correct + /// rather than the same bug: a tight constraint is the parent stating an + /// exact size, and no className overrides it. `constrainHeight` is what keeps + /// this box honest about that. double _heightFor(BoxConstraints constraints) { final double available = constraints.hasBoundedHeight ? constraints.maxHeight : _fallbackHeight; @@ -129,9 +136,16 @@ class _RenderFullHeight extends RenderProxyBox { /// The width constraints to hand the child. /// - /// Untouched unless `w-full` asked for the whole width, which is what the + /// Untouched unless a `w-*` fraction asked for a share, which is what the /// widget-layer `SizedBox(width: double.infinity)` and - /// `FractionallySizedBox(widthFactor: 1)` both did in their own branches. + /// `FractionallySizedBox(widthFactor: ...)` both did in their own branches. + /// + /// The cap applies AFTER the fraction, and that is a fix rather than a port. + /// The widget-layer version put its `ConstrainedBox` outside the + /// `FractionallySizedBox`, but `BoxConstraints.enforce` clamps an additional + /// constraint into the incoming range, so against the tight width the + /// fraction had already produced the cap was discarded: `w-1/2 h-full + /// max-w-[100px]` in a 300 pixel parent rendered 150 and now renders 100. (double, double) _widthRangeFor(BoxConstraints constraints) { double minWidth = constraints.minWidth; double maxWidth = constraints.maxWidth; @@ -156,15 +170,12 @@ class _RenderFullHeight extends RenderProxyBox { final (double minWidth, double maxWidth) = _widthRangeFor(constraints); final RenderBox? target = child; - // Unreachable from `WDiv`, which is the only construction site and always - // passes the accumulated tree, never null. Kept because a `RenderProxyBox` - // has to survive a null child, which is how `_RenderCrossStretch` treats it. - // coverage:ignore-start + // Reachable: a childless `WDiv` (a rule, a divider, a spacer) carrying only + // `h-full` builds no core structure, so this box gets a null child. if (target == null) { size = constraints.constrain(Size(minWidth, height)); return; } - // coverage:ignore-end target.layout( BoxConstraints( @@ -202,12 +213,9 @@ class _RenderFullHeight extends RenderProxyBox { final (double minWidth, double maxWidth) = _widthRangeFor(constraints); final RenderBox? target = child; - // Unreachable from `WDiv`, as above. - // coverage:ignore-start if (target == null) { return constraints.constrain(Size(minWidth, height)); } - // coverage:ignore-end final Size childSize = target.getDryLayout( BoxConstraints( diff --git a/test/widgets/w_div/full_height_sizing_test.dart b/test/widgets/w_div/full_height_sizing_test.dart index d8ea9943..974ebd74 100644 --- a/test/widgets/w_div/full_height_sizing_test.dart +++ b/test/widgets/w_div/full_height_sizing_test.dart @@ -77,45 +77,62 @@ void main() { expect(tester.getSize(find.byType(_Probe)), const Size(300, 400)); }); - testWidgets('max-h-* clamps the fill', (tester) async { - // Skipped: this fails on master too, so it is a pre-existing defect - // rather than a regression from the render-layer rewrite, and fixing it - // is a different change from this one. - // - // `max-h-*` reaches the element as a `ConstrainedBox` applied INSIDE the - // sizing wrapper, and `BoxConstraints.enforce` clamps an additional - // constraint into the incoming range: handed a tight 400 it computes - // `clamp(120, 400, 400)` and yields 400, so the cap is discarded. The old - // `LayoutBuilder` had the same shape and the same result. The fix is to - // apply the cap OUTSIDE the sizing box so it narrows the constraints the - // box then fills, which is a wrapping-order change to `w_div.dart` rather - // than anything this class does. - // - // The asymmetry is what makes it a defect rather than a decision: the - // unbounded branch DOES honour `max-h-*` (see the case below), so the - // same className means two different things depending on the parent. + testWidgets('a TIGHT parent wins over max-h-*, which is correct', ( + tester, + ) async { + // `pumpBounded` uses a `SizedBox(height: 400)`, a tight constraint: the + // parent is stating an exact size, and no className overrides that. The + // first version of this file asserted 120 here and called the 400 a + // defect; it is Flutter's constraint model working. await pumpBounded( tester, const WDiv(className: 'h-full max-h-[120px]', child: _Probe()), ); - expect(tester.getSize(find.byType(_Probe)).height, 120); - // See the note above: pre-existing on master, and the fix is a - // wrapping-order change in `w_div.dart` rather than anything here. - }, skip: true); + expect(tester.getSize(find.byType(_Probe)).height, 400); + }); - testWidgets('with w-full and max-h-* clamps only the height', ( + testWidgets('a LOOSE parent honours max-h-*, which master did not', ( tester, ) async { + // The behaviour that actually changed. Master wrapped no `ConstrainedBox` + // on its bounded branch, so the cap was discarded outright: this rendered + // 400 there and renders 120 here. Verified as an A/B against master. + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: + const BoxConstraints(maxWidth: 300, maxHeight: 400), + child: const WDiv( + className: 'h-full max-h-[120px]', + child: _Probe(), + ), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(_Probe)).height, 120); + }); + + testWidgets('max-w-* applies after the width fraction', (tester) async { + // Also a fix. Master put its `ConstrainedBox` outside the + // `FractionallySizedBox`, and `BoxConstraints.enforce` clamps an + // additional constraint into the incoming range, so against the tight + // width the fraction had already produced the cap was discarded: + // `clamp(100, 150, 150)` is 150. Master renders 150, this renders 100. await pumpBounded( tester, - const WDiv(className: 'w-full h-full max-h-[120px]', child: _Probe()), + const WDiv(className: 'w-1/2 h-full max-w-[100px]', child: _Probe()), ); - expect(tester.getSize(find.byType(_Probe)), const Size(300, 120)); - // See the note above: pre-existing on master, and the fix is a - // wrapping-order change in `w_div.dart` rather than anything here. - }, skip: true); + expect(tester.getSize(find.byType(_Probe)).width, 100); + }); testWidgets('a child is laid out against the filled height', ( tester, @@ -278,10 +295,17 @@ void main() { // A childless `WDiv` is a real shape (a divider, a rule, a spacer), and the // box still has to report a size rather than reaching for a child that is // not there. - testWidgets('still fills a bounded height', (tester) async { - await pumpBounded(tester, const WDiv(className: 'h-full w-[40px]')); + testWidgets('still resolves a height with nothing to lay out', ( + tester, + ) async { + // No `w-[40px]`: any explicit width makes `WDiv` build a core structure, + // so the box gets a child and this stops testing the null branch. The + // first version carried one and covered nothing. + await pumpUnbounded(tester, const WDiv(className: 'h-full')); - expect(tester.getSize(find.byType(WDiv)).height, 400); + final double screen = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect(tester.getSize(find.byType(WDiv)).height, screen); }); }); @@ -309,7 +333,7 @@ void main() { }); testWidgets('with no child, and with an unbounded height', (tester) async { - await pumpUnbounded(tester, const WDiv(className: 'h-full w-[40px]')); + await pumpUnbounded(tester, const WDiv(className: 'h-full')); final RenderBox box = tester.renderObject( find.byType(WDiv), diff --git a/test/widgets/w_div/sizing_test.dart b/test/widgets/w_div/sizing_test.dart index f5da6704..19b53cd8 100644 --- a/test/widgets/w_div/sizing_test.dart +++ b/test/widgets/w_div/sizing_test.dart @@ -223,7 +223,7 @@ void main() { find.byType(WDiv), findsOneWidget, reason: - 'h-full in unbounded parent requires LayoutBuilder to check constraints', + 'h-full in an unbounded parent falls back to the screen height', ); }); }); From 7995028d2f356f7d5b7b6ea2e3a08bb33d0717ea Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 8 Sep 2026 02:37:48 +0300 Subject: [PATCH 4/5] docs(w-div): retire the last five comments describing the deleted LayoutBuilder Two were the residual the third review named: `:1723` and `:1756` still said "LayoutBuilder only for h-full in unbounded contexts" and "Use LayoutBuilder only when needed for unbounded axis". The sibling at `:1637` was fixed last commit and these were missed. Three more were adjacent and are fixed here rather than left, because a file that contradicts itself is worse than one that is merely out of date. Only one of the three is this PR's doing: `_buildStretchGrid`'s doc said `h-full` and `basis-*` "all carry a `LayoutBuilder`". The other two predate it and were already contradicted by the code beside them, `_applyMainAxisBasis` at `:874` stating in as many words that neither basis path uses one. Comments only. --- lib/src/widgets/w_div.dart | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index 0367231f..1f225f3f 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -625,7 +625,7 @@ class WDiv extends StatelessWidget { /// Builds the final `Row`/`Column` from `basis-*`-resolved children, applying /// the column smart cross-axis stretch or the row `Flexible` shrink wrap. /// Split out of [_buildFlexStructure] so it can run either directly or inside - /// the `basis-*` `LayoutBuilder`. + /// the `basis-*` extent provider. Widget _composeFlex({ required WindStyle styles, required bool isColumn, @@ -1281,9 +1281,11 @@ class WDiv extends StatelessWidget { /// cells so columns stay aligned. /// /// Because the row uses real layout rather than the intrinsic protocol, cells - /// whose content is a `flex flex-col`, or that use `h-full` / `basis-*` (all - /// of which carry a `LayoutBuilder`), stretch correctly instead of asserting - /// `LayoutBuilder does not support returning intrinsic dimensions` (#139). + /// whose content is a `flex flex-col`, or that use `h-full` / `basis-*`, + /// stretch correctly instead of asserting `LayoutBuilder does not support + /// returning intrinsic dimensions` (#139). None of those three carries a + /// `LayoutBuilder` any more, so they would survive the intrinsic protocol + /// too; real layout is still the cheaper path and is what this builds. Widget _buildStretchGrid( int cols, double gapX, @@ -1313,9 +1315,9 @@ class WDiv extends StatelessWidget { } // WindEqualHeightRow measures each cell with a real (loose-height) layout // and re-lays it to at least the row max via a MIN height, instead of the - // intrinsic query IntrinsicHeight would run. A `flex flex-col` cell (which - // carries a LayoutBuilder) can then be stretched without the "LayoutBuilder - // does not support returning intrinsic dimensions" assert (#139), and the + // intrinsic query IntrinsicHeight would run. That kept a `flex flex-col` + // cell clear of the "LayoutBuilder does not support returning intrinsic + // dimensions" assert (#139) back when it carried one, and the // min (never tight) height leaves no residual RenderFlex overflow (#141). rows.add(WindEqualHeightRow(spacing: gapX, children: rowChildren)); } @@ -1719,8 +1721,9 @@ class WDiv extends StatelessWidget { } } else if (styles.widthFactor == null) { // Height-only fractional sizing (h-full, h-1/2, etc.) - // Vertical axis is often unbounded (ScrollView/Column), so we need - // LayoutBuilder only for h-full in unbounded contexts. + // The vertical axis is often unbounded (ScrollView/Column), which only + // `h-full` has to resolve against; a fraction of an unbounded height is + // meaningless, so `h-1/2` and friends stay a plain FractionallySizedBox. if (isFullHeight) { // h-full resolves at the render layer, no LayoutBuilder. // @@ -1753,7 +1756,8 @@ class WDiv extends StatelessWidget { } } else { // Both width and height factors (e.g., w-full h-full, w-1/2 h-1/2) - // Use LayoutBuilder only when needed for unbounded axis + // `h-full` carries the width factor into the same render-layer box; + // everything else is a plain FractionallySizedBox on both axes. if (isFullHeight) { // Both axes, height full: the same render-layer box as the // height-only path, carrying the width factor too. From f23b18db5c3125eea397423668adb540da1acf96 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Tue, 8 Sep 2026 11:32:09 +0300 Subject: [PATCH 5/5] docs: fix the three surfaces still blaming a LayoutBuilder that is gone The last two open review findings, plus one adjacent to them. `doc/layout/sizing.md` states four paragraphs above that `grid` is the only remaining trigger, then gave an example blaming a card that "resolves h-full / basis-* internally". CodeRabbit caught it. The throwing example is a `grid` now, and the safe counterpart is the `items-stretch` grid the escape-hatch list already recommends first. Verified rather than reasoned: the `grid` case raises `LayoutBuilder does not support returning intrinsic dimensions` under an `IntrinsicHeight` and the `items-stretch` case renders clean. `wind_equal_height_row.dart` said Wind cell content "frequently contains a `LayoutBuilder` (flex cross-axis stretch, `h-full`, `basis-*`)". That is the last companion spot, the one Kodizm flagged as fine to leave for a follow-up. All three are render objects now. The widget's rationale is untouched: a nested `grid` still carries one, a cell subtree is arbitrary caller content either way, and real layout is what a `LayoutBuilder` supports where the intrinsic protocol is not. `w_div.dart:854` said the basis pre-check exists "so the common no-basis case skips the LayoutBuilder wrap". It skips the `WindMainExtentProvider` wrap, and the doc comment twenty lines below already says in as many words that neither basis path uses a `LayoutBuilder`. This one predates the PR and is fixed here for the reason the last commit gave: a file that contradicts itself is worse than one merely out of date. Comments and one doc example. No executable line changed. --- doc/layout/sizing.md | 13 +++++------ lib/src/widgets/w_div.dart | 2 +- lib/src/widgets/wind_equal_height_row.dart | 26 ++++++++++++---------- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/doc/layout/sizing.md b/doc/layout/sizing.md index d3ea0c88..41d39f3a 100644 --- a/doc/layout/sizing.md +++ b/doc/layout/sizing.md @@ -190,18 +190,15 @@ LayoutBuilder does not support returning intrinsic dimensions. - Wind's own `items-stretch` grid equalizes row heights with real layout rather than `IntrinsicHeight`, so reach for it INSTEAD of wrapping. ```dart -// Throws if a card resolves h-full / basis-* internally: +// Throws: the wrapped subtree contains a grid. IntrinsicHeight( - child: WDiv(className: 'flex flex-row', children: cards), + child: WDiv(className: 'grid grid-cols-2 gap-4', children: cards), ) -// Safe: explicit height on each cell, no IntrinsicHeight needed. +// Safe: items-stretch equalizes the row heights with real layout. WDiv( - className: 'flex flex-row gap-4', - children: [ - WDiv(className: 'h-40 ...', child: card1), - WDiv(className: 'h-40 ...', child: card2), - ], + className: 'grid grid-cols-2 gap-4 items-stretch', + children: cards, ) ``` diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index 1f225f3f..fa6e9a77 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -851,7 +851,7 @@ class WDiv extends StatelessWidget { } /// Whether any direct flex child carries a `basis-*` token. Cheap pre-check - /// (substring) so the common no-basis case skips the LayoutBuilder wrap. + /// (substring) so the common no-basis case skips the extent-provider wrap. static bool _anyChildHasBasis(List children) { for (final child in children) { final className = _extractChildClassName(child); diff --git a/lib/src/widgets/wind_equal_height_row.dart b/lib/src/widgets/wind_equal_height_row.dart index cddd3077..aa77e85e 100644 --- a/lib/src/widgets/wind_equal_height_row.dart +++ b/lib/src/widgets/wind_equal_height_row.dart @@ -5,18 +5,20 @@ import 'package:flutter/widgets.dart'; /// REAL two-pass layout rather than the intrinsic-sizing protocol. /// /// This is the intrinsic-free replacement for `IntrinsicHeight` + a -/// `Row(crossAxisAlignment: stretch)` in the grid `items-stretch` path. Wind -/// cell content frequently contains a `LayoutBuilder` (flex cross-axis stretch, -/// `h-full`, `basis-*`), and `LayoutBuilder` cannot answer intrinsic or -/// dry-layout queries, so `IntrinsicHeight` asserts `LayoutBuilder does not -/// support returning intrinsic dimensions` the moment it has to stretch an -/// unequal cell (issue #139). This widget instead lays each child out for real -/// with a loose height to measure it, then lays it out again to the row's max -/// height via a MIN constraint (never a tight one). Real layout is exactly what -/// `LayoutBuilder` supports, so a `flex flex-col` cell stretches without -/// asserting; and because a cell is never forced BELOW its own content height, a -/// stretched cell leaves no residual `RenderFlex overflowed` warning the way a -/// tight re-lay could on fractional (sub-pixel) content (issue #141). +/// `Row(crossAxisAlignment: stretch)` in the grid `items-stretch` path. +/// `IntrinsicHeight` measures through the intrinsic protocol, which a +/// `LayoutBuilder` cannot answer, so it asserts `LayoutBuilder does not support +/// returning intrinsic dimensions` the moment it has to stretch a cell carrying +/// one (issue #139). Flex cross-axis stretch, `h-full` and `basis-*` each did +/// when that was filed and are render objects now, but a nested `grid` still +/// does, and a cell subtree is arbitrary caller content either way. This widget +/// instead lays each child out for real with a loose height to measure it, then +/// lays it out again to the row's max height via a MIN constraint (never a tight +/// one). Real layout is exactly what `LayoutBuilder` supports, so a `flex +/// flex-col` cell stretches without asserting; and because a cell is never +/// forced BELOW its own content height, a stretched cell leaves no residual +/// `RenderFlex overflowed` warning the way a tight re-lay could on fractional +/// (sub-pixel) content (issue #141). /// /// Every child is given an equal share of the incoming width (`(maxWidth - /// spacing * (n - 1)) / n`), matching the grid's fixed column count, so callers