Skip to content

feat(ios): honor textDecorationStyle on Text decorations#56769

Open
quantizor wants to merge 2 commits into
facebook:mainfrom
quantizor:fix-text-decoration-ios
Open

feat(ios): honor textDecorationStyle on Text decorations#56769
quantizor wants to merge 2 commits into
facebook:mainfrom
quantizor:fix-text-decoration-ios

Conversation

@quantizor
Copy link
Copy Markdown

@quantizor quantizor commented May 11, 2026

Summary:

textDecorationStyle is declared on TextStyleIOS in the public types but wavy is silently dropped: Fabric's C++ enum doesn't include Wavy, and UIKit's NSUnderlineStyle has no native wavy pattern bit. Separately, dotted and dashed map to NSUnderlineStylePatternDot / NSUnderlineStylePatternDash which don't match browser geometry on iOS.

This PR adds TextDecorationStyle::Wavy to the shared Fabric primitives / conversions (also unblocks the same value on Android, see companion PR #56768) and renders wavy / dotted / dashed decorations with custom Core Graphics paths.

Implementation:

  • Wavy ranges are tagged with a custom RCTCustomDecorationAttributeName (storing the line kinds, stroke color, and style key) in RCTAttributedTextUtils.mm and painted by RCTTextLayoutManager.mm after drawGlyphsForGlyphRange:. Wavy uses an adaptation of WebKit's formula from Source/WebCore/style/InlineTextBoxStyle.cpp (controlPointDistance = thickness * 1.5 + 0.5, one cubic Bezier per wavelength, control points at the midpoint above and below the y-axis). At iOS point sizes the literal Blink amplitude renders as a very pronounced wave because Core Graphics paints in points (not device pixels), so the constants are dialed back to read as a clear-but-subtle browser-style wave at typical text sizes.
  • Dotted uses a custom CG path with a zero-length dash + round line caps, producing actual circular dots at 2 * thickness spacing.
  • Dashed uses a custom CG path with [2 * thickness, thickness] intervals — short rectangular dashes with a tight gap, closer to Safari's geometry than UIKit's default.
  • Solid and double continue to use UIKit's native NSUnderlineStyle pattern bits, so this PR does not touch the long-standing iOS Arial+bold solid-underline rendering bug tracked in [iOS] rendering Arial, underline and bold text produce bad underlining in specific case fontSize configuration #53935.
  • The wavy drawing loop iterates while x < x2 so the final cycle continues through the last character (including trailing punctuation that would otherwise be visually uncovered when the run width is not an integer multiple of the wavelength).

Companion PRs (independent, also targeting main):

Changelog:

[IOS] [ADDED] - textDecorationStyle: 'wavy' for <Text> (custom CoreGraphics path)
[IOS] [CHANGED] - textDecorationStyle: 'dotted' and 'dashed' for <Text> render with custom CoreGraphics paths instead of UIKit pattern bits, matching browser geometry more closely

Test Plan:

Side-by-side comparison on iPhone 17 sim (iOS 26.4) of a <Text> with textDecorationLine="underline" and textDecorationStyle cycling through solid / double / dotted / dashed / wavy, verified against Safari rendering of the same CSS. Trailing periods now fall under the wavy stroke. Verified with textDecorationColor set distinct from the foreground color.

<Text style={{
  color: 'black',
  textDecorationLine: 'underline',
  textDecorationStyle: 'wavy',
  textDecorationColor: '#ff00aa',
}}>
  Hello
</Text>

`textDecorationStyle` is declared on `TextStyleIOS` in the public types
but `wavy` is silently dropped (Fabric's C++ enum doesn't include
`Wavy`, and UIKit's `NSUnderlineStyle` has no native wavy pattern bit).
This PR closes the gap by adding `TextDecorationStyle::Wavy` to the
shared Fabric primitives / conversions and rendering wavy / dotted /
dashed decorations with custom Core Graphics paths instead of UIKit
pattern bits.

Implementation:
- Wavy ranges are tagged with a custom
  `RCTCustomDecorationAttributeName` (storing the line kinds, stroke
  color, and style key) in `RCTAttributedTextUtils.mm` and painted by
  `RCTTextLayoutManager.mm` after `drawGlyphsForGlyphRange:`.
- Wavy uses an adaptation of WebKit's formula from
  `Source/WebCore/style/InlineTextBoxStyle.cpp`
  (`controlPointDistance = thickness * 1.5 + 0.5`, one cubic Bezier
  per wavelength, control points at the midpoint above and below the
  y-axis). At iOS point sizes the literal Blink amplitude renders as
  a very pronounced wave because Core Graphics paints in points (not
  device pixels), so the constants are dialed back to read as a
  clear-but-subtle browser-style wave at typical text sizes.
- Dotted uses a custom CG path with a zero-length dash + round line
  caps, producing actual circular dots at `2 * thickness` spacing
  (UIKit's `NSUnderlineStylePatternDot` does not match browser
  geometry on iOS).
- Dashed uses a custom CG path with `[2 * thickness, thickness]`
  intervals — short rectangular dashes with a tight gap, closer to
  Safari's geometry than UIKit's default `NSUnderlineStylePatternDash`.
- Solid and double continue to use UIKit's native `NSUnderlineStyle`
  pattern bits.
- The wavy drawing loop iterates `while x < x2` so the final cycle
  continues through the last character (including trailing punctuation
  that would otherwise be visually uncovered when the run width is not
  an integer multiple of the wavelength).

The shared C++ enum addition unblocks the same value on Android (see
companion PR).

## Changelog:

[IOS] [ADDED] - `textDecorationStyle: 'wavy'` for `<Text>` (custom CG path)
[IOS] [CHANGED] - `textDecorationStyle: 'dotted'` and `'dashed'` for `<Text>` render with custom CoreGraphics paths instead of UIKit pattern bits, matching browser geometry more closely

## Test Plan:

Side-by-side comparison on iPhone 17 sim (iOS 26.4) of a `<Text>` with
`textDecorationLine="underline"` and `textDecorationStyle` cycling
through `solid` / `double` / `dotted` / `dashed` / `wavy`, verified
against Safari rendering of the same CSS. Trailing periods now fall
under the wavy stroke. Verified with `textDecorationColor` set
distinct from the foreground color.

```tsx
<Text style={{
  color: 'black',
  textDecorationLine: 'underline',
  textDecorationStyle: 'wavy',
  textDecorationColor: '#ff00aa',
}}>
  Hello
</Text>
```
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label May 11, 2026
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label May 11, 2026
@meta-codesync
Copy link
Copy Markdown

meta-codesync Bot commented May 11, 2026

@CalixTang has imported this pull request. If you are a Meta employee, you can view this in D104680636.

The `feat(ios): honor textDecorationStyle` commit added `Wavy` to the
shared `facebook::react::TextDecorationStyle` enum but the C++ API
snapshots under `scripts/cxx-api/api-snapshots/` weren't regenerated.
CI's `validate_cxx_api_snapshots` job flagged the divergence across all
six snapshot files (Android Debug/Release, Apple Debug/Release, Common
Debug/Release).

No code or behavior change.
@github-actions
Copy link
Copy Markdown

Warning

JavaScript API change detected

This PR commits an update to ReactNativeApi.d.ts, indicating a change to React Native's public JavaScript API.

  • Please include a clear changelog message.
  • This change will be subject to additional review.

This change was flagged as: POTENTIALLY_BREAKING

quantizor added a commit to quantizor/react-native that referenced this pull request May 11, 2026
Three CI breakages were introduced by changes that landed on `main`
between the original PR push and now:

1. PR facebook#56705 renamed `DrawCommandSpan` to `CanvasEffectSpan` and dropped
   the `ReactSpan` / `UpdateAppearance` interfaces from the base class.
   `ReactUnderlineSpan` and `ReactStrikethroughSpan` were extending the
   old name; rename them and re-declare `ReactSpan` so they remain
   valid `SetSpanOperation` arguments. `ReactTextView.onDraw` updated
   to import the new name.

2. Adding `Wavy` to `facebook::react::TextDecorationStyle` (this PR)
   left `RCTNSUnderlineStyleFromTextDecorationStyle` non-exhaustive,
   tripping `-Werror,-Wreturn-type` on iOS builds. Add a `Wavy` case
   that falls back to a solid underline (the actual wavy rendering
   ships in companion PR facebook#56769).

3. `validate_cxx_api_snapshots` flagged the missing `Wavy` entry in
   all six snapshots under `scripts/cxx-api/api-snapshots/`.
   Regenerate.

No behavior change beyond what was already in the feature commit; this
is purely "rebase the implementation onto current `main`."
quantizor added a commit to quantizor/react-native that referenced this pull request May 11, 2026
Three CI breakages were introduced by changes that landed on `main`
between the original PR push and now:

1. PR facebook#56705 renamed `DrawCommandSpan` to `CanvasEffectSpan` and dropped
   the `ReactSpan` / `UpdateAppearance` interfaces from the base class.
   `ReactUnderlineSpan` and `ReactStrikethroughSpan` were extending the
   old name; rename them and re-declare `ReactSpan` so they remain
   valid `SetSpanOperation` arguments. `ReactTextView.onDraw` updated
   to import the new name.

2. Adding `Wavy` to `facebook::react::TextDecorationStyle` (this PR)
   left `RCTNSUnderlineStyleFromTextDecorationStyle` non-exhaustive,
   tripping `-Werror,-Wreturn-type` on iOS builds. Add a `Wavy` case
   that falls back to a solid underline (the actual wavy rendering
   ships in companion PR facebook#56769).

3. `validate_cxx_api_snapshots` flagged the missing `Wavy` entry in
   all six snapshots under `scripts/cxx-api/api-snapshots/`.
   Regenerate.

No behavior change beyond what was already in the feature commit; this
is purely "rebase the implementation onto current `main`."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Android] Issue with textDecoration

1 participant