react-native-nitro-markdown 0.7.1 → 0.7.2

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 (60) hide show
  1. package/README.md +114 -24
  2. package/lib/commonjs/MarkdownContext.js.map +1 -1
  3. package/lib/commonjs/index.js.map +1 -1
  4. package/lib/commonjs/markdown-stream.js +43 -10
  5. package/lib/commonjs/markdown-stream.js.map +1 -1
  6. package/lib/commonjs/markdown.js +2 -1
  7. package/lib/commonjs/markdown.js.map +1 -1
  8. package/lib/commonjs/renderers/image.js +9 -0
  9. package/lib/commonjs/renderers/image.js.map +1 -1
  10. package/lib/commonjs/renderers/math.js +16 -0
  11. package/lib/commonjs/renderers/math.js.map +1 -1
  12. package/lib/commonjs/use-markdown-stream.js +2 -6
  13. package/lib/commonjs/use-markdown-stream.js.map +1 -1
  14. package/lib/commonjs/utils/incremental-ast.js +45 -5
  15. package/lib/commonjs/utils/incremental-ast.js.map +1 -1
  16. package/lib/module/MarkdownContext.js.map +1 -1
  17. package/lib/module/index.js.map +1 -1
  18. package/lib/module/markdown-stream.js +44 -11
  19. package/lib/module/markdown-stream.js.map +1 -1
  20. package/lib/module/markdown.js +2 -1
  21. package/lib/module/markdown.js.map +1 -1
  22. package/lib/module/renderers/image.js +10 -1
  23. package/lib/module/renderers/image.js.map +1 -1
  24. package/lib/module/renderers/math.js +16 -0
  25. package/lib/module/renderers/math.js.map +1 -1
  26. package/lib/module/use-markdown-stream.js +2 -6
  27. package/lib/module/use-markdown-stream.js.map +1 -1
  28. package/lib/module/utils/incremental-ast.js +43 -4
  29. package/lib/module/utils/incremental-ast.js.map +1 -1
  30. package/lib/typescript/commonjs/MarkdownContext.d.ts +1 -0
  31. package/lib/typescript/commonjs/MarkdownContext.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/index.d.ts +1 -1
  33. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/markdown-stream.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/markdown.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/renderers/image.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/renderers/math.d.ts.map +1 -1
  38. package/lib/typescript/commonjs/use-markdown-stream.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/utils/incremental-ast.d.ts +1 -0
  40. package/lib/typescript/commonjs/utils/incremental-ast.d.ts.map +1 -1
  41. package/lib/typescript/module/MarkdownContext.d.ts +1 -0
  42. package/lib/typescript/module/MarkdownContext.d.ts.map +1 -1
  43. package/lib/typescript/module/index.d.ts +1 -1
  44. package/lib/typescript/module/index.d.ts.map +1 -1
  45. package/lib/typescript/module/markdown-stream.d.ts.map +1 -1
  46. package/lib/typescript/module/markdown.d.ts.map +1 -1
  47. package/lib/typescript/module/renderers/image.d.ts.map +1 -1
  48. package/lib/typescript/module/renderers/math.d.ts.map +1 -1
  49. package/lib/typescript/module/use-markdown-stream.d.ts.map +1 -1
  50. package/lib/typescript/module/utils/incremental-ast.d.ts +1 -0
  51. package/lib/typescript/module/utils/incremental-ast.d.ts.map +1 -1
  52. package/package.json +1 -1
  53. package/src/MarkdownContext.ts +2 -0
  54. package/src/index.ts +1 -0
  55. package/src/markdown-stream.tsx +53 -12
  56. package/src/markdown.tsx +7 -1
  57. package/src/renderers/image.tsx +11 -0
  58. package/src/renderers/math.tsx +18 -0
  59. package/src/use-markdown-stream.ts +2 -6
  60. package/src/utils/incremental-ast.ts +81 -4
package/src/index.ts CHANGED
@@ -48,6 +48,7 @@ export type {
48
48
  LinkPressHandler,
49
49
  MarkdownContextValue,
50
50
  CustomRendererPropsByNode,
51
+ MarkdownRenderers,
51
52
  TableOptions,
52
53
  } from "./MarkdownContext";
53
54
 
@@ -3,10 +3,12 @@ import {
3
3
  useEffect,
4
4
  useRef,
5
5
  useCallback,
6
+ useMemo,
6
7
  startTransition,
7
8
  type FC,
8
9
  } from "react";
9
10
  import type { MarkdownNode } from "./headless";
11
+ import type { ParserOptions } from "./Markdown.nitro";
10
12
  import { Markdown, type MarkdownProps } from "./markdown";
11
13
  import type { MarkdownSession } from "./specs/MarkdownSession.nitro";
12
14
  import {
@@ -21,6 +23,22 @@ const normalizeOffset = (value: number): number | null => {
21
23
  return Math.floor(value);
22
24
  };
23
25
 
26
+ const normalizeParserOptions = (
27
+ options?: ParserOptions,
28
+ ): ParserOptions | undefined => {
29
+ if (!options) return undefined;
30
+
31
+ const gfm = options.gfm;
32
+ const math = options.math;
33
+ const html = options.html;
34
+
35
+ if (gfm === undefined && math === undefined && html === undefined) {
36
+ return undefined;
37
+ }
38
+
39
+ return { gfm, math, html };
40
+ };
41
+
24
42
  const resolveStreamText = ({
25
43
  forceFullSync,
26
44
  pendingFrom,
@@ -115,9 +133,21 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
115
133
  ...props
116
134
  }) => {
117
135
  const activeSession = resolveMarkdownSession(session);
136
+ const parserOptionGfm = options?.gfm;
137
+ const parserOptionMath = options?.math;
138
+ const parserOptionHtml = options?.html;
139
+ const parserOptions = useMemo(
140
+ () =>
141
+ normalizeParserOptions({
142
+ gfm: parserOptionGfm,
143
+ math: parserOptionMath,
144
+ html: parserOptionHtml,
145
+ }),
146
+ [parserOptionGfm, parserOptionMath, parserOptionHtml],
147
+ );
118
148
  const parseText = useCallback(
119
- (text: string): MarkdownNode => parseMarkdownAst(text, options),
120
- [options],
149
+ (text: string): MarkdownNode => parseMarkdownAst(text, parserOptions),
150
+ [parserOptions],
121
151
  );
122
152
  const createEmptyAst = (): MarkdownNode => ({
123
153
  type: "document",
@@ -184,13 +214,19 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
184
214
  pendingToRef.current = null;
185
215
  forceFullSyncRef.current = false;
186
216
 
187
- const latest = resolveStreamText({
188
- forceFullSync,
189
- pendingFrom,
190
- pendingTo,
191
- previousText: previousState.text,
192
- session: activeSession,
193
- });
217
+ let latest: string;
218
+ try {
219
+ latest = resolveStreamText({
220
+ forceFullSync,
221
+ pendingFrom,
222
+ pendingTo,
223
+ previousText: previousState.text,
224
+ session: activeSession,
225
+ });
226
+ } catch (error) {
227
+ warnStreamError("[NitroMarkdown] Failed to read stream session:", error);
228
+ return;
229
+ }
194
230
  if (latest === previousState.text) return;
195
231
 
196
232
  const nextAst = hasBeforeParsePlugins
@@ -198,7 +234,7 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
198
234
  : getNextStreamAst({
199
235
  allowIncremental,
200
236
  nextText: latest,
201
- options,
237
+ options: parserOptions,
202
238
  previousAst: previousState.ast,
203
239
  previousText: previousState.text,
204
240
  });
@@ -236,6 +272,8 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
236
272
 
237
273
  try {
238
274
  unsubscribe = activeSession.addListener((from, to) => {
275
+ if (!mountedRef.current) return;
276
+
239
277
  const nextFrom = normalizeOffset(from);
240
278
  const nextTo = normalizeOffset(to);
241
279
 
@@ -259,6 +297,10 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
259
297
  }
260
298
 
261
299
  return () => {
300
+ pendingUpdateRef.current = false;
301
+ pendingFromRef.current = null;
302
+ pendingToRef.current = null;
303
+ forceFullSyncRef.current = false;
262
304
  try {
263
305
  unsubscribe?.();
264
306
  } catch (error) {
@@ -279,8 +321,7 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
279
321
  }, [
280
322
  allowIncremental,
281
323
  hasBeforeParsePlugins,
282
- options,
283
- plugins,
324
+ parserOptions,
284
325
  activeSession,
285
326
  updateIntervalMs,
286
327
  updateStrategy,
package/src/markdown.tsx CHANGED
@@ -483,8 +483,14 @@ export const Markdown: FC<MarkdownProps> = ({
483
483
  math: parserOptionMath,
484
484
  html: parserOptionHtml,
485
485
  });
486
+ const shouldCloneSourceAst =
487
+ sourceAst &&
488
+ (Boolean(astTransform) ||
489
+ sortedPlugins?.some((plugin) => plugin.afterParse) === true);
486
490
  let parsedAst = sourceAst
487
- ? cloneMarkdownNode(sourceAst)
491
+ ? shouldCloneSourceAst
492
+ ? cloneMarkdownNode(sourceAst)
493
+ : sourceAst
488
494
  : parseCache
489
495
  ? getCachedParsedAst(markdownToParse, parserOptions)
490
496
  : parseWithNativeParser(markdownToParse, parserOptions);
@@ -2,6 +2,7 @@ import {
2
2
  useState,
3
3
  useEffect,
4
4
  useMemo,
5
+ useRef,
5
6
  type ReactNode,
6
7
  type FC,
7
8
  type ComponentType,
@@ -47,6 +48,7 @@ export const Image: FC<ImageProps> = ({ url, title, alt, Renderer, style }) => {
47
48
  const [loading, setLoading] = useState(true);
48
49
  const [error, setError] = useState(false);
49
50
  const [aspectRatio, setAspectRatio] = useState<number | undefined>(undefined);
51
+ const mountedRef = useRef(true);
50
52
  const { theme, imageOptions } = useMarkdownContext();
51
53
  const allowedImageHref = useMemo(
52
54
  () => getAllowedImageHref(url, imageOptions),
@@ -115,6 +117,13 @@ export const Image: FC<ImageProps> = ({ url, title, alt, Renderer, style }) => {
115
117
  [theme, aspectRatio],
116
118
  );
117
119
 
120
+ useEffect(() => {
121
+ mountedRef.current = true;
122
+ return () => {
123
+ mountedRef.current = false;
124
+ };
125
+ }, []);
126
+
118
127
  useEffect(() => {
119
128
  let isMounted = true;
120
129
  setLoading(true);
@@ -236,9 +245,11 @@ export const Image: FC<ImageProps> = ({ url, title, alt, Renderer, style }) => {
236
245
  accessibilityRole={accessibilityLabel ? "image" : undefined}
237
246
  accessibilityLabel={accessibilityLabel}
238
247
  onLoad={() => {
248
+ if (!mountedRef.current) return;
239
249
  setLoading(false);
240
250
  }}
241
251
  onError={() => {
252
+ if (!mountedRef.current) return;
242
253
  setLoading(false);
243
254
  setError(true);
244
255
  }}
@@ -247,6 +247,14 @@ export const MathInline: FC<MathInlineProps> = ({ content, style }) => {
247
247
  const { theme } = useMarkdownContext();
248
248
  const styles = getCachedStyles(mathStylesCache, theme, createMathStyles);
249
249
  const [hasRenderError, setHasRenderError] = useState(false);
250
+ const mountedRef = useRef(true);
251
+
252
+ useEffect(() => {
253
+ mountedRef.current = true;
254
+ return () => {
255
+ mountedRef.current = false;
256
+ };
257
+ }, []);
250
258
 
251
259
  if (!content) return null;
252
260
 
@@ -260,6 +268,7 @@ export const MathInline: FC<MathInlineProps> = ({ content, style }) => {
260
268
  color={theme.colors.text}
261
269
  style={styles.ratexInline}
262
270
  onError={() => {
271
+ if (!mountedRef.current) return;
263
272
  setHasRenderError(true);
264
273
  }}
265
274
  />
@@ -283,6 +292,14 @@ export const MathBlock: FC<MathBlockProps> = ({ content, style }) => {
283
292
  const { theme } = useMarkdownContext();
284
293
  const styles = getCachedStyles(mathStylesCache, theme, createMathStyles);
285
294
  const [hasRenderError, setHasRenderError] = useState(false);
295
+ const mountedRef = useRef(true);
296
+
297
+ useEffect(() => {
298
+ mountedRef.current = true;
299
+ return () => {
300
+ mountedRef.current = false;
301
+ };
302
+ }, []);
286
303
 
287
304
  if (!content) return null;
288
305
 
@@ -300,6 +317,7 @@ export const MathBlock: FC<MathBlockProps> = ({ content, style }) => {
300
317
  color={theme.colors.text}
301
318
  style={styles.ratexBlock}
302
319
  onError={() => {
320
+ if (!mountedRef.current) return;
303
321
  setHasRenderError(true);
304
322
  }}
305
323
  />
@@ -21,13 +21,9 @@ export function useMarkdownSession(initialText?: string) {
21
21
  const session = sessionRef.current!;
22
22
  return () => {
23
23
  try {
24
- session.clear();
24
+ session.dispose();
25
25
  } finally {
26
- try {
27
- session.dispose();
28
- } finally {
29
- sessionRef.current = null;
30
- }
26
+ sessionRef.current = null;
31
27
  }
32
28
  };
33
29
  }, []);
@@ -152,6 +152,83 @@ const endsAtBlockBoundary = (text: string): boolean => {
152
152
  return text.endsWith("\n") || text.endsWith("\r");
153
153
  };
154
154
 
155
+ const nodesHaveMatchingMetadata = (
156
+ previousNode: MarkdownNode,
157
+ nextNode: MarkdownNode,
158
+ ): boolean => {
159
+ return (
160
+ previousNode.type === nextNode.type &&
161
+ previousNode.content === nextNode.content &&
162
+ previousNode.level === nextNode.level &&
163
+ previousNode.href === nextNode.href &&
164
+ previousNode.title === nextNode.title &&
165
+ previousNode.alt === nextNode.alt &&
166
+ previousNode.language === nextNode.language &&
167
+ previousNode.ordered === nextNode.ordered &&
168
+ previousNode.start === nextNode.start &&
169
+ previousNode.checked === nextNode.checked &&
170
+ previousNode.isHeader === nextNode.isHeader &&
171
+ previousNode.align === nextNode.align &&
172
+ previousNode.beg === nextNode.beg &&
173
+ previousNode.end === nextNode.end
174
+ );
175
+ };
176
+
177
+ export const reuseStableAstNodes = (
178
+ previousNode: MarkdownNode,
179
+ nextNode: MarkdownNode,
180
+ ): MarkdownNode => {
181
+ if (previousNode.type !== nextNode.type) {
182
+ return nextNode;
183
+ }
184
+
185
+ const hasMatchingMetadata = nodesHaveMatchingMetadata(previousNode, nextNode);
186
+ const previousChildren = previousNode.children;
187
+ const nextChildren = nextNode.children;
188
+
189
+ if (!previousChildren || !nextChildren) {
190
+ return hasMatchingMetadata && previousChildren === nextChildren
191
+ ? previousNode
192
+ : nextNode;
193
+ }
194
+
195
+ if (previousChildren.length !== nextChildren.length) {
196
+ return {
197
+ ...nextNode,
198
+ children: nextChildren.map((nextChild, index) => {
199
+ const previousChild = previousChildren[index];
200
+ return previousChild
201
+ ? reuseStableAstNodes(previousChild, nextChild)
202
+ : nextChild;
203
+ }),
204
+ };
205
+ }
206
+
207
+ let hasChildChange = false;
208
+ const children = nextChildren.map((nextChild, index) => {
209
+ const child = reuseStableAstNodes(previousChildren[index], nextChild);
210
+ hasChildChange ||= child !== previousChildren[index];
211
+ return child;
212
+ });
213
+
214
+ if (hasMatchingMetadata && !hasChildChange) {
215
+ return previousNode;
216
+ }
217
+
218
+ return {
219
+ ...nextNode,
220
+ children,
221
+ };
222
+ };
223
+
224
+ const parseAstWithStableNodes = (
225
+ previousAst: MarkdownNode,
226
+ text: string,
227
+ options?: ParserOptions,
228
+ ): MarkdownNode => {
229
+ return reuseStableAstNodes(previousAst, parseAst(text, options));
230
+ };
231
+
155
232
  export type IncrementalAstInput = {
156
233
  allowIncremental?: boolean;
157
234
  nextText: string;
@@ -168,7 +245,7 @@ export const getNextStreamAst = ({
168
245
  previousText,
169
246
  }: IncrementalAstInput): MarkdownNode => {
170
247
  if (!allowIncremental || !nextText.startsWith(previousText)) {
171
- return parseAst(nextText, options);
248
+ return parseAstWithStableNodes(previousAst, nextText, options);
172
249
  }
173
250
 
174
251
  const appendedChunk = nextText.slice(previousText.length);
@@ -194,7 +271,7 @@ export const getNextStreamAst = ({
194
271
 
195
272
  if (!PLAIN_TEXT_APPEND_PATTERN.test(appendedChunk)) {
196
273
  if (endsAtBlockBoundary(previousText)) {
197
- return parseAst(nextText, options);
274
+ return parseAstWithStableNodes(previousAst, nextText, options);
198
275
  }
199
276
 
200
277
  const textAppendedAst = appendPlainTextToAst(
@@ -208,12 +285,12 @@ export const getNextStreamAst = ({
208
285
  }
209
286
 
210
287
  if (insideFencedCodeBlock) {
211
- return parseAst(nextText, options);
288
+ return parseAstWithStableNodes(previousAst, nextText, options);
212
289
  }
213
290
 
214
291
  // Correctness-first fallback: full reparse for all non-trivial appends.
215
292
  // Incremental append is only used for plain text chunks at the true trailing leaf.
216
- return parseAst(nextText, options);
293
+ return parseAstWithStableNodes(previousAst, nextText, options);
217
294
  };
218
295
 
219
296
  export const parseMarkdownAst = (