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:
- React immediately detaches the old ref callback by calling it with
null.
- 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
- Set up a project with React 19 (
react@^19.0.0, react-dom@^19.0.0) and Vite.
- Install
@astryxdesign/core@^0.1.3.
- Render a
<Text maxLines={1}>Long text that overflows the container</Text> or any container component like <AlertDialog> or <DialogHeader>.
- Trigger a state change or re-render in the parent component.
- 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
Description
In
@astryxdesign/core(tested onv0.1.3with React19.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:Additionally, in ESM environments such as Vite, the dynamic import of
TooltipinsideText.jsandHeading.jsfails resolution due to a missing.jsextension (import('../Tooltip/Tooltip')).Root Cause Analysis
There are two closely coupled root causes causing the React 19 infinite loop:
1. Unmemoized
mergeRefsinText.tsxandHeading.tsxIn
packages/core/src/Text/Text.tsxandHeading.tsx:Because
mergeRefs(...)is called inline during render withoutuseMemo, 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:
null.element.2. Synchronous
setStateduring ref detachment inuseTruncation.tsIn
packages/core/src/Text/useTruncation.ts:When React 19 calls
truncation.ref(null)during ref detachment:isTruncated === true),setIsTruncated((prev) => (prev ? false : prev))updates state tofalse.<Text>.mergeRefs(...)function identity.checkTruncationmeasures the DOM element, detects truncation, and callssetIsTruncated(true).isTruncatedback totrueschedules another render.Maximum update depth exceeded.Additional Issue: Extensionless Dynamic Import in Vite/ESM
In
packages/core/src/Text/Text.tsxandHeading.tsx(and their compileddist/outputs):In modern ESM bundlers like Vite or native Node ESM, relative imports inside package distributions require explicit file extensions. The missing
.jsextension triggers:Reproduction
react@^19.0.0,react-dom@^19.0.0) and Vite.@astryxdesign/core@^0.1.3.<Text maxLines={1}>Long text that overflows the container</Text>or any container component like<AlertDialog>or<DialogHeader>.Maximum update depth exceeded.Suggested Fix
1. Memoize
mergeRefsinText.tsxandHeading.tsx2. Remove
setStateduring ref detachment inuseTruncation.tsWhen an element unmounts or detaches, state updates should not be queued synchronously from the ref callback. Only the
elementRef.currentand observer cleanup are needed:3. Add
.jsextension to dynamic Tooltip importAstryx Version
@astryxdesign/core@0.1.3Environment
19.2.x6.x