Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.

---

## [Unreleased]

### Fixed

- **A multi-line `className` hid its flex tokens from the row and column composers, and this project's own style guide is what put them there.** `.claude/rules/widgets.md` and `SKILL.md` both instruct a className covering 3+ concerns to be a triple-quoted string with one concern per line, so `flex-1` at the end of a line arrives at the composition helpers as `flex-1\n`. Three of them split on a single space and therefore never matched it. Two consequences, both reproduced: a `flex-1` child of an `overflow-hidden` row got wrapped a second time and threw `Incorrect use of ParentDataWidget` (`_selfWrapsInFlex`), and a `w-24 shrink-0` child in a crowded row shrank to its 50pt flex share instead of holding 96pt (`_hasShrinkZero`). The third site, `_hasExplicitCrossWidth`, is corrected for consistency with its own documented "in ANY state or breakpoint variant" intent, but no observable failure could be produced for it: a stretched column child still renders at the width it asked for, so the miss costs a redundant wrapper rather than a wrong layout. All five token scans in `WDiv` now share one hoisted `_whitespaceRegex`, which also stops the two that already split on whitespace from allocating a fresh `RegExp` on every pass through the composition path. (`lib/src/widgets/w_div.dart`, `test/widgets/w_div/multiline_classname_test.dart`)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## [1.4.0] - 2026-08-21

### Added
Expand Down
19 changes: 14 additions & 5 deletions lib/src/widgets/w_div.dart
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,7 @@ class WDiv extends StatelessWidget {
/// tokens and prefixed variants like `md:shrink-0` / `md:flex-none`.
static bool _hasShrinkZero(String? className) {
if (className == null || className.isEmpty) return false;
for (final token in className.split(' ')) {
for (final token in className.split(_whitespaceRegex)) {
if (token == 'shrink-0' ||
token.endsWith(':shrink-0') ||
token == 'flex-none' ||
Expand Down Expand Up @@ -879,7 +879,7 @@ class WDiv extends StatelessWidget {
/// stretch wrap (a parse without those states active would miss them).
static bool _hasExplicitCrossWidth(String? className) {
if (className == null || className.isEmpty) return false;
for (final raw in className.split(' ')) {
for (final raw in className.split(_whitespaceRegex)) {
if (raw.isEmpty) continue;
final token = raw.contains(':') ? raw.split(':').last : raw;
if (token.startsWith('w-') ||
Expand Down Expand Up @@ -966,6 +966,15 @@ class WDiv extends StatelessWidget {
/// has been stripped.
static final RegExp _numericFlexRegex = RegExp(r'^flex-[0-9]+$');

/// Splits a raw `className` into tokens.
///
/// Any whitespace, not a single space: this project writes a className with
/// 3+ concerns as a triple-quoted string with one concern per line (see
/// `.claude/rules/widgets.md`), so a token that ends a line arrives as
/// `flex-1\n` and a single-space split never matches it. Hoisted to a field
/// because every caller runs inside the row/column composition path.
static final RegExp _whitespaceRegex = RegExp(r'\s+');

/// Whether a child's className makes it self-wrap in `Expanded`/`Flexible`
/// (i.e. sets `styles.flex` or `styles.flexFit`, see the composition pipeline
/// at the bottom of `_buildCompositionPipeline`). Such a child must never be
Expand All @@ -978,7 +987,7 @@ class WDiv extends StatelessWidget {
/// `Flexible`), so they are absent here.
static bool _selfWrapsInFlex(String? className) {
if (className == null || className.isEmpty) return false;
for (final raw in className.split(' ')) {
for (final raw in className.split(_whitespaceRegex)) {
if (raw.isEmpty) continue;
final token = raw.contains(':') ? raw.split(':').last : raw;
if (token == 'grow' ||
Expand All @@ -1003,7 +1012,7 @@ class WDiv extends StatelessWidget {
/// responsive intent at breakpoints where it does not apply.
static bool _hasBareFullWidth(String? className) {
if (className == null || className.isEmpty) return false;
for (final raw in className.split(RegExp(r'\s+'))) {
for (final raw in className.split(_whitespaceRegex)) {
if (raw == 'w-full') return true;
}
return false;
Expand Down Expand Up @@ -1046,7 +1055,7 @@ class WDiv extends StatelessWidget {
return true;
}

for (final token in className.split(RegExp(r'\s+'))) {
for (final token in className.split(_whitespaceRegex)) {
if (token.isEmpty || token.contains(':')) continue;
if (token == 'grow' ||
token == 'flex-grow' ||
Expand Down
91 changes: 91 additions & 0 deletions test/widgets/w_div/multiline_classname_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fluttersdk_wind/fluttersdk_wind.dart';

/// The composition helpers scan a child's raw `className` for tokens, and this
/// project's own convention writes any className with 3+ concerns as a
/// triple-quoted string with one concern per line (`.claude/rules/widgets.md`).
///
/// A scan that splits on a single space therefore sees `flex-1\n` rather than
/// `flex-1` for every token that ends a line, and misses it. Each helper fails
/// differently when that happens, so each gets its own case here: the wrap is
/// applied twice, or applied where it should not be, or skipped where it should
/// not be.
void main() {
setUp(() {
WindParser.clearCache();
});

/// Pumps [child] inside a fixed-width Wind surface.
Future<void> pumpAt(WidgetTester tester, double width, Widget child) {
return tester.pumpWidget(
MaterialApp(
home: WindTheme(
data: WindThemeData(),
child: Align(
alignment: Alignment.topLeft,
child: SizedBox(width: width, child: child),
),
),
),
);
}

testWidgets(
'a multi-line flex-1 child is not wrapped a second time',
(tester) async {
// `_selfWrapsInFlex` guards against exactly this: the child already
// carries its own Expanded, so a parent Flexible on top of it throws
// "Incorrect use of ParentDataWidget". `overflow-hidden` wraps
// unconditionally, which is the shortest route to the double wrap.
await pumpAt(
tester,
400,
const WDiv(
className: 'flex flex-row items-center overflow-hidden',
children: [
WDiv(
className: '''
flex-1
bg-white dark:bg-gray-800
''',
child: SizedBox(height: 20),
),
WDiv(child: SizedBox(width: 24, height: 24)),
],
),
);

expect(tester.takeException(), isNull);
},
);

testWidgets(
'a multi-line shrink-0 child keeps its intrinsic width',
(tester) async {
// `shrink-0` is the caller saying "never shrink me". Missing the token
// wraps the child in a Flexible, whose share in a crowded row is smaller
// than the width it asked for.
await pumpAt(
tester,
100,
const WDiv(
className: 'flex flex-row items-center overflow-hidden',
children: [
WDiv(
key: Key('fixed'),
className: '''
w-24 shrink-0
bg-white dark:bg-gray-800
''',
child: SizedBox(height: 20),
),
WText('Very Long Value Text That Cannot Fit', className: 'text-sm'),
],
),
);

expect(tester.getSize(find.byKey(const Key('fixed'))).width, 96);
},
);
}
Loading