Yogesh Parwani

Building awesome mobile experiences, one pixel at a time.

Yogesh Parwani

Building awesome mobile experiences, one pixel at a time.

Yogesh Parwani

Building awesome mobile experiences, one pixel at a time.

Jan 6, 2025

3 min read

A debugging story about 2 identical (looking) strings that weren’t so identical after all.

Mystery

So I stumbled upon this interesting case where 2 identical-looking strings were flagged as different by my test case. Here’s what I was looking at:


The test was for a property to return relativeDate. It was a simple test that checked if a formatted date string matched the expected output.

  test('returns "Tomorrow, 9:00 AM" for next day', () {
    final target = _baseDateTime //
        .add(const Duration(days: 1))
        .copyWith(hour: 9, minute: 0);

    expect(target.relativeDate, 'Tomorrow, 9:00 AM');
  });

The Investigation

After starting at these similar strings and hours of re-running the test, hoping it to work (because by the looks of it, it felt right to the naked eye), I tried to dig deeper into what was actually going on.

To understand what was different between these strings, I printed out each character’s Unicode value using the codeUnits property:

'Tomorrow, 9:00 AM' -> [... 44, 32, 57, 58, 48, 48, 32, 65, 77]
'Tomorrow, 9:00 AM' -> [... 44, 32, 57, 58, 48, 48, 8239, 65, 77]

And there it was! 32 vs 8239. Two different values representing what appeared to be the same thing — a space.

The Revelation

That 8239 is actually a “Narrow No-Break Space” character (32 is the regular space) which was being used internally by the intl package’s DateFormat.

But why would anyone use a special space character?
Consider these two scenarios of how a text might wrap:

With a regular space:

With a no-break space:

The no-break space keeps certain text elements together, preventing awkward line breaks that could harm readability. This is particularly important in use cases like:
- Time formats (like our case)
- Units of measurement (e.g., “100 km”)
- Currency amounts (e.g., “$ 100”)
- Other cases where breaking text could lead to confusion

The Solution

Having understood the issue, I had two potential solutions:

1. Replace the no-break space with a regular space in our result before comparison
2. Include the no-break space in the expected string

I opted for the second approach as it felt more robust and true to the actual formatting requirements. I created the following extension:

extension NarrowNoBreakSpaceExtension on String {
  /// Adds a narrow no-break space (\u202F) before AM/PM in time strings
  /// For example:
  /// - "9:00 AM" becomes "9:00\u202FAM"
  /// - "Today, 9:00 PM" becomes "Today, 9:00\u202FPM"
  /// - "The PM spoke today" remains unchanged
  String get withNarrowNoBreakSpace {
    const narrowNoBreakSpace = '\u202F';

    // Match time patterns with AM/PM
    // Looks for:
    // - Numbers (with optional leading zero)
    // - Followed by colon and numbers
    // - Followed by space and AM/PM
    final timeRegex = RegExp(r'(\d{1,2}:\d{2})\s(AM|PM|am|pm)');

    return replaceAllMapped(
      timeRegex,
      (match) => '${match.group(1)}$narrowNoBreakSpace${match.group(2)}',
    );
  }
}

And the updated test case:

  test('returns "Tomorrow, 9:00 AM" for next day', () {
    final target = _baseDateTime //
        .add(const Duration(days: 1))
        .copyWith(hour: 9, minute: 0);

    expect(target.relativeDate, 'Tomorrow, 9:00 AM'.withNarrowNoBreakSpace);
  });

Looking Back

And yes, that was it. What started as a puzzling string comparison led me to discover a tiny but powerful character that I’ll definitely keep in my toolkit.

Thanks for reading! 👋

LET'S
CONNECT

LET'S
CONNECT

LET'S
CONNECT