Skip to content

Commit 325aaba

Browse files
committed
feat(ios): improve GFM streaming for tables and math
1 parent a2bef06 commit 325aaba

2 files changed

Lines changed: 104 additions & 49 deletions

File tree

apps/example/src/StreamingMarkdownSimulator.tsx

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,46 +9,78 @@ import {
99
import { EnrichedMarkdownText } from 'react-native-enriched-markdown';
1010
import { customMarkdownStyle } from './markdownStyles';
1111

12-
const STREAM_SOURCE = `Here is a tiny streamed answer.
12+
const STREAM_SOURCE = `Here is a longer streamed answer used to stress GitHub-flavored markdown streaming on iOS.
1313
14-
First table:
14+
The goal is to keep normal text flowing while completed tables and block LaTeX views stay stable. Each section below adds enough text between block views to make layout changes easier to notice during streaming.
1515
16-
| Item | Value |
17-
| --- | ---: |
18-
| Alpha | 1 |
19-
| Beta | 2 |
16+
First summary table:
17+
18+
| Area | Why it matters | Expected behavior |
19+
| --- | --- | --- |
20+
| Text | Keeps streaming frequently | Tail text fades in |
21+
| Table | Expensive native block | Existing table is reused |
22+
| Math | Expensive native block | Existing formula is reused |
23+
24+
After the first table, the answer continues with regular prose. This paragraph should stream normally and should not cause the completed table above to be recreated. It gives the preview enough height to make jumps and delayed measurements visible.
2025
2126
First LaTeX block:
2227
2328
$$
2429
E = mc^2
2530
$$
2631
27-
Second table:
32+
The first equation is intentionally short. The following text continues immediately after it so we can verify that the math block appears once, then remains stable while more text is appended below.
2833
29-
| Step | Status |
30-
| --- | --- |
31-
| Parse | done |
32-
| Render | streaming |
34+
Second progress table:
35+
36+
| Step | Status | Notes |
37+
| --- | --- | --- |
38+
| Parse markdown | done | AST is ready |
39+
| Split segments | done | Text, table, and math are separated |
40+
| Reconcile views | active | Unchanged blocks should be reused |
41+
| Measure height | active | Height should update only when needed |
42+
43+
The stream now adds a longer paragraph to simulate a real assistant response. The important thing is that appending this text should not force the previous table or formula to flash, fade again, or rebuild their native views.
3344
3445
Second LaTeX block:
3546
3647
$$
3748
a^2 + b^2 = c^2
3849
$$
3950
40-
Final table:
51+
More explanatory text follows the second formula. This gives us another opportunity to check that previously completed blocks remain visually stable while the tail of the message continues to animate.
52+
53+
Comparison table:
54+
55+
| Scenario | Static GFM | Streaming GFM |
56+
| --- | --- | --- |
57+
| Complete table | Renders immediately | Renders when complete |
58+
| Incomplete table | Renders as parser allows | Hidden until complete |
59+
| Complete math block | Renders immediately | Renders when complete |
60+
| Incomplete math block | Renders as parser allows | Hidden until closing delimiter |
61+
62+
This paragraph is intentionally a little longer. It should make the preview scrollable and help us see whether the UI thread stays smooth when several completed block views already exist above the streaming tail.
63+
64+
Third LaTeX block:
65+
66+
$$
67+
F(x) = \\int_0^x t^2\\,dt = \\frac{x^3}{3}
68+
$$
69+
70+
Final validation table:
4171
42-
| Block | Kind |
72+
| Check | Result |
4373
| --- | --- |
44-
| One | text |
45-
| Two | table |
46-
| Three | math |
74+
| Text keeps streaming | expected |
75+
| Completed tables stay visible | expected |
76+
| Completed math stays visible | expected |
77+
| Incomplete block is hidden | expected |
78+
| Height grows only for rendered content | expected |
4779
48-
Done.`;
80+
The streamed answer is complete. At this point all tables and block LaTeX sections should be visible, and none of the earlier blocks should have been recreated unnecessarily while the final text was appended.`;
4981

5082
const TICK_MS = 80;
51-
const CHARS_PER_TICK = 3;
83+
const CHARS_PER_TICK = 6;
5284

5385
export default function StreamingMarkdownSimulator() {
5486
const [cursor, setCursor] = useState(0);
@@ -85,7 +117,7 @@ export default function StreamingMarkdownSimulator() {
85117
<ScrollView style={styles.root} contentContainerStyle={styles.content}>
86118
<Text style={styles.title}>Streaming markdown simulator</Text>
87119
<Text style={styles.subtitle}>
88-
JS-only stream: short text, a few tables, and a few block LaTeX
120+
JS-only stream: longer text, several tables, and several block LaTeX
89121
segments.
90122
</Text>
91123

ios/EnrichedMarkdown.mm

Lines changed: 53 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ @implementation EnrichedMarkdown {
6969
ENRMSegmentViewRegistry *_segmentViewRegistry;
7070
BOOL _forceRecreateSegments;
7171
BOOL _forceHeightUpdateOnNextRender;
72-
BOOL _heightUpdateScheduled;
7372

7473
dispatch_queue_t _renderQueue;
7574
NSUInteger _currentRenderId;
@@ -110,7 +109,6 @@ - (instancetype)initWithFrame:(CGRect)frame
110109
_segmentSignatures = [NSMutableArray array];
111110
_forceRecreateSegments = NO;
112111
_forceHeightUpdateOnNextRender = NO;
113-
_heightUpdateScheduled = NO;
114112
[self configureSegmentViewRegistry];
115113

116114
_renderQueue = dispatch_queue_create("com.swmansion.enriched.markdown.container.render", DISPATCH_QUEUE_SERIAL);
@@ -299,6 +297,21 @@ - (BOOL)hasRenderedMarkdown:(NSString *)markdown
299297
return _renderedMarkdown != nil && [_renderedMarkdown isEqualToString:markdown];
300298
}
301299

300+
- (BOOL)renderedSegmentsChangeTopology:(NSArray<ENRMRenderedSegment *> *)renderedSegments
301+
{
302+
if (renderedSegments.count != _segmentViews.count) {
303+
return YES;
304+
}
305+
306+
for (NSUInteger index = 0; index < renderedSegments.count; index++) {
307+
if (![_segmentViewRegistry view:_segmentViews[index] matchesSegment:renderedSegments[index]]) {
308+
return YES;
309+
}
310+
}
311+
312+
return NO;
313+
}
314+
302315
- (void)updateState:(const facebook::react::State::Shared &)state
303316
oldState:(const facebook::react::State::Shared &)oldState
304317
{
@@ -320,32 +333,6 @@ - (void)requestHeightUpdate
320333
_state->updateState(EnrichedMarkdownState(_heightUpdateCounter, selfRef));
321334
}
322335

323-
- (void)scheduleStreamingHeightUpdate
324-
{
325-
if (_heightUpdateScheduled) {
326-
return;
327-
}
328-
329-
_heightUpdateScheduled = YES;
330-
__weak EnrichedMarkdown *weakSelf = self;
331-
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.12 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
332-
EnrichedMarkdown *strongSelf = weakSelf;
333-
if (!strongSelf) {
334-
return;
335-
}
336-
337-
strongSelf->_heightUpdateScheduled = NO;
338-
if (strongSelf.bounds.size.width <= 0) {
339-
return;
340-
}
341-
342-
CGSize measured = [strongSelf measureSize:strongSelf.bounds.size.width];
343-
if (needsHeightUpdate(measured, strongSelf.bounds)) {
344-
[strongSelf requestHeightUpdate];
345-
}
346-
});
347-
}
348-
349336
- (void)renderMarkdownContent:(NSString *)markdownString
350337
{
351338
if (_blockAsyncRender) {
@@ -444,6 +431,7 @@ - (void)renderMarkdownSynchronously:(NSString *)markdownString
444431
- (void)applyRenderedSegments:(NSArray *)renderedSegments renderedMarkdown:(NSString *)renderedMarkdown
445432
{
446433
_renderedMarkdown = [renderedMarkdown copy];
434+
BOOL segmentTopologyChanged = _streamingAnimation && [self renderedSegmentsChangeTopology:renderedSegments];
447435

448436
ENRMSegmentReconciliationResult *result = [ENRMSegmentReconciler reconcileCurrentViews:_segmentViews
449437
currentSignatures:_segmentSignatures
@@ -472,10 +460,15 @@ - (void)applyRenderedSegments:(NSArray *)renderedSegments renderedMarkdown:(NSSt
472460
_forceHeightUpdateOnNextRender = NO;
473461
[self setNeedsLayout];
474462

475-
if (forceHeightUpdate) {
463+
if (forceHeightUpdate || segmentTopologyChanged) {
464+
[self computeSegmentLayoutForWidth:self.bounds.size.width applyFrames:YES];
465+
[self layoutIfNeeded];
476466
[self requestHeightUpdate];
477467
} else if (_streamingAnimation) {
478-
[self scheduleStreamingHeightUpdate];
468+
CGSize measured = [self measureSize:self.bounds.size.width];
469+
if (needsHeightUpdate(measured, self.bounds)) {
470+
[self requestHeightUpdate];
471+
}
479472
} else {
480473
CGSize measured = [self measureSize:self.bounds.size.width];
481474
if (needsHeightUpdate(measured, self.bounds)) {
@@ -770,9 +763,39 @@ - (void)didMoveToWindow
770763
return EnrichedMarkdown.class;
771764
}
772765

766+
#if !TARGET_OS_OSX
767+
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
768+
{
769+
if ([super pointInside:point withEvent:event]) {
770+
return YES;
771+
}
772+
773+
for (RCTUIView *segment in _segmentViews) {
774+
if (CGRectContainsPoint(segment.frame, point)) {
775+
return YES;
776+
}
777+
}
778+
779+
return NO;
780+
}
781+
#endif
782+
773783
- (facebook::react::SharedTouchEventEmitter)touchEventEmitterAtPoint:(CGPoint)point
774784
{
775785
for (RCTUIView *segment in _segmentViews) {
786+
if ([segment isKindOfClass:[TableContainerView class]]) {
787+
CGPoint segmentPoint = [self convertPoint:point toView:segment];
788+
#if !TARGET_OS_OSX
789+
if ([segment pointInside:segmentPoint withEvent:nil]) {
790+
return nil;
791+
}
792+
#else
793+
if (CGRectContainsPoint(segment.bounds, segmentPoint)) {
794+
return nil;
795+
}
796+
#endif
797+
}
798+
776799
if (![segment isKindOfClass:[EnrichedMarkdownInternalText class]]) {
777800
continue;
778801
}

0 commit comments

Comments
 (0)