react-native-nitro-markdown 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +351 -22
  2. package/android/src/main/java/com/margelo/nitro/com/nitromarkdown/HybridMarkdownSession.kt +27 -8
  3. package/cpp/bindings/HybridMarkdownParser.cpp +216 -66
  4. package/cpp/bindings/HybridMarkdownParser.hpp +2 -0
  5. package/ios/HybridMarkdownSession.swift +33 -7
  6. package/lib/commonjs/headless.js +41 -5
  7. package/lib/commonjs/headless.js.map +1 -1
  8. package/lib/commonjs/index.js.map +1 -1
  9. package/lib/commonjs/markdown-stream.js +107 -13
  10. package/lib/commonjs/markdown-stream.js.map +1 -1
  11. package/lib/commonjs/markdown.js +180 -25
  12. package/lib/commonjs/markdown.js.map +1 -1
  13. package/lib/commonjs/renderers/code.js +1 -0
  14. package/lib/commonjs/renderers/code.js.map +1 -1
  15. package/lib/commonjs/renderers/table.js +116 -24
  16. package/lib/commonjs/renderers/table.js.map +1 -1
  17. package/lib/commonjs/utils/incremental-ast.js +153 -0
  18. package/lib/commonjs/utils/incremental-ast.js.map +1 -0
  19. package/lib/module/headless.js +37 -4
  20. package/lib/module/headless.js.map +1 -1
  21. package/lib/module/index.js.map +1 -1
  22. package/lib/module/markdown-stream.js +108 -14
  23. package/lib/module/markdown-stream.js.map +1 -1
  24. package/lib/module/markdown.js +182 -27
  25. package/lib/module/markdown.js.map +1 -1
  26. package/lib/module/renderers/code.js +1 -0
  27. package/lib/module/renderers/code.js.map +1 -1
  28. package/lib/module/renderers/table.js +116 -24
  29. package/lib/module/renderers/table.js.map +1 -1
  30. package/lib/module/utils/incremental-ast.js +147 -0
  31. package/lib/module/utils/incremental-ast.js.map +1 -0
  32. package/lib/typescript/commonjs/Markdown.nitro.d.ts +2 -0
  33. package/lib/typescript/commonjs/Markdown.nitro.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/headless.d.ts +13 -0
  35. package/lib/typescript/commonjs/headless.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/index.d.ts +2 -0
  37. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  38. package/lib/typescript/commonjs/markdown-stream.d.ts +6 -1
  39. package/lib/typescript/commonjs/markdown-stream.d.ts.map +1 -1
  40. package/lib/typescript/commonjs/markdown.d.ts +53 -1
  41. package/lib/typescript/commonjs/markdown.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/renderers/code.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/renderers/table.d.ts +1 -1
  44. package/lib/typescript/commonjs/renderers/table.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/specs/MarkdownSession.nitro.d.ts +5 -2
  46. package/lib/typescript/commonjs/specs/MarkdownSession.nitro.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/utils/incremental-ast.d.ts +12 -0
  48. package/lib/typescript/commonjs/utils/incremental-ast.d.ts.map +1 -0
  49. package/lib/typescript/module/Markdown.nitro.d.ts +2 -0
  50. package/lib/typescript/module/Markdown.nitro.d.ts.map +1 -1
  51. package/lib/typescript/module/headless.d.ts +13 -0
  52. package/lib/typescript/module/headless.d.ts.map +1 -1
  53. package/lib/typescript/module/index.d.ts +2 -0
  54. package/lib/typescript/module/index.d.ts.map +1 -1
  55. package/lib/typescript/module/markdown-stream.d.ts +6 -1
  56. package/lib/typescript/module/markdown-stream.d.ts.map +1 -1
  57. package/lib/typescript/module/markdown.d.ts +53 -1
  58. package/lib/typescript/module/markdown.d.ts.map +1 -1
  59. package/lib/typescript/module/renderers/code.d.ts.map +1 -1
  60. package/lib/typescript/module/renderers/table.d.ts +1 -1
  61. package/lib/typescript/module/renderers/table.d.ts.map +1 -1
  62. package/lib/typescript/module/specs/MarkdownSession.nitro.d.ts +5 -2
  63. package/lib/typescript/module/specs/MarkdownSession.nitro.d.ts.map +1 -1
  64. package/lib/typescript/module/utils/incremental-ast.d.ts +12 -0
  65. package/lib/typescript/module/utils/incremental-ast.d.ts.map +1 -0
  66. package/nitrogen/generated/android/NitroMarkdownOnLoad.cpp +2 -0
  67. package/nitrogen/generated/android/c++/JFunc_void_double_double.hpp +75 -0
  68. package/nitrogen/generated/android/c++/JHybridMarkdownSessionSpec.cpp +18 -6
  69. package/nitrogen/generated/android/c++/JHybridMarkdownSessionSpec.hpp +4 -2
  70. package/nitrogen/generated/android/kotlin/com/margelo/nitro/com/nitromarkdown/Func_void_double_double.kt +80 -0
  71. package/nitrogen/generated/android/kotlin/com/margelo/nitro/com/nitromarkdown/HybridMarkdownSessionSpec.kt +11 -3
  72. package/nitrogen/generated/ios/NitroMarkdown-Swift-Cxx-Bridge.cpp +8 -0
  73. package/nitrogen/generated/ios/NitroMarkdown-Swift-Cxx-Bridge.hpp +31 -0
  74. package/nitrogen/generated/ios/c++/HybridMarkdownSessionSpecSwift.hpp +20 -2
  75. package/nitrogen/generated/ios/swift/Func_void_double_double.swift +46 -0
  76. package/nitrogen/generated/ios/swift/HybridMarkdownSessionSpec.swift +4 -2
  77. package/nitrogen/generated/ios/swift/HybridMarkdownSessionSpec_cxx.swift +34 -9
  78. package/nitrogen/generated/shared/c++/HybridMarkdownParserSpec.cpp +2 -0
  79. package/nitrogen/generated/shared/c++/HybridMarkdownParserSpec.hpp +2 -0
  80. package/nitrogen/generated/shared/c++/HybridMarkdownSessionSpec.cpp +2 -0
  81. package/nitrogen/generated/shared/c++/HybridMarkdownSessionSpec.hpp +4 -2
  82. package/package.json +4 -3
  83. package/src/Markdown.nitro.ts +2 -0
  84. package/src/headless.ts +42 -4
  85. package/src/index.ts +7 -0
  86. package/src/markdown-stream.tsx +163 -15
  87. package/src/markdown.tsx +339 -24
  88. package/src/renderers/code.tsx +5 -1
  89. package/src/renderers/table.tsx +212 -66
  90. package/src/specs/MarkdownSession.nitro.ts +6 -2
  91. package/src/utils/incremental-ast.ts +224 -0
@@ -1,6 +1,54 @@
1
- import { useState, useEffect, useRef, startTransition, type FC } from "react";
1
+ import {
2
+ useState,
3
+ useEffect,
4
+ useRef,
5
+ useCallback,
6
+ startTransition,
7
+ type FC,
8
+ } from "react";
9
+ import type { MarkdownNode } from "./headless";
2
10
  import { Markdown, type MarkdownProps } from "./markdown";
3
11
  import type { MarkdownSession } from "./specs/MarkdownSession.nitro";
12
+ import { getNextStreamAst, parseMarkdownAst } from "./utils/incremental-ast";
13
+
14
+ const normalizeOffset = (value: number): number | null => {
15
+ if (!Number.isFinite(value)) return null;
16
+ if (value <= 0) return 0;
17
+ return Math.floor(value);
18
+ };
19
+
20
+ const resolveStreamText = ({
21
+ forceFullSync,
22
+ pendingFrom,
23
+ pendingTo,
24
+ previousText,
25
+ session,
26
+ }: {
27
+ forceFullSync: boolean;
28
+ pendingFrom: number | null;
29
+ pendingTo: number | null;
30
+ previousText: string;
31
+ session: MarkdownSession;
32
+ }): string => {
33
+ if (forceFullSync || pendingFrom === null || pendingTo === null) {
34
+ return session.getAllText();
35
+ }
36
+
37
+ if (pendingTo < pendingFrom) {
38
+ return session.getAllText();
39
+ }
40
+
41
+ if (pendingFrom === previousText.length) {
42
+ const appendedChunk = session.getTextRange(pendingFrom, pendingTo);
43
+ return `${previousText}${appendedChunk}`;
44
+ }
45
+
46
+ if (pendingFrom === 0) {
47
+ return session.getTextRange(0, pendingTo);
48
+ }
49
+
50
+ return session.getAllText();
51
+ };
4
52
 
5
53
  export type MarkdownStreamProps = {
6
54
  /**
@@ -23,7 +71,12 @@ export type MarkdownStreamProps = {
23
71
  * Useful when you want to prioritize user interactions over stream renders.
24
72
  */
25
73
  useTransitionUpdates?: boolean;
26
- } & Omit<MarkdownProps, "children">;
74
+ /**
75
+ * Enable incremental AST updates for append-only streams.
76
+ * Automatically falls back to full parse when updates are not safely mergeable.
77
+ */
78
+ incrementalParsing?: boolean;
79
+ } & Omit<MarkdownProps, "children" | "sourceAst">;
27
80
 
28
81
  /**
29
82
  * A component that renders streaming Markdown from a MarkdownSession.
@@ -34,20 +87,55 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
34
87
  updateIntervalMs = 50,
35
88
  updateStrategy = "interval",
36
89
  useTransitionUpdates = false,
90
+ incrementalParsing = true,
91
+ options,
92
+ plugins,
37
93
  ...props
38
94
  }) => {
39
- const [text, setText] = useState(() => session.getAllText());
95
+ const parseText = useCallback(
96
+ (text: string): MarkdownNode => parseMarkdownAst(text, options),
97
+ [options],
98
+ );
99
+ const createEmptyAst = (): MarkdownNode => ({
100
+ type: "document",
101
+ children: [],
102
+ });
103
+ const initialText = session.getAllText();
104
+ const hasBeforeParsePlugins =
105
+ plugins?.some((plugin) => typeof plugin.beforeParse === "function") ??
106
+ false;
107
+ const [renderState, setRenderState] = useState(() => ({
108
+ text: initialText,
109
+ ast: hasBeforeParsePlugins ? createEmptyAst() : parseText(initialText),
110
+ }));
111
+ const renderStateRef = useRef(renderState);
40
112
  const pendingUpdateRef = useRef(false);
113
+ const pendingFromRef = useRef<number | null>(null);
114
+ const pendingToRef = useRef<number | null>(null);
115
+ const forceFullSyncRef = useRef(false);
41
116
  const updateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
42
117
  const rafRef = useRef<number | null>(null);
43
- const lastEmittedRef = useRef(text);
118
+ const allowIncremental = incrementalParsing && !hasBeforeParsePlugins;
119
+
120
+ useEffect(() => {
121
+ renderStateRef.current = renderState;
122
+ }, [renderState]);
44
123
 
45
124
  useEffect(() => {
46
- // Ensure initial state is synced
47
125
  const initialText = session.getAllText();
48
- setText(initialText);
49
- lastEmittedRef.current = initialText;
126
+ const initialState = {
127
+ text: initialText,
128
+ ast: hasBeforeParsePlugins ? createEmptyAst() : parseText(initialText),
129
+ };
130
+ pendingUpdateRef.current = false;
131
+ pendingFromRef.current = null;
132
+ pendingToRef.current = null;
133
+ forceFullSyncRef.current = false;
134
+ renderStateRef.current = initialState;
135
+ setRenderState(initialState);
136
+ }, [hasBeforeParsePlugins, parseText, session]);
50
137
 
138
+ useEffect(() => {
51
139
  const flushUpdate = () => {
52
140
  updateTimerRef.current = null;
53
141
  if (rafRef.current !== null) {
@@ -57,16 +145,44 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
57
145
  if (!pendingUpdateRef.current) return;
58
146
  pendingUpdateRef.current = false;
59
147
 
60
- const latest = session.getAllText();
61
- if (latest === lastEmittedRef.current) return;
62
- lastEmittedRef.current = latest;
148
+ const previousState = renderStateRef.current;
149
+ const pendingFrom = pendingFromRef.current;
150
+ const pendingTo = pendingToRef.current;
151
+ const forceFullSync = forceFullSyncRef.current;
152
+ pendingFromRef.current = null;
153
+ pendingToRef.current = null;
154
+ forceFullSyncRef.current = false;
155
+
156
+ const latest = resolveStreamText({
157
+ forceFullSync,
158
+ pendingFrom,
159
+ pendingTo,
160
+ previousText: previousState.text,
161
+ session,
162
+ });
163
+ if (latest === previousState.text) return;
164
+
165
+ const nextAst = hasBeforeParsePlugins
166
+ ? previousState.ast
167
+ : getNextStreamAst({
168
+ allowIncremental,
169
+ nextText: latest,
170
+ options,
171
+ previousAst: previousState.ast,
172
+ previousText: previousState.text,
173
+ });
174
+ const nextState = {
175
+ text: latest,
176
+ ast: nextAst,
177
+ };
178
+ renderStateRef.current = nextState;
63
179
 
64
180
  if (useTransitionUpdates) {
65
181
  startTransition(() => {
66
- setText(latest);
182
+ setRenderState(nextState);
67
183
  });
68
184
  } else {
69
- setText(latest);
185
+ setRenderState(nextState);
70
186
  }
71
187
  };
72
188
 
@@ -83,7 +199,22 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
83
199
  }
84
200
  };
85
201
 
86
- const unsubscribe = session.addListener(() => {
202
+ const unsubscribe = session.addListener((from, to) => {
203
+ const nextFrom = normalizeOffset(from);
204
+ const nextTo = normalizeOffset(to);
205
+
206
+ if (nextFrom === null || nextTo === null || nextTo < nextFrom) {
207
+ forceFullSyncRef.current = true;
208
+ } else {
209
+ const currentFrom = pendingFromRef.current;
210
+ const currentTo = pendingToRef.current;
211
+
212
+ pendingFromRef.current =
213
+ currentFrom === null ? nextFrom : Math.min(currentFrom, nextFrom);
214
+ pendingToRef.current =
215
+ currentTo === null ? nextTo : Math.max(currentTo, nextTo);
216
+ }
217
+
87
218
  pendingUpdateRef.current = true;
88
219
  scheduleFlush();
89
220
  });
@@ -99,7 +230,24 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
99
230
  rafRef.current = null;
100
231
  }
101
232
  };
102
- }, [session, updateIntervalMs, updateStrategy, useTransitionUpdates]);
233
+ }, [
234
+ allowIncremental,
235
+ hasBeforeParsePlugins,
236
+ options,
237
+ session,
238
+ updateIntervalMs,
239
+ updateStrategy,
240
+ useTransitionUpdates,
241
+ ]);
103
242
 
104
- return <Markdown {...props}>{text}</Markdown>;
243
+ return (
244
+ <Markdown
245
+ {...props}
246
+ options={options}
247
+ plugins={plugins}
248
+ sourceAst={hasBeforeParsePlugins ? undefined : renderState.ast}
249
+ >
250
+ {renderState.text}
251
+ </Markdown>
252
+ );
105
253
  };
package/src/markdown.tsx CHANGED
@@ -1,4 +1,6 @@
1
1
  import {
2
+ memo,
3
+ useCallback,
2
4
  useEffect,
3
5
  useMemo,
4
6
  type FC,
@@ -10,7 +12,10 @@ import {
10
12
  StyleSheet,
11
13
  View,
12
14
  Text,
15
+ FlatList,
13
16
  Platform,
17
+ type ListRenderItemInfo,
18
+ type FlatListProps,
14
19
  type StyleProp,
15
20
  type ViewStyle,
16
21
  } from "react-native";
@@ -50,6 +55,188 @@ import {
50
55
  } from "./theme";
51
56
 
52
57
  const baseStylesCache = new WeakMap<MarkdownTheme, BaseStyles>();
58
+ const parseAstCache = new Map<string, MarkdownNode>();
59
+ const MAX_PARSE_CACHE_ENTRIES = 32;
60
+ const MAX_CACHEABLE_TEXT_LENGTH = 24_000;
61
+
62
+ export type AstTransform = (ast: MarkdownNode) => MarkdownNode;
63
+ export type MarkdownVirtualizationOptions = Pick<
64
+ FlatListProps<MarkdownNode>,
65
+ | "initialNumToRender"
66
+ | "maxToRenderPerBatch"
67
+ | "windowSize"
68
+ | "updateCellsBatchingPeriod"
69
+ | "removeClippedSubviews"
70
+ >;
71
+
72
+ export type MarkdownPlugin = {
73
+ /**
74
+ * Optional plugin name used for diagnostics and debugging.
75
+ */
76
+ name?: string;
77
+ /**
78
+ * Optional plugin version metadata for diagnostics.
79
+ */
80
+ version?: string | number;
81
+ /**
82
+ * Optional text preprocessor executed before native parsing.
83
+ * Should return a full markdown string.
84
+ */
85
+ beforeParse?: (markdown: string) => string;
86
+ /**
87
+ * Optional AST postprocessor executed after native parsing.
88
+ */
89
+ afterParse?: AstTransform;
90
+ };
91
+
92
+ const isMarkdownNode = (value: unknown): value is MarkdownNode => {
93
+ if (typeof value !== "object" || value === null) return false;
94
+ return typeof Reflect.get(value, "type") === "string";
95
+ };
96
+
97
+ const warnInDev = (message: string, error?: unknown): void => {
98
+ if (typeof __DEV__ === "undefined" || !__DEV__) return;
99
+
100
+ const runtimeConsole = Reflect.get(globalThis, "console");
101
+ if (
102
+ typeof runtimeConsole === "object" &&
103
+ runtimeConsole !== null &&
104
+ "warn" in runtimeConsole &&
105
+ typeof runtimeConsole.warn === "function"
106
+ ) {
107
+ runtimeConsole.warn(message, error);
108
+ }
109
+ };
110
+
111
+ const cloneMarkdownNode = (node: MarkdownNode): MarkdownNode => {
112
+ return {
113
+ ...node,
114
+ children: node.children?.map(cloneMarkdownNode),
115
+ };
116
+ };
117
+
118
+ const getParserOptionsKey = (options?: ParserOptions): string => {
119
+ if (!options) return "gfm:default|math:default";
120
+
121
+ const gfm = options.gfm === undefined ? "default" : options.gfm ? "1" : "0";
122
+ const math =
123
+ options.math === undefined ? "default" : options.math ? "1" : "0";
124
+ return `gfm:${gfm}|math:${math}`;
125
+ };
126
+
127
+ const normalizeParserOptions = (
128
+ options?: ParserOptions,
129
+ ): ParserOptions | undefined => {
130
+ if (!options) return undefined;
131
+
132
+ const gfm = options.gfm;
133
+ const math = options.math;
134
+
135
+ if (gfm === undefined && math === undefined) {
136
+ return undefined;
137
+ }
138
+
139
+ return {
140
+ gfm,
141
+ math,
142
+ };
143
+ };
144
+
145
+ const parseWithNativeParser = (
146
+ text: string,
147
+ options?: ParserOptions,
148
+ ): MarkdownNode => {
149
+ if (options) {
150
+ return parseMarkdownWithOptions(text, options);
151
+ }
152
+ return parseMarkdown(text);
153
+ };
154
+
155
+ const getCachedParsedAst = (
156
+ text: string,
157
+ options?: ParserOptions,
158
+ ): MarkdownNode => {
159
+ if (text.length > MAX_CACHEABLE_TEXT_LENGTH) {
160
+ return parseWithNativeParser(text, options);
161
+ }
162
+
163
+ const cacheKey = `${getParserOptionsKey(options)}|${text}`;
164
+ const cachedNode = parseAstCache.get(cacheKey);
165
+ if (cachedNode) {
166
+ parseAstCache.delete(cacheKey);
167
+ parseAstCache.set(cacheKey, cachedNode);
168
+ return cloneMarkdownNode(cachedNode);
169
+ }
170
+
171
+ const parsedNode = parseWithNativeParser(text, options);
172
+ parseAstCache.set(cacheKey, parsedNode);
173
+ if (parseAstCache.size > MAX_PARSE_CACHE_ENTRIES) {
174
+ const oldestCacheKey = parseAstCache.keys().next().value;
175
+ if (typeof oldestCacheKey === "string") {
176
+ parseAstCache.delete(oldestCacheKey);
177
+ }
178
+ }
179
+
180
+ return cloneMarkdownNode(parsedNode);
181
+ };
182
+
183
+ const applyBeforeParsePlugins = (
184
+ markdown: string,
185
+ plugins?: MarkdownPlugin[],
186
+ ): string => {
187
+ if (!plugins || plugins.length === 0) {
188
+ return markdown;
189
+ }
190
+
191
+ let nextMarkdown = markdown;
192
+ for (const plugin of plugins) {
193
+ if (!plugin.beforeParse) continue;
194
+
195
+ try {
196
+ const transformed = plugin.beforeParse(nextMarkdown);
197
+ if (typeof transformed === "string") {
198
+ nextMarkdown = transformed;
199
+ }
200
+ } catch (error) {
201
+ const pluginLabel = plugin.name ? ` (${plugin.name})` : "";
202
+ warnInDev(
203
+ `[react-native-nitro-markdown] plugin beforeParse${pluginLabel} threw; using previous markdown.`,
204
+ error,
205
+ );
206
+ }
207
+ }
208
+
209
+ return nextMarkdown;
210
+ };
211
+
212
+ const applyAfterParsePlugins = (
213
+ ast: MarkdownNode,
214
+ plugins?: MarkdownPlugin[],
215
+ ): MarkdownNode => {
216
+ if (!plugins || plugins.length === 0) {
217
+ return ast;
218
+ }
219
+
220
+ let nextAst = ast;
221
+ for (const plugin of plugins) {
222
+ if (!plugin.afterParse) continue;
223
+
224
+ try {
225
+ const transformed = plugin.afterParse(nextAst);
226
+ if (isMarkdownNode(transformed)) {
227
+ nextAst = transformed;
228
+ }
229
+ } catch (error) {
230
+ const pluginLabel = plugin.name ? ` (${plugin.name})` : "";
231
+ warnInDev(
232
+ `[react-native-nitro-markdown] plugin afterParse${pluginLabel} threw; using previous AST.`,
233
+ error,
234
+ );
235
+ }
236
+ }
237
+
238
+ return nextAst;
239
+ };
53
240
 
54
241
  export type MarkdownProps = {
55
242
  /**
@@ -60,6 +247,20 @@ export type MarkdownProps = {
60
247
  * Parser options to enable GFM or Math support.
61
248
  */
62
249
  options?: ParserOptions;
250
+ /**
251
+ * Optional parser plugins for preprocessing and AST postprocessing.
252
+ */
253
+ plugins?: MarkdownPlugin[];
254
+ /**
255
+ * Optional pre-parsed AST.
256
+ * When provided, native parse is skipped and this tree is rendered instead.
257
+ */
258
+ sourceAst?: MarkdownNode;
259
+ /**
260
+ * Optional transform applied after parsing and before rendering.
261
+ * The transformed AST is also returned in `onParseComplete`.
262
+ */
263
+ astTransform?: AstTransform;
63
264
  /**
64
265
  * Callback fired when parsing begins.
65
266
  */
@@ -107,11 +308,31 @@ export type MarkdownProps = {
107
308
  * Return false to prevent the default openURL behavior.
108
309
  */
109
310
  onLinkPress?: LinkPressHandler;
311
+ /**
312
+ * Enables top-level block virtualization for very large markdown documents.
313
+ * Best used when Markdown is the primary scroll container on screen.
314
+ * - `true`: always virtualize when block threshold is met
315
+ * - `"auto"`: virtualize only when threshold is met (recommended for large docs)
316
+ * - `false`: disable virtualization (default)
317
+ */
318
+ virtualize?: boolean | "auto";
319
+ /**
320
+ * Minimum number of top-level blocks before virtualization is activated.
321
+ * Helps avoid FlatList overhead on small documents.
322
+ */
323
+ virtualizationMinBlocks?: number;
324
+ /**
325
+ * Optional FlatList tuning for virtualization.
326
+ */
327
+ virtualization?: MarkdownVirtualizationOptions;
110
328
  };
111
329
 
112
330
  export const Markdown: FC<MarkdownProps> = ({
113
331
  children,
114
332
  options,
333
+ plugins,
334
+ sourceAst,
335
+ astTransform,
115
336
  renderers = {},
116
337
  theme: userTheme,
117
338
  styles: nodeStyles,
@@ -120,41 +341,77 @@ export const Markdown: FC<MarkdownProps> = ({
120
341
  onParsingInProgress,
121
342
  onParseComplete,
122
343
  onLinkPress,
344
+ virtualize = false,
345
+ virtualizationMinBlocks = 40,
346
+ virtualization,
123
347
  }) => {
348
+ const parserOptionGfm = options?.gfm;
349
+ const parserOptionMath = options?.math;
350
+
124
351
  const parseResult = useMemo(() => {
125
352
  try {
126
- let ast: MarkdownNode;
127
- if (options) {
128
- ast = parseMarkdownWithOptions(children, options);
129
- } else {
130
- ast = parseMarkdown(children);
353
+ const markdownToParse = applyBeforeParsePlugins(children, plugins);
354
+ const parserOptions = normalizeParserOptions({
355
+ gfm: parserOptionGfm,
356
+ math: parserOptionMath,
357
+ });
358
+ let parsedAst = sourceAst
359
+ ? cloneMarkdownNode(sourceAst)
360
+ : getCachedParsedAst(markdownToParse, parserOptions);
361
+ parsedAst = applyAfterParsePlugins(parsedAst, plugins);
362
+
363
+ let ast = parsedAst;
364
+ if (astTransform) {
365
+ try {
366
+ const nextAst = astTransform(parsedAst);
367
+ if (isMarkdownNode(nextAst)) {
368
+ ast = nextAst;
369
+ }
370
+ } catch (error) {
371
+ warnInDev(
372
+ "[react-native-nitro-markdown] astTransform threw; falling back to parsed AST.",
373
+ error,
374
+ );
375
+ ast = parsedAst;
376
+ }
131
377
  }
132
378
 
133
379
  return {
134
380
  ast,
135
- text: getFlattenedText(ast),
136
381
  };
137
382
  } catch {
138
383
  return {
139
384
  ast: null,
140
- text: "",
141
385
  };
142
386
  }
143
- }, [children, options]);
387
+ }, [
388
+ children,
389
+ parserOptionGfm,
390
+ parserOptionMath,
391
+ plugins,
392
+ sourceAst,
393
+ astTransform,
394
+ ]);
144
395
 
145
396
  useEffect(() => {
146
397
  onParsingInProgress?.();
147
- }, [children, options, onParsingInProgress]);
398
+ }, [
399
+ children,
400
+ parserOptionGfm,
401
+ parserOptionMath,
402
+ plugins,
403
+ onParsingInProgress,
404
+ ]);
148
405
 
149
406
  useEffect(() => {
150
- if (!parseResult.ast) return;
407
+ if (!parseResult.ast || !onParseComplete) return;
151
408
 
152
- onParseComplete?.({
409
+ onParseComplete({
153
410
  raw: children,
154
411
  ast: parseResult.ast,
155
- text: parseResult.text,
412
+ text: getFlattenedText(parseResult.ast),
156
413
  });
157
- }, [children, onParseComplete, parseResult.ast, parseResult.text]);
414
+ }, [children, onParseComplete, parseResult.ast]);
158
415
 
159
416
  const theme = useMemo(() => {
160
417
  const base =
@@ -165,6 +422,41 @@ export const Markdown: FC<MarkdownProps> = ({
165
422
  }, [userTheme, stylingStrategy]);
166
423
 
167
424
  const baseStyles = getBaseStyles(theme);
425
+ const contextValue = useMemo(
426
+ () => ({
427
+ renderers,
428
+ theme,
429
+ styles: nodeStyles,
430
+ stylingStrategy,
431
+ onLinkPress,
432
+ }),
433
+ [renderers, theme, nodeStyles, stylingStrategy, onLinkPress],
434
+ );
435
+
436
+ const topLevelBlocks =
437
+ parseResult.ast?.type === "document"
438
+ ? (parseResult.ast.children ?? [])
439
+ : parseResult.ast
440
+ ? [parseResult.ast]
441
+ : [];
442
+ const shouldVirtualizeBySetting =
443
+ virtualize === true ||
444
+ (virtualize === "auto" && topLevelBlocks.length >= virtualizationMinBlocks);
445
+ const shouldVirtualize =
446
+ parseResult.ast !== null && shouldVirtualizeBySetting;
447
+
448
+ const keyExtractor = useCallback((node: MarkdownNode, index: number) => {
449
+ const beg = typeof node.beg === "number" ? node.beg : index;
450
+ const end = typeof node.end === "number" ? node.end : index;
451
+ return `${node.type}:${beg}:${end}:${index}`;
452
+ }, []);
453
+
454
+ const renderVirtualizedItem = useCallback(
455
+ ({ item }: ListRenderItemInfo<MarkdownNode>): ReactElement => (
456
+ <NodeRenderer node={item} depth={0} inListItem={false} />
457
+ ),
458
+ [],
459
+ );
168
460
 
169
461
  if (!parseResult.ast) {
170
462
  return (
@@ -175,17 +467,28 @@ export const Markdown: FC<MarkdownProps> = ({
175
467
  }
176
468
 
177
469
  return (
178
- <MarkdownContext.Provider
179
- value={{
180
- renderers,
181
- theme,
182
- styles: nodeStyles,
183
- stylingStrategy,
184
- onLinkPress,
185
- }}
186
- >
470
+ <MarkdownContext.Provider value={contextValue}>
187
471
  <View style={[baseStyles.container, style]}>
188
- <NodeRenderer node={parseResult.ast} depth={0} inListItem={false} />
472
+ {shouldVirtualize ? (
473
+ <FlatList
474
+ data={topLevelBlocks}
475
+ renderItem={renderVirtualizedItem}
476
+ keyExtractor={keyExtractor}
477
+ style={baseStyles.virtualizedList}
478
+ initialNumToRender={virtualization?.initialNumToRender ?? 12}
479
+ maxToRenderPerBatch={virtualization?.maxToRenderPerBatch ?? 12}
480
+ windowSize={virtualization?.windowSize ?? 10}
481
+ updateCellsBatchingPeriod={
482
+ virtualization?.updateCellsBatchingPeriod ?? 16
483
+ }
484
+ removeClippedSubviews={
485
+ virtualization?.removeClippedSubviews ?? true
486
+ }
487
+ showsVerticalScrollIndicator={false}
488
+ />
489
+ ) : (
490
+ <NodeRenderer node={parseResult.ast} depth={0} inListItem={false} />
491
+ )}
189
492
  </View>
190
493
  </MarkdownContext.Provider>
191
494
  );
@@ -206,7 +509,7 @@ const isInline = (type: MarkdownNode["type"]): boolean => {
206
509
  );
207
510
  };
208
511
 
209
- const NodeRenderer: FC<NodeRendererProps> = ({
512
+ const NodeRendererComponent: FC<NodeRendererProps> = ({
210
513
  node,
211
514
  depth,
212
515
  inListItem,
@@ -521,6 +824,15 @@ const NodeRenderer: FC<NodeRendererProps> = ({
521
824
  }
522
825
  };
523
826
 
827
+ const NodeRenderer = memo(NodeRendererComponent, (previousProps, nextProps) => {
828
+ return (
829
+ previousProps.node === nextProps.node &&
830
+ previousProps.depth === nextProps.depth &&
831
+ previousProps.inListItem === nextProps.inListItem &&
832
+ previousProps.parentIsText === nextProps.parentIsText
833
+ );
834
+ }) as FC<NodeRendererProps>;
835
+
524
836
  type BaseStyles = ReturnType<typeof createBaseStyles>;
525
837
 
526
838
  const getBaseStyles = (theme: MarkdownTheme): BaseStyles => {
@@ -537,6 +849,9 @@ const createBaseStyles = (theme: MarkdownTheme) =>
537
849
  container: {
538
850
  flex: 1,
539
851
  },
852
+ virtualizedList: {
853
+ flex: 1,
854
+ },
540
855
  document: {
541
856
  flex: 1,
542
857
  },
@@ -70,7 +70,11 @@ export const CodeBlock: FC<CodeBlockProps> = ({
70
70
  {showLanguage ? (
71
71
  <Text style={styles.codeLanguage}>{language}</Text>
72
72
  ) : null}
73
- <ScrollView horizontal showsHorizontalScrollIndicator={false}>
73
+ <ScrollView
74
+ horizontal
75
+ showsHorizontalScrollIndicator={false}
76
+ bounces={false}
77
+ >
74
78
  <Text style={styles.codeBlockText}>{displayContent}</Text>
75
79
  </ScrollView>
76
80
  </View>