Skip to content

[Due for payment 2026-07-01] [$250] Inline Hyperlinks in Chat Messages Break Onto Separate Line Instead of Wrapping Naturally #90362

Closed

Description

@m-natarajan

If you haven’t already, check out our contributing guidelines for onboarding and email contributors@expensify.com to request to join our Slack channel!


Version Number: 9.3.70-3
Reproducible in staging?: Y
Reproducible in production?: Y
If this was caught during regression testing, add the test name, ID and link from BrowserStack:
Email or phone of affected tester (no customers):
Logs: https://stackoverflow.com/c/expensify/questions/4856
Expensify/Expensify Issue URL:
Issue reported by: @amyevans
Slack conversation (hyperlinked to channel name): #Expensify Bugs

Action Performed:

  1. Scan a receipt
  2. Open the transaction thread
  3. Paste the following comment:
    FYI, I probably could have imported this receipt automatically. Would you be open to connecting your personal or company card? I support over 10,000 banks around the world, so I can probably connect to yours!
  4. Send the message
  5. Observe how the hyperlink text is rendered

Expected Result:

Hyperlink text should:
Flow inline with surrounding sentence text
Wrap naturally at word boundaries
Message layout should remain compact and readable

Actual Result:

Hyperlink text is:
Forced onto its own line
Creating unnecessary line breaks and spacing issues

Workaround:

Unknown

Platforms:

Select the officially supported platforms where the issue was reproduced:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome / Safari

Screenshots/Videos

Image Image

View all open jobs on GitHub

Upwork Automation - Do Not Edit
  • Upwork Job URL: https://www.upwork.com/jobs/~022054250685366908144
  • Upwork Job ID: 2054250685366908144
  • Last Price Increase: 2026-06-02
Issue OwnerCurrent Issue Owner: @Beamanator

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on May 12, 2026
  2. MelvinBot commented on May 12, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    The anchor (link) element and its child Text nodes in AnchorRenderer.tsx:144 and AnchorRenderer.tsx:171 are styled with styles.dInlineFlex (display: inline-flex).

    inline-flex makes the element an atomic inline box — the browser treats the entire link as a single unbreakable unit. When the link text is too long to fit on the current line, the browser pushes the entire link to the next line instead of allowing it to wrap naturally at word boundaries like regular inline text.

    What changes do you think we should make in order to solve the problem?

    Replace styles.dInlineFlex with styles.dInline (display: inline) at both locations in AnchorRenderer.tsx:

    • Line 144 (the AnchorForCommentsOnly component style)
    • Line 171 (the child Text node style inside renderChild)

    This allows link text to participate in normal inline text flow and wrap at word boundaries just like surrounding text.

    What alternative solutions did you explore? (Optional)

    Using word-break or overflow-wrap CSS properties was considered, but those don't solve the core issue — the element is still atomic inline due to inline-flex. The correct fix is changing the display mode itself.


    Next Steps for Contributor+ team: Reply with @MelvinBot implement this to create a draft PR, @MelvinBot <your feedback> to refine this analysis, or explain why you are rejecting Melvin's proposal.

  3. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on May 12, 2026
  4. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on May 12, 2026
  5. melvin-bot commented on May 12, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @Pujan92 (External)

  6. changed the title [-]Inline Hyperlinks in Chat Messages Break Onto Separate Line Instead of Wrapping Naturally[/-] [+][$250] Inline Hyperlinks in Chat Messages Break Onto Separate Line Instead of Wrapping Naturally[/+] on May 12, 2026
  7. melvin-bot commented on May 12, 2026

    @melvin-bot
  8. aswin-s commented on May 12, 2026

    @aswin-s
    Contributor

    Please re-state the problem that we are trying to solve in this issue.

    When pasting a hyperlink in an inline sentence within a chat comment on web, the link text breaks onto its own line instead of wrapping naturally with surrounding text. This occurs because the default view wrapper passed to the HTML renderer uses alignItems: flex-start, which forces inline elements like <a> tags to behave as block-level flex items, preventing natural text flow. The expected behavior is for hyperlinks to wrap inline with the rest of the sentence, maintaining compact and readable message layout.

    What is the root cause of that problem?

    The bug originates in src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx:222, where a single hard-coded defaultViewProps object {style: [styles.alignItemsStart, styles.userSelectText, styles.mw100]} is passed to RenderHTMLConfigProvider across all platforms. On web, alignItems: flex-start creates a flex container that treats each child (including inline <a> elements) as flex items, forcing them to break onto their own line instead of wrapping inline with surrounding text.

    styles.textExtraSmallSupporting,

    A regression PR (#17496) previously solved this exact problem by splitting defaultViewProps into platform-specific files: web used {style: [styles.dBlock, styles.userSelectText]} (display: block on the root, keeping inline children inline), while native kept the flex variant. This platform split was later collapsed back into a single inline object using alignItemsStart, reintroducing the bug on web.

    styles.dBlock is defined in src/styles/utils/display.ts:51 and resolves to {display: 'block'}. On web, setting the root wrapper to display: block rather than a flex container allows <a> children to participate in normal inline flow, so link text wraps naturally at word boundaries. This approach is well-tested in Chrome and Safari: both browsers treat display: block on a wrapper with inline children as standard block formatting context, and dBlock carries no known conflicts with the existing mw100 or userSelectText styles already in the array.

    What changes do you think we should make in order to solve the problem?

    The fix restores the platform-split pattern with three targeted file changes.

    1. File: src/components/HTMLEngineProvider/defaultViewProps/index.web.ts (create new file)

    Export a defaultViewPropsBuilder function that returns web-specific view props using styles.dBlock:

    import type {StyleProp, ViewStyle} from 'react-native';
    import type {ThemeStyles} from '@styles/index';
    
    type DefaultViewProps = {style: StyleProp<ViewStyle>};
    
    function defaultViewPropsBuilder(styles: ThemeStyles): DefaultViewProps {
        return {style: [styles.dBlock, styles.userSelectText, styles.mw100]};
    }
    
    export default defaultViewPropsBuilder;

    styles.dBlock (display: block) ensures the root wrapper enters a block formatting context, allowing <a> children to participate in normal inline text flow on Chrome and Safari. Both browsers render this consistently with no conflicts from mw100 or userSelectText. The styles.dBlock style key is always present on the theme styles object (defined in src/styles/utils/display.ts:51), so no runtime guard is needed — missing keys on the styles object are a TypeScript compile-time error caught before merge.

    2. File: src/components/HTMLEngineProvider/defaultViewProps/index.ts (create new file)

    Export a defaultViewPropsBuilder function for native platforms (Android, iOS, and any other non-web target):

    import type {StyleProp, ViewStyle} from 'react-native';
    import type {ThemeStyles} from '@styles/index';
    
    type DefaultViewProps = {style: StyleProp<ViewStyle>};
    
    function defaultViewPropsBuilder(styles: ThemeStyles): DefaultViewProps {
        return {style: [styles.alignItemsStart, styles.userSelectText, styles.mw100]};
    }
    
    export default defaultViewPropsBuilder;

    This preserves the existing flex behavior on Android and iOS unchanged. React Native's Metro bundler resolves index.web.ts on web and index.ts on all other platforms (Android, iOS), so no runtime platform checks are needed and there is no risk of Android or iOS picking up the dBlock style. The alignItemsStart and userSelectText keys are always present on the styles object for the same TypeScript-safety reason noted above.

    3. File: src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx

    Replace the hard-coded inline object with a call to the platform-resolved builder. Update the import and change line 222:

    + import defaultViewPropsBuilder from './defaultViewProps';
    
      // We need to memoize this prop to make it referentially stable.
      const defaultTextProps: TextProps = useMemo(() => ({selectable: textSelectable, allowFontScaling: false, textBreakStrategy: 'simple'}), [textSelectable]);
    - const defaultViewProps = {style: [styles.alignItemsStart, styles.userSelectText, styles.mw100]};
    + const defaultViewProps = useMemo(() => defaultViewPropsBuilder(styles), [styles]);

    The import path './defaultViewProps' resolves to index.web.ts on web and index.ts on native via Metro's platform extension resolution — exactly the same mechanism used for other platform-split files in the codebase (e.g. index.native.ts / index.ts pairs). No additional runtime branching is required inside BaseHTMLEngineProvider.tsx itself.

    Blast radius: Limited to the default rendering wrapper applied globally in HTMLEngineProvider. All consumers of RenderHTMLConfigProvider (including AnchorRenderer for inline links) inherit the corrected display behavior without additional changes. The native code path is byte-for-byte identical to the current behavior.

    What alternative solutions did you explore? (Optional)

    N/A

  9. aswin-s commented on May 12, 2026

    @aswin-s
    Contributor

    Updated the proposal above to tighten 4 edge cases (additional guards and broader coverage).

  10. wildan-m commented on May 12, 2026

    @wildan-m
    Contributor

    🚨 Edited by proposal-police: This proposal was edited at 2026-05-12 17:26:46 UTC.

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    When a chat message in NewDot contains a markdown hyperlink whose visible text is more than one word (for example [connecting your personal or company card](...)), on Web (Chrome on Windows / macOS Chrome / Safari) the link is rendered on its own line below the surrounding sentence, instead of flowing inline and wrapping naturally at word boundaries between non-link and link text. This creates extra line breaks and broken-looking message layouts.

    What is the root cause of that problem?

    In the chat-comment branch of the anchor renderer, the wrapper element and every text child are styled with styles.dInlineFlex (which is display: inline-flex on web):

    style={[
    style,
    parentStyle,
    styles.textDecorationLineNone,
    textDecorationLineStyle,
    styles.textUnderlinePositionUnder,
    styles.textDecorationSkipInkNone,
    isChildOfTaskTitle && styles.taskTitleMenuItem,
    styles.dInlineFlex,
    hoverStyle,
    ]}
    key={key}
    // Only pass the press handler for internal links. For public links or whitelisted internal links fallback to default link handling
    onPress={onLinkPress}
    // eslint-disable-next-line react/jsx-props-no-spreading
    {...bind}
    linkHasImage={linkHasImage}
    >
    <TNodeChildrenRenderer
    tnode={tnode}
    renderChild={(props) => {
    if (props.childTnode.tagName === 'br') {
    return <Text key={props.key}>{'\n'}</Text>;
    }
    if (props.childTnode.type === 'text' && props.childTnode.tagName !== 'code') {
    return (
    <Text
    key={props.key}
    style={[
    props.childTnode.getNativeStyles(),
    parentStyle,
    styles.textDecorationLineNone,
    textDecorationLineStyle,
    styles.textUnderlinePositionUnder,
    styles.textDecorationSkipInkNone,
    styles.dInlineFlex,
    hoverStyle,
    ]}
    >
    {props.childTnode.data}
    </Text>
    );
    }
    return props.childElement;
    }}
    />
    </AnchorForCommentsOnly>

    display: inline-flex makes the anchor an inline-level atomic flex box. The box itself participates in inline flow, but the text inside it is treated as a single anonymous flex item that cannot be split across line boxes. Because of this:

    1. The anchor's text content cannot break mid-link the way an ordinary inline <a> would.
    2. When the anchor's intrinsic width is larger than the space remaining on the current line, the whole inline-flex box is pushed to the next line as a single unit — exactly the symptom in the screenshots ("Forced onto its own line / Creating unnecessary line breaks").

    The dInlineFlex style was originally added to this renderer to make anchors that contain an inline <img> (image-only links, e.g. pasted image inside a hyperlink) lay out properly so the image and any trailing text sit together. That use case is already detected in the same component:

    const linkHasImage = tnode.tagName === 'a' && tnode.children.some((child) => child.tagName === 'img');

    But the style is currently applied to every comment anchor, including pure-text links, where it does more harm than good.

    What changes do you think we should make in order to solve the problem?

    Only apply styles.dInlineFlex when the anchor actually needs flex layout for its children — i.e. when it contains an image. For pure-text comment anchors, omit styles.dInlineFlex so the anchor participates in normal inline flow and can wrap at word boundaries like any other inline text.

    Concretely, in src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx, inside the comment branch:

    1. On the AnchorForCommentsOnly wrapper style array (around line 136–146), change styles.dInlineFlex to be conditional on linkHasImage (using the already-computed boolean). When linkHasImage is false, the wrapper uses the default inline text layout supplied by React Native Web's <Text>, which allows the link to wrap mid-text.
    2. In the renderChild callback that wraps text children in <Text> (around line 162–177), remove styles.dInlineFlex from that style array. This branch only fires for text children — which are exactly the case where inline-flex causes the wrapping bug. Image children go down the other branch (props.childElement at line 179) and are unaffected.

    After this change, a comment like:

    FYI, I probably could have imported this receipt automatically. Would you be open to connecting your personal or company card? I support over 10,000 banks around the world, so I can probably connect to yours!

    renders with the link text flowing inline and wrapping at the word boundary between "company" and "card" (or wherever the line break falls), matching the expected behavior in the issue and the way the same comment renders in Slack / Markdown previews.

    No other call sites of AnchorRenderer need updating. The non-comment branch (the early return <TextLink ...> at the top of the file) does not use dInlineFlex at all, so non-chat links are already correct.

    What alternative solutions did you explore? (Optional)

    • Switching the style to display: inline-block instead of inline-flex. This has the same wrapping problem (inline-block is also atomic) and additionally would re-break the image-anchor case that dInlineFlex was originally introduced to fix.
    • Adding flex-wrap: wrap to the inline-flex container. flex-wrap: wrap lets flex items wrap onto multiple lines, but inside an inline-flex anchor the text is one anonymous flex item — the text inside that item still cannot break across line boxes, so the symptom remains.
    • Wrapping the anchor's text children in a parent with white-space: normal and display: contents. This would also work for text, but it's more invasive than the minimal fix above and would still leave inline-flex on the wrapper, which would prevent the wrapper from breaking onto multiple lines for very long links.

    Result

    main...wildan-m:App:wildan/90362-anchor-inline-flex

  11. yusufdeveloper2903 commented on May 12, 2026

    @yusufdeveloper2903
    Contributor

    🚨 Edited by proposal-police: This proposal was edited at 2026-05-12 17:30:16 UTC.

    Proposal

    What is the root cause of that problem?

    In AnchorRenderer.tsx, every chat-comment anchor and every one of its text children is styled with styles.dInlineFlex (display: inline-flex) at L144 and L171.

    inline-flex makes the anchor an atomic inline-level flex box on web. Its contents are a single anonymous flex item that the browser cannot split across line boxes, so when the link doesn't fit on the current line the whole anchor is pushed to the next line as one unit — exactly the symptom in the screenshots.

    The style was added in PR #58555 / commit d6771239 to align an inline <img> inside an anchor (task-title image link). It was correct for that case but applied unconditionally to every chat anchor — including the common pure-text case in this issue — which is what regressed inline wrapping.

    The component already computes the exact discriminator at L41:

    const linkHasImage = tnode.tagName === 'a' && tnode.children.some((child) => child.tagName === 'img');

    so dInlineFlex just needs to be gated on it.

    What changes do you think we should make in order to solve the problem?

    Make styles.dInlineFlex conditional on linkHasImage at both call sites in the chat-comment branch of AnchorRenderer.tsx.

    Wrapper — L136-L146:

                     isChildOfTaskTitle && styles.taskTitleMenuItem,
    -                styles.dInlineFlex,
    +                linkHasImage && styles.dInlineFlex,
                     hoverStyle,
                 ]}

    Text child inside renderChild — L162-L173:

                                         styles.textUnderlinePositionUnder,
                                         styles.textDecorationSkipInkNone,
    -                                    styles.dInlineFlex,
    +                                    linkHasImage && styles.dInlineFlex,
                                         hoverStyle,
                                     ]}

    When linkHasImage is false (the common chat case in the issue) both the anchor wrapper and its text children fall back to normal inline <Text> rendering, so link text participates in inline flow and wraps at word boundaries — restoring the expected behavior.

    When linkHasImage is true the original inline-flex is preserved at both sites, so the task-title-image alignment shipped by PR #58555 is byte-for-byte unchanged.

    The non-comment branch at L104-L112 does not use dInlineFlex, so non-chat links (RBR, label, task-title-text, etc.) are unaffected and need no change.

    What alternative solutions did you explore?

    • Replace styles.dInlineFlex with styles.dInline unconditionally at both lines. Fixes the text-wrap case but re-breaks the image-anchor layout that dInlineFlex was introduced for in PR #58555 — display: inline does not provide the alignment that lets the anchor host an inline-block <img> next to text.
    • Change the root view wrapper in BaseHTMLEngineProvider from alignItemsStart to dBlock. The root wrapper is not the cause: a chat anchor styled inline-flex breaks onto its own line inside any container, because the atomic-inline-flex behavior is on the anchor itself. The same symptom returns under any other container that holds rendered chat HTML.
    • Add flex-wrap: wrap to the inline-flex anchor. flex-wrap only wraps multiple flex items; the anchor's text is one anonymous flex item and still cannot break mid-link, so the symptom would remain.
  12. Hossiy21 commented on May 12, 2026

    @Hossiy21

    🚫 Duplicated proposal withdrawn by 🤖 ProposalPolice.

  13. 113 remaining items

  14. mallenexpensify commented on Jul 2, 2026

    @mallenexpensify
    Contributor

    @Pujan92 from the C+ doc

    If there are posts on the PR or issue that reference a regression or deploy blocker, C+ posts on the issue and confirms regression (which will reduce pay by 50%) or provides reasoning why they shouldn’t be considered. If the C+ doesn’t update the issue, the contributor manager making the payment will assume any mention of a regression or deploy blockers reduces the issue amount.

  15. Pujan92 commented on Jul 3, 2026

    @Pujan92
    Contributor

    Summary

    First PR gets reverted due to DB #93068
    Second PR gets reverted due to DB #92668
    No DB on Third PR

    As that part is used at multiple places, we ended up missing for all use cases and due to that, needed to revert 2 times. Considering the overall efforts, I would request for $125. Thoughts @Beamanator ?

  16. mallenexpensify commented on Jul 3, 2026

    @mallenexpensify
    Contributor

    @Beamanator 👀 above

  17. Beamanator commented on Jul 7, 2026

    @Beamanator
    Contributor

    makes sense to me! 👍

  18. mallenexpensify commented on Jul 7, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor+: @Pujan92 due $125 via NewDot

    ^ Test case created. Thx

  19. twisterdotcom commented on Jul 8, 2026

    @twisterdotcom
    Contributor

    $125 approved for @Pujan92 in Report ID: R00Xkf2P4liW.

    Zapier Logs Run ID: 00040eee-02f2-ab59-12b2-11358cbedc64

    SO: https://stackoverflowteams.com/c/expensify/questions/7582

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions