Skip to content

[Bug] Text / Heading: Infinite re-render loop in React 19 (unmemoized mergeRefs & setState on detachment) and ESM import error #6400

Description

@Abdul-Maajith

Description

In @astryxdesign/core (tested on v0.1.3 with React 19.x), rendering <Text> or <Heading> with truncation enabled (or any component that wraps them, such as <DialogHeader>, <AlertDialog>, or <Spinner label="...">) causes an infinite re-render loop resulting in a fatal crash:

Uncaught Error: Maximum update depth exceeded. This can happen when a component
repeatedly calls setState inside componentWillUpdate or componentDidUpdate.
React limits the number of nested updates to prevent infinite loops.

Additionally, in ESM environments such as Vite, the dynamic import of Tooltip inside Text.js and Heading.js fails resolution due to a missing .js extension (import('../Tooltip/Tooltip')).


Root Cause Analysis

There are two closely coupled root causes causing the React 19 infinite loop:

1. Unmemoized mergeRefs in Text.tsx and Heading.tsx

In packages/core/src/Text/Text.tsx and Heading.tsx:

<Component
  ref={mergeRefs(ref, truncation.ref, textRef)}
  ...
/>

Because mergeRefs(...) is called inline during render without useMemo, a new callback ref function identity is returned on every single render.

In React 19, whenever a DOM element's ref callback reference changes between renders:

  1. React immediately detaches the old ref callback by calling it with null.
  2. React attaches the new ref callback by calling it with the DOM node element.

2. Synchronous setState during ref detachment in useTruncation.ts

In packages/core/src/Text/useTruncation.ts:

const ref = useCallback(
  (element: HTMLElement | null) => {
    if (elementRef.current) {
      unobserveResize(elementRef.current);
    }
    elementRef.current = element;

    if (element && maxLines > 0) {
      ...
    } else if (!element) {
      setIsTruncated((prev) => (prev ? false : prev));
      setFullText((prev) => (prev ? '' : prev));
    }
  },
  [maxLines, checkTruncation],
);

When React 19 calls truncation.ref(null) during ref detachment:

  • If the element was previously truncated (isTruncated === true), setIsTruncated((prev) => (prev ? false : prev)) updates state to false.
  • This state change immediately schedules another render of <Text>.
  • The subsequent render creates yet another new mergeRefs(...) function identity.
  • On attachment, checkTruncation measures the DOM element, detects truncation, and calls setIsTruncated(true).
  • Setting isTruncated back to true schedules another render.
  • The cycle repeats infinitely until React aborts with Maximum update depth exceeded.

Additional Issue: Extensionless Dynamic Import in Vite/ESM

In packages/core/src/Text/Text.tsx and Heading.tsx (and their compiled dist/ outputs):

const LazyXDSTooltip = lazy(async () =>
  import('../Tooltip/Tooltip').then((mod) => ({
    default: mod.Tooltip,
  })),
);

In modern ESM bundlers like Vite or native Node ESM, relative imports inside package distributions require explicit file extensions. The missing .js extension triggers:

Failed to resolve import "../Tooltip/Tooltip" from "node_modules/@astryxdesign/core/dist/Text/Text.js". Does the file exist?

Reproduction

  1. Set up a project with React 19 (react@^19.0.0, react-dom@^19.0.0) and Vite.
  2. Install @astryxdesign/core@^0.1.3.
  3. Render a <Text maxLines={1}>Long text that overflows the container</Text> or any container component like <AlertDialog> or <DialogHeader>.
  4. Trigger a state change or re-render in the parent component.
  5. React crashes with Maximum update depth exceeded.

Suggested Fix

1. Memoize mergeRefs in Text.tsx and Heading.tsx

const mergedRef = useMemo(
  () => mergeRefs(ref, truncation.ref, textRef),
  [ref, truncation.ref]
);

return (
  <Component
    ref={mergedRef}
    ...

2. Remove setState during ref detachment in useTruncation.ts

When an element unmounts or detaches, state updates should not be queued synchronously from the ref callback. Only the elementRef.current and observer cleanup are needed:

const ref = useCallback(
  (element: HTMLElement | null) => {
    if (elementRef.current) {
      unobserveResize(elementRef.current);
    }
    elementRef.current = element;

    if (element && maxLines > 0) {
      if (typeof ResizeObserver !== 'undefined') {
        observeResize(element, () => {
          checkTruncation(element);
        });
      } else {
        checkTruncation(element);
      }
    }
  },
  [maxLines, checkTruncation],
);

3. Add .js extension to dynamic Tooltip import

const LazyXDSTooltip = lazy(async () =>
  import('../Tooltip/Tooltip.js').then((mod) => ({
    default: mod.Tooltip,
  })),
);

Astryx Version

@astryxdesign/core@0.1.3

Environment

  • React: 19.2.x
  • Bundler: Vite 6.x
  • OS: macOS / Linux

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions