react-native-nitro-markdown 0.7.0 → 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 (73) hide show
  1. package/README.md +188 -488
  2. package/lib/commonjs/MarkdownContext.js.map +1 -1
  3. package/lib/commonjs/MarkdownSession.js +6 -2
  4. package/lib/commonjs/MarkdownSession.js.map +1 -1
  5. package/lib/commonjs/index.js.map +1 -1
  6. package/lib/commonjs/markdown-stream.js +49 -14
  7. package/lib/commonjs/markdown-stream.js.map +1 -1
  8. package/lib/commonjs/markdown.js +2 -1
  9. package/lib/commonjs/markdown.js.map +1 -1
  10. package/lib/commonjs/renderers/image.js +9 -0
  11. package/lib/commonjs/renderers/image.js.map +1 -1
  12. package/lib/commonjs/renderers/math.js +16 -0
  13. package/lib/commonjs/renderers/math.js.map +1 -1
  14. package/lib/commonjs/use-markdown-stream.js +25 -10
  15. package/lib/commonjs/use-markdown-stream.js.map +1 -1
  16. package/lib/commonjs/utils/incremental-ast.js +45 -5
  17. package/lib/commonjs/utils/incremental-ast.js.map +1 -1
  18. package/lib/module/MarkdownContext.js.map +1 -1
  19. package/lib/module/MarkdownSession.js +6 -2
  20. package/lib/module/MarkdownSession.js.map +1 -1
  21. package/lib/module/index.js.map +1 -1
  22. package/lib/module/markdown-stream.js +50 -15
  23. package/lib/module/markdown-stream.js.map +1 -1
  24. package/lib/module/markdown.js +2 -1
  25. package/lib/module/markdown.js.map +1 -1
  26. package/lib/module/renderers/image.js +10 -1
  27. package/lib/module/renderers/image.js.map +1 -1
  28. package/lib/module/renderers/math.js +16 -0
  29. package/lib/module/renderers/math.js.map +1 -1
  30. package/lib/module/use-markdown-stream.js +24 -11
  31. package/lib/module/use-markdown-stream.js.map +1 -1
  32. package/lib/module/utils/incremental-ast.js +43 -4
  33. package/lib/module/utils/incremental-ast.js.map +1 -1
  34. package/lib/typescript/commonjs/MarkdownContext.d.ts +1 -0
  35. package/lib/typescript/commonjs/MarkdownContext.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/MarkdownSession.d.ts +1 -1
  37. package/lib/typescript/commonjs/MarkdownSession.d.ts.map +1 -1
  38. package/lib/typescript/commonjs/index.d.ts +2 -1
  39. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  40. package/lib/typescript/commonjs/markdown-stream.d.ts +2 -1
  41. package/lib/typescript/commonjs/markdown-stream.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/markdown.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/renderers/image.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/renderers/math.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/use-markdown-stream.d.ts +4 -1
  46. package/lib/typescript/commonjs/use-markdown-stream.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/utils/incremental-ast.d.ts +1 -0
  48. package/lib/typescript/commonjs/utils/incremental-ast.d.ts.map +1 -1
  49. package/lib/typescript/module/MarkdownContext.d.ts +1 -0
  50. package/lib/typescript/module/MarkdownContext.d.ts.map +1 -1
  51. package/lib/typescript/module/MarkdownSession.d.ts +1 -1
  52. package/lib/typescript/module/MarkdownSession.d.ts.map +1 -1
  53. package/lib/typescript/module/index.d.ts +2 -1
  54. package/lib/typescript/module/index.d.ts.map +1 -1
  55. package/lib/typescript/module/markdown-stream.d.ts +2 -1
  56. package/lib/typescript/module/markdown-stream.d.ts.map +1 -1
  57. package/lib/typescript/module/markdown.d.ts.map +1 -1
  58. package/lib/typescript/module/renderers/image.d.ts.map +1 -1
  59. package/lib/typescript/module/renderers/math.d.ts.map +1 -1
  60. package/lib/typescript/module/use-markdown-stream.d.ts +4 -1
  61. package/lib/typescript/module/use-markdown-stream.d.ts.map +1 -1
  62. package/lib/typescript/module/utils/incremental-ast.d.ts +1 -0
  63. package/lib/typescript/module/utils/incremental-ast.d.ts.map +1 -1
  64. package/package.json +2 -1
  65. package/src/MarkdownContext.ts +2 -0
  66. package/src/MarkdownSession.ts +9 -2
  67. package/src/index.ts +2 -0
  68. package/src/markdown-stream.tsx +64 -18
  69. package/src/markdown.tsx +7 -1
  70. package/src/renderers/image.tsx +11 -0
  71. package/src/renderers/math.tsx +18 -0
  72. package/src/use-markdown-stream.ts +55 -19
  73. package/src/utils/incremental-ast.ts +81 -4
@@ -1,6 +1,6 @@
1
1
  import { createMarkdownSession } from "./MarkdownSession";
2
2
  export type MarkdownSession = ReturnType<typeof createMarkdownSession>;
3
- export declare function useMarkdownSession(): {
3
+ export declare function useMarkdownSession(initialText?: string): {
4
4
  getSession: () => import("./specs/MarkdownSession.nitro").MarkdownSession;
5
5
  isStreaming: boolean;
6
6
  setIsStreaming: import("react").Dispatch<import("react").SetStateAction<boolean>>;
@@ -10,6 +10,9 @@ export declare function useMarkdownSession(): {
10
10
  reset: (text: string) => void;
11
11
  replace: (from: number, to: number, text: string) => number;
12
12
  };
13
+ export type MarkdownSessionController = ReturnType<typeof useMarkdownSession>;
14
+ export declare function isMarkdownSessionController(value: MarkdownSession | MarkdownSessionController): value is MarkdownSessionController;
15
+ export declare function resolveMarkdownSession(session: MarkdownSession | MarkdownSessionController): MarkdownSession;
13
16
  export declare function useStream(timestamps?: Record<number, number>): {
14
17
  isPlaying: boolean;
15
18
  setIsPlaying: import("react").Dispatch<import("react").SetStateAction<boolean>>;
@@ -1 +1 @@
1
- {"version":3,"file":"use-markdown-stream.d.ts","sourceRoot":"","sources":["../../../src/use-markdown-stream.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAO1D,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAEvE,wBAAgB,kBAAkB;;;;;;6BAiCY,MAAM;kBAMjB,MAAM;oBAIJ,MAAM,MAAM,MAAM,QAAQ,MAAM;EAcpE;AAED,wBAAgB,SAAS,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;;;0BAgBzC,MAAM;;;;;;6BA1CoB,MAAM;kBAMjB,MAAM;oBAIJ,MAAM,MAAM,MAAM,QAAQ,MAAM;EAqDpE"}
1
+ {"version":3,"file":"use-markdown-stream.d.ts","sourceRoot":"","sources":["../../../src/use-markdown-stream.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAO1D,MAAM,MAAM,eAAe,GAAG,UAAU,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAEvE,wBAAgB,kBAAkB,CAAC,WAAW,CAAC,EAAE,MAAM;;;;;;6BAuCT,MAAM;kBAMjB,MAAM;oBAIJ,MAAM,MAAM,MAAM,QAAQ,MAAM;EA0BpE;AAED,MAAM,MAAM,yBAAyB,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9E,wBAAgB,2BAA2B,CACzC,KAAK,EAAE,eAAe,GAAG,yBAAyB,GACjD,KAAK,IAAI,yBAAyB,CAEpC;AAED,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,eAAe,GAAG,yBAAyB,GACnD,eAAe,CAMjB;AAED,wBAAgB,SAAS,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;;;0BAgBzC,MAAM;;;;;;6BAxEoB,MAAM;kBAMjB,MAAM;oBAIJ,MAAM,MAAM,MAAM,QAAQ,MAAM;EAmFpE"}
@@ -1,5 +1,6 @@
1
1
  import { type MarkdownNode } from "../headless";
2
2
  import type { ParserOptions } from "../Markdown.nitro";
3
+ export declare const reuseStableAstNodes: (previousNode: MarkdownNode, nextNode: MarkdownNode) => MarkdownNode;
3
4
  export type IncrementalAstInput = {
4
5
  allowIncremental?: boolean;
5
6
  nextText: string;
@@ -1 +1 @@
1
- {"version":3,"file":"incremental-ast.d.ts","sourceRoot":"","sources":["../../../../src/utils/incremental-ast.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,YAAY,EAClB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAqJvD,MAAM,MAAM,mBAAmB,GAAG;IAChC,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,WAAW,EAAE,YAAY,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAAI,qEAM9B,mBAAmB,KAAG,YAgDxB,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAC3B,MAAM,MAAM,EACZ,UAAU,aAAa,KACtB,YAEF,CAAC"}
1
+ {"version":3,"file":"incremental-ast.d.ts","sourceRoot":"","sources":["../../../../src/utils/incremental-ast.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,YAAY,EAClB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AA2KvD,eAAO,MAAM,mBAAmB,GAC9B,cAAc,YAAY,EAC1B,UAAU,YAAY,KACrB,YA0CF,CAAC;AAUF,MAAM,MAAM,mBAAmB,GAAG;IAChC,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,WAAW,EAAE,YAAY,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAAI,qEAM9B,mBAAmB,KAAG,YAgDxB,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAC3B,MAAM,MAAM,EACZ,UAAU,aAAa,KACtB,YAEF,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-nitro-markdown",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "High-performance Markdown parser for React Native using Nitro Modules and md4c",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -57,6 +57,7 @@
57
57
  "clean": "rimraf lib nitrogen/generated",
58
58
  "codegen": "nitrogen --logLevel=\"debug\"",
59
59
  "lint": "bunx eslint src --max-warnings=0 --ignore-pattern 'src/**/__tests__/**' --ignore-pattern 'src/**/*.nitro.ts'",
60
+ "lint:fix": "bunx eslint src --fix --ignore-pattern 'src/**/__tests__/**' --ignore-pattern 'src/**/*.nitro.ts'",
60
61
  "typecheck": "tsc --noEmit",
61
62
  "test": "jest",
62
63
  "test:coverage": "jest --coverage",
@@ -123,6 +123,8 @@ export type CustomRenderers = Partial<{
123
123
  >;
124
124
  }>;
125
125
 
126
+ export type MarkdownRenderers = CustomRenderers;
127
+
126
128
  export type TableOptions = {
127
129
  minColumnWidth?: number;
128
130
  measurementStabilizeMs?: number;
@@ -3,6 +3,13 @@ import type { MarkdownSession as MarkdownSessionSpec } from "./specs/MarkdownSes
3
3
 
4
4
  export type MarkdownSession = MarkdownSessionSpec;
5
5
 
6
- export function createMarkdownSession(): MarkdownSession {
7
- return NitroModules.createHybridObject<MarkdownSession>("MarkdownSession");
6
+ export function createMarkdownSession(initialText?: string): MarkdownSession {
7
+ const session =
8
+ NitroModules.createHybridObject<MarkdownSession>("MarkdownSession");
9
+
10
+ if (initialText !== undefined) {
11
+ session.reset(initialText);
12
+ }
13
+
14
+ return session;
8
15
  }
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
 
@@ -77,6 +78,7 @@ export { MathInline, MathBlock } from "./renderers/math";
77
78
  export { createMarkdownSession } from "./MarkdownSession";
78
79
  export type { MarkdownSession } from "./MarkdownSession";
79
80
  export { useMarkdownSession, useStream } from "./use-markdown-stream";
81
+ export type { MarkdownSessionController } from "./use-markdown-stream";
80
82
 
81
83
  export type {
82
84
  HighlightedToken,
@@ -3,12 +3,18 @@ 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";
14
+ import {
15
+ resolveMarkdownSession,
16
+ type MarkdownSessionController,
17
+ } from "./use-markdown-stream";
12
18
  import { getNextStreamAst, parseMarkdownAst } from "./utils/incremental-ast";
13
19
 
14
20
  const normalizeOffset = (value: number): number | null => {
@@ -17,6 +23,22 @@ const normalizeOffset = (value: number): number | null => {
17
23
  return Math.floor(value);
18
24
  };
19
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
+
20
42
  const resolveStreamText = ({
21
43
  forceFullSync,
22
44
  pendingFrom,
@@ -71,7 +93,7 @@ export type MarkdownStreamProps = {
71
93
  /**
72
94
  * The active MarkdownSession to stream content from.
73
95
  */
74
- session: MarkdownSession;
96
+ session: MarkdownSession | MarkdownSessionController;
75
97
  /**
76
98
  * Throttle UI updates when updateStrategy is "interval".
77
99
  * Ignored when updateStrategy is "raf".
@@ -110,15 +132,28 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
110
132
  plugins,
111
133
  ...props
112
134
  }) => {
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
+ );
113
148
  const parseText = useCallback(
114
- (text: string): MarkdownNode => parseMarkdownAst(text, options),
115
- [options],
149
+ (text: string): MarkdownNode => parseMarkdownAst(text, parserOptions),
150
+ [parserOptions],
116
151
  );
117
152
  const createEmptyAst = (): MarkdownNode => ({
118
153
  type: "document",
119
154
  children: [],
120
155
  });
121
- const initialText = session.getAllText();
156
+ const initialText = activeSession.getAllText();
122
157
  const hasBeforeParsePlugins =
123
158
  plugins?.some((plugin) => typeof plugin.beforeParse === "function") ??
124
159
  false;
@@ -148,7 +183,7 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
148
183
  }, []);
149
184
 
150
185
  useEffect(() => {
151
- const initialText = session.getAllText();
186
+ const initialText = activeSession.getAllText();
152
187
  const initialState = {
153
188
  text: initialText,
154
189
  ast: hasBeforeParsePlugins ? createEmptyAst() : parseText(initialText),
@@ -159,7 +194,7 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
159
194
  forceFullSyncRef.current = false;
160
195
  renderStateRef.current = initialState;
161
196
  setRenderState(initialState);
162
- }, [hasBeforeParsePlugins, parseText, session]);
197
+ }, [activeSession, hasBeforeParsePlugins, parseText]);
163
198
 
164
199
  useEffect(() => {
165
200
  const flushUpdate = () => {
@@ -179,13 +214,19 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
179
214
  pendingToRef.current = null;
180
215
  forceFullSyncRef.current = false;
181
216
 
182
- const latest = resolveStreamText({
183
- forceFullSync,
184
- pendingFrom,
185
- pendingTo,
186
- previousText: previousState.text,
187
- session,
188
- });
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
+ }
189
230
  if (latest === previousState.text) return;
190
231
 
191
232
  const nextAst = hasBeforeParsePlugins
@@ -193,7 +234,7 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
193
234
  : getNextStreamAst({
194
235
  allowIncremental,
195
236
  nextText: latest,
196
- options,
237
+ options: parserOptions,
197
238
  previousAst: previousState.ast,
198
239
  previousText: previousState.text,
199
240
  });
@@ -230,7 +271,9 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
230
271
  let unsubscribe: (() => void) | null = null;
231
272
 
232
273
  try {
233
- unsubscribe = session.addListener((from, to) => {
274
+ unsubscribe = activeSession.addListener((from, to) => {
275
+ if (!mountedRef.current) return;
276
+
234
277
  const nextFrom = normalizeOffset(from);
235
278
  const nextTo = normalizeOffset(to);
236
279
 
@@ -254,6 +297,10 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
254
297
  }
255
298
 
256
299
  return () => {
300
+ pendingUpdateRef.current = false;
301
+ pendingFromRef.current = null;
302
+ pendingToRef.current = null;
303
+ forceFullSyncRef.current = false;
257
304
  try {
258
305
  unsubscribe?.();
259
306
  } catch (error) {
@@ -274,9 +321,8 @@ export const MarkdownStream: FC<MarkdownStreamProps> = ({
274
321
  }, [
275
322
  allowIncremental,
276
323
  hasBeforeParsePlugins,
277
- options,
278
- plugins,
279
- session,
324
+ parserOptions,
325
+ activeSession,
280
326
  updateIntervalMs,
281
327
  updateStrategy,
282
328
  useTransitionUpdates,
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
  />
@@ -1,4 +1,4 @@
1
- import { useRef, useCallback, useState, useEffect } from "react";
1
+ import { useRef, useCallback, useState, useEffect, useMemo } from "react";
2
2
  import { createMarkdownSession } from "./MarkdownSession";
3
3
  import {
4
4
  createTimestampTimeline,
@@ -8,10 +8,11 @@ import {
8
8
 
9
9
  export type MarkdownSession = ReturnType<typeof createMarkdownSession>;
10
10
 
11
- export function useMarkdownSession() {
11
+ export function useMarkdownSession(initialText?: string) {
12
12
  const sessionRef = useRef<MarkdownSession | null>(null);
13
+ const initialTextRef = useRef(initialText);
13
14
  if (sessionRef.current === null) {
14
- sessionRef.current = createMarkdownSession();
15
+ sessionRef.current = createMarkdownSession(initialText);
15
16
  }
16
17
 
17
18
  const [isStreaming, setIsStreaming] = useState(false);
@@ -20,17 +21,22 @@ export function useMarkdownSession() {
20
21
  const session = sessionRef.current!;
21
22
  return () => {
22
23
  try {
23
- session.clear();
24
+ session.dispose();
24
25
  } finally {
25
- try {
26
- session.dispose();
27
- } finally {
28
- sessionRef.current = null;
29
- }
26
+ sessionRef.current = null;
30
27
  }
31
28
  };
32
29
  }, []);
33
30
 
31
+ useEffect(() => {
32
+ if (initialText === undefined || initialTextRef.current === initialText) {
33
+ return;
34
+ }
35
+
36
+ initialTextRef.current = initialText;
37
+ sessionRef.current!.reset(initialText);
38
+ }, [initialText]);
39
+
34
40
  const stop = useCallback(() => {
35
41
  setIsStreaming(false);
36
42
  }, []);
@@ -55,16 +61,46 @@ export function useMarkdownSession() {
55
61
  return sessionRef.current!.replace(from, to, text);
56
62
  }, []);
57
63
 
58
- return {
59
- getSession,
60
- isStreaming,
61
- setIsStreaming,
62
- stop,
63
- clear,
64
- setHighlight,
65
- reset,
66
- replace,
67
- };
64
+ return useMemo(
65
+ () => ({
66
+ getSession,
67
+ isStreaming,
68
+ setIsStreaming,
69
+ stop,
70
+ clear,
71
+ setHighlight,
72
+ reset,
73
+ replace,
74
+ }),
75
+ [
76
+ clear,
77
+ getSession,
78
+ isStreaming,
79
+ replace,
80
+ reset,
81
+ setHighlight,
82
+ setIsStreaming,
83
+ stop,
84
+ ],
85
+ );
86
+ }
87
+
88
+ export type MarkdownSessionController = ReturnType<typeof useMarkdownSession>;
89
+
90
+ export function isMarkdownSessionController(
91
+ value: MarkdownSession | MarkdownSessionController,
92
+ ): value is MarkdownSessionController {
93
+ return typeof Reflect.get(value, "getSession") === "function";
94
+ }
95
+
96
+ export function resolveMarkdownSession(
97
+ session: MarkdownSession | MarkdownSessionController,
98
+ ): MarkdownSession {
99
+ if (isMarkdownSessionController(session)) {
100
+ return session.getSession();
101
+ }
102
+
103
+ return session;
68
104
  }
69
105
 
70
106
  export function useStream(timestamps?: Record<number, number>) {
@@ -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 = (