Skip to content

Commit 3169a87

Browse files
authored
fix(ios): guard zero-width measurement and synchronize layout in recycled views (#157)
* fix(ios): default to synchronous rendering and clamp shadow node layout constraints * fix(ios): clear segment views before rendering markdown and update documentation * refactor(ios): remove synchronous rendering support and improve layout handling
1 parent c96970d commit 3169a87

2 files changed

Lines changed: 120 additions & 43 deletions

File tree

ios/EnrichedMarkdown.mm

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -356,22 +356,15 @@ - (void)renderMarkdownContent:(NSString *)markdownString
356356
});
357357
}
358358

359-
- (void)renderMarkdownSynchronously:(NSString *)markdownString
359+
- (NSArray *)parseAndRenderSegments:(NSString *)markdownString
360360
{
361-
if (!markdownString || markdownString.length == 0) {
362-
return;
363-
}
364-
365-
_blockAsyncRender = YES;
366-
_cachedMarkdown = [markdownString copy];
367-
_renderedMarkdown = [markdownString copy];
368-
369361
MarkdownASTNode *ast = [_parser parseMarkdown:markdownString flags:_md4cFlags];
370362
if (!ast) {
371-
return;
363+
return nil;
372364
}
373365

374366
NSArray *segments = [self splitASTIntoSegments:ast];
367+
NSMutableArray *renderedSegments = [NSMutableArray array];
375368

376369
for (id segment in segments) {
377370
if ([segment isKindOfClass:[EMTextSegment class]]) {
@@ -380,19 +373,54 @@ - (void)renderMarkdownSynchronously:(NSString *)markdownString
380373
allowTrailingMargin:_allowTrailingMargin
381374
allowFontScaling:_fontScaleObserver.allowFontScaling
382375
maxFontSizeMultiplier:_maxFontSizeMultiplier];
383-
EnrichedMarkdownInternalText *view = [self createTextViewForRenderedSegment:rendered];
376+
[renderedSegments addObject:rendered];
377+
} else if ([segment isKindOfClass:[EMTableSegment class]]) {
378+
[renderedSegments addObject:segment];
379+
}
380+
#if ENRICHED_MARKDOWN_MATH
381+
else if ([segment isKindOfClass:[EMMathSegment class]]) {
382+
[renderedSegments addObject:segment];
383+
}
384+
#endif
385+
}
386+
387+
return renderedSegments;
388+
}
389+
390+
/// Synchronous rendering for mock view measurement (no UI updates needed).
391+
- (void)renderMarkdownSynchronously:(NSString *)markdownString
392+
{
393+
if (!markdownString || markdownString.length == 0) {
394+
return;
395+
}
396+
397+
for (UIView *view in _segmentViews) {
398+
[view removeFromSuperview];
399+
}
400+
[_segmentViews removeAllObjects];
401+
402+
_blockAsyncRender = YES;
403+
_cachedMarkdown = [markdownString copy];
404+
_renderedMarkdown = [markdownString copy];
405+
406+
NSArray *renderedSegments = [self parseAndRenderSegments:markdownString];
407+
if (!renderedSegments) {
408+
return;
409+
}
410+
411+
for (id segment in renderedSegments) {
412+
if ([segment isKindOfClass:[EMRenderedTextSegment class]]) {
413+
EnrichedMarkdownInternalText *view = [self createTextViewForRenderedSegment:(EMRenderedTextSegment *)segment];
384414
[_segmentViews addObject:view];
385415
[self addSubview:view];
386416
} else if ([segment isKindOfClass:[EMTableSegment class]]) {
387-
EMTableSegment *tableSegment = (EMTableSegment *)segment;
388-
TableContainerView *tableView = [self createTableViewForSegment:tableSegment];
417+
TableContainerView *tableView = [self createTableViewForSegment:(EMTableSegment *)segment];
389418
[_segmentViews addObject:tableView];
390419
[self addSubview:tableView];
391420
}
392421
#if ENRICHED_MARKDOWN_MATH
393422
else if ([segment isKindOfClass:[EMMathSegment class]]) {
394-
EMMathSegment *mathSegment = (EMMathSegment *)segment;
395-
ENRMMathContainerView *mathView = [self createMathViewForSegment:mathSegment];
423+
ENRMMathContainerView *mathView = [self createMathViewForSegment:(EMMathSegment *)segment];
396424
[_segmentViews addObject:mathView];
397425
[self addSubview:mathView];
398426
}
@@ -430,11 +458,17 @@ - (void)applyRenderedSegments:(NSArray *)renderedSegments
430458
#endif
431459
}
432460

433-
if (needsHeightUpdate([self measureSize:self.bounds.size.width], self.bounds)) {
434-
[self requestHeightUpdate];
435-
}
461+
// When bounds width is zero (recycled view not yet laid out), skip
462+
// measurement — didMoveToWindow will handle it once the view has real
463+
// bounds. Measuring with width=0 produces a bogus single-line measurement
464+
// that corrupts the height sent to Yoga.
465+
if (self.bounds.size.width > 0) {
466+
[self setNeedsLayout];
436467

437-
[self setNeedsLayout];
468+
if (needsHeightUpdate([self measureSize:self.bounds.size.width], self.bounds)) {
469+
[self requestHeightUpdate];
470+
}
471+
}
438472
}
439473

440474
- (EMRenderedTextSegment *)renderTextSegment:(EMTextSegment *)textSegment
@@ -615,18 +649,25 @@ - (void)didMoveToWindow
615649
EnrichedMarkdownInternalText *textSegment = (EnrichedMarkdownInternalText *)segment;
616650
UITextView *textView = textSegment.textView;
617651
textView.contentOffset = CGPointZero;
652+
653+
textView.frame = textSegment.bounds;
654+
textView.textContainer.size = CGSizeMake(textView.bounds.size.width, CGFLOAT_MAX);
655+
618656
if (textView.attributedText.length > 0) {
619657
[textView.layoutManager invalidateLayoutForCharacterRange:NSMakeRange(0, textView.attributedText.length)
620658
actualCharacterRange:NULL];
621-
textView.textContainer.size = CGSizeMake(textView.bounds.size.width, CGFLOAT_MAX);
622-
[textView setNeedsLayout];
623-
[textView layoutIfNeeded];
624-
[textView setNeedsDisplay];
659+
[textView.layoutManager ensureLayoutForTextContainer:textView.textContainer];
625660
}
661+
662+
[textView layoutIfNeeded];
663+
[textView setNeedsDisplay];
626664
}
627665
}
628666

629-
[self requestHeightUpdate];
667+
CGSize measured = [self measureSize:self.bounds.size.width];
668+
if (needsHeightUpdate(measured, self.bounds)) {
669+
[self requestHeightUpdate];
670+
}
630671
}
631672
}
632673

ios/EnrichedMarkdownText.mm

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -302,19 +302,11 @@ - (void)renderMarkdownContent:(NSString *)markdownString
302302
});
303303
}
304304

305-
// Synchronous rendering for mock view measurement (no UI updates needed)
306-
- (void)renderMarkdownSynchronously:(NSString *)markdownString
305+
- (NSMutableAttributedString *)parseAndRenderMarkdown:(NSString *)markdownString
307306
{
308-
if (!markdownString || markdownString.length == 0) {
309-
return;
310-
}
311-
312-
_blockAsyncRender = YES;
313-
_cachedMarkdown = [markdownString copy];
314-
315307
MarkdownASTNode *ast = [_parser parseMarkdown:markdownString flags:_md4cFlags];
316308
if (!ast) {
317-
return;
309+
return nil;
318310
}
319311

320312
AttributedRenderer *renderer = [[AttributedRenderer alloc] initWithConfig:_config];
@@ -331,6 +323,24 @@ - (void)renderMarkdownSynchronously:(NSString *)markdownString
331323

332324
_accessibilityInfo = [AccessibilityInfo infoFromContext:context];
333325

326+
return attributedText;
327+
}
328+
329+
/// Synchronous rendering for mock view measurement (no UI updates needed).
330+
- (void)renderMarkdownSynchronously:(NSString *)markdownString
331+
{
332+
if (!markdownString || markdownString.length == 0) {
333+
return;
334+
}
335+
336+
_blockAsyncRender = YES;
337+
_cachedMarkdown = [markdownString copy];
338+
339+
NSMutableAttributedString *attributedText = [self parseAndRenderMarkdown:markdownString];
340+
if (!attributedText) {
341+
return;
342+
}
343+
334344
_textView.attributedText = attributedText;
335345
_renderedMarkdown = [_cachedMarkdown copy];
336346
}
@@ -346,18 +356,38 @@ - (void)applyRenderedText:(NSMutableAttributedString *)attributedText
346356

347357
objc_setAssociatedObject(_textView.textContainer, kTextViewKey, _textView, OBJC_ASSOCIATION_ASSIGN);
348358

359+
// Ensure the text container has unlimited height before setting content.
360+
// updateLayoutMetrics may have shrunk the frame (and thus the text container)
361+
// from a previous layout pass, which would clip the new attributed text.
362+
CGFloat containerWidth = _textView.textContainer.size.width;
363+
if (containerWidth <= 0) {
364+
containerWidth = self.bounds.size.width;
365+
}
366+
_textView.textContainer.size = CGSizeMake(containerWidth, CGFLOAT_MAX);
367+
349368
_textView.attributedText = attributedText;
350369
_renderedMarkdown = [_cachedMarkdown copy];
351370

352371
[_textView.layoutManager invalidateLayoutForCharacterRange:NSMakeRange(0, attributedText.length)
353372
actualCharacterRange:NULL];
354373

355-
[_textView setNeedsLayout];
356-
[_textView setNeedsDisplay];
357-
[self setNeedsLayout];
374+
// When bounds width is zero (recycled view not yet laid out), skip layout
375+
// and measurement — didMoveToWindow will handle it once the view has real
376+
// bounds. Measuring with width=0 produces a bogus single-line measurement
377+
// that corrupts the height sent to Yoga.
378+
if (self.bounds.size.width > 0) {
379+
[_textView.layoutManager ensureLayoutForTextContainer:_textView.textContainer];
380+
[_textView layoutIfNeeded];
358381

359-
if (needsHeightUpdate([self measureSize:self.bounds.size.width], self.bounds)) {
360-
[self requestHeightUpdate];
382+
[_textView setNeedsDisplay];
383+
[self setNeedsLayout];
384+
385+
CGSize measured = [self measureSize:self.bounds.size.width];
386+
BOOL needsUpdate = needsHeightUpdate(measured, self.bounds);
387+
388+
if (needsUpdate) {
389+
[self requestHeightUpdate];
390+
}
361391
}
362392

363393
_accessibilityNeedsRebuild = YES;
@@ -471,18 +501,24 @@ - (void)didMoveToWindow
471501
_textView.hidden = NO;
472502
_textView.contentOffset = CGPointZero;
473503

504+
_textView.frame = self.bounds;
505+
_textView.textContainer.size = CGSizeMake(self.bounds.size.width, CGFLOAT_MAX);
506+
474507
NSAttributedString *text = _textView.attributedText;
475508
if (text.length > 0) {
476509
[_textView.layoutManager invalidateLayoutForCharacterRange:NSMakeRange(0, text.length) actualCharacterRange:NULL];
510+
[_textView.layoutManager ensureLayoutForTextContainer:_textView.textContainer];
477511
}
478512

479-
_textView.frame = self.bounds;
480-
_textView.textContainer.size = CGSizeMake(self.bounds.size.width, CGFLOAT_MAX);
481-
[_textView setNeedsLayout];
482513
[_textView layoutIfNeeded];
483514
[_textView setNeedsDisplay];
484515

485-
[self requestHeightUpdate];
516+
CGSize measured = [self measureSize:self.bounds.size.width];
517+
BOOL needsUpdate = needsHeightUpdate(measured, self.bounds);
518+
519+
if (needsUpdate) {
520+
[self requestHeightUpdate];
521+
}
486522
}
487523
}
488524

0 commit comments

Comments
 (0)