diff --git a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx
index 0ae9a0f7b178..cbc9aff5f6c5 100644
--- a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx
+++ b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx
@@ -60,11 +60,8 @@ function MarkdownTextPrimitiveChild({
() => [true, flattenedStyle],
[flattenedStyle],
);
- let childPosition = 0;
- const nativeChildren = React.Children.toArray(children).map((child) => {
- const position = childPosition;
- childPosition += 1;
-
+ // Children.map keeps each text child's position key while its text grows.
+ const nativeChildren = React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
return child;
}
@@ -75,12 +72,7 @@ function MarkdownTextPrimitiveChild({
const text = child.toString();
return (
// @ts-expect-error The generated run props do not include inherited Text props.
-
+
);
});
diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx
index 348a3c489a2c..d9f1da9c2cd1 100644
--- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx
+++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx
@@ -4,7 +4,11 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless";
import { CopyTextButton } from "./CopyTextButton";
import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
-import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText";
+import {
+ nativeMarkdownDocumentRuns,
+ nativeMarkdownListItemBlocks,
+ nativeMarkdownNodeKey,
+} from "./nativeMarkdownText";
import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";
import type {
MarkdownCodeHighlighter,
@@ -28,10 +32,6 @@ const MONO_FONT_FAMILY = Platform.select({
default: "monospace",
});
-function nodeKey(node: MarkdownNode, index: number): string {
- return `${node.type}:${node.beg ?? index}:${node.end ?? index}`;
-}
-
/** Code inside markdown scales with the base text size (12pt at the default 15pt body). */
function codeBlockFontSize(textStyle: NativeMarkdownTextStyle): number {
return Math.max(10, Math.round(textStyle.fontSize * 0.8));
@@ -193,14 +193,14 @@ function HighlightedCodeText(props: {
const tokens = line.map((token) => {
const start = sourceOffset;
sourceOffset += token.content.length;
- const signature = `${start}:${token.content}:${token.color ?? ""}:${token.fontStyle ?? ""}`;
+ const signature = `${start}:${token.color ?? ""}:${token.fontStyle ?? ""}`;
const occurrence = keyOccurrences.get(signature) ?? 0;
keyOccurrences.set(signature, occurrence + 1);
return { key: `${signature}:${occurrence}`, token };
});
sourceOffset += 1;
return {
- key: `line:${lineStart}:${line.map((token) => token.content).join("")}`,
+ key: `line:${lineStart}`,
tokens,
};
});
@@ -353,7 +353,7 @@ function NativeTable(props: {
>
{rows.map((row, rowIndex) => (
{(row.children ?? []).map((cell, cellIndex) => (
child.type === "image" ? (
) : (
{nativeMarkdownListItemBlocks(item).map((child, childIndex) => (
{(props.node.children ?? []).map((child, index) => (
{(props.node.children ?? []).map((child, index) => (
{(props.node.children ?? []).map((child, index) => (
();
const prefixedExternalLinks = new Set();
- const keyedRuns = props.runs.map((run) => {
- const signature = runKeySignature(run);
- const occurrence = occurrences.get(signature) ?? 0;
- occurrences.set(signature, occurrence + 1);
-
+ const keyedRuns = props.runs.map((run, index) => {
let text = run.text;
if (run.fileIcon && Platform.OS === "ios") {
text = `${INLINE_ATTACHMENT_PREFIX}${text}`;
@@ -210,7 +183,7 @@ export function NativeMarkdownSelectableText(props: {
text = `${EXTERNAL_LINK_PREFIX}${text}`;
}
- return { key: `${signature}:${occurrence}`, run, text };
+ return { key: nativeMarkdownTextRunKey(run, index), run, text };
});
// T3MarkdownText only rebuilds its attributed string during native layout. A
// color-only child update can otherwise leave the previous appearance cached.
diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
index 2b39ac201599..822d5a095170 100644
--- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
+++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
@@ -36,6 +36,34 @@ export interface NativeMarkdownTextRun {
readonly paragraphSpacing?: number;
}
+/** Keep text updates in the same run. Attribute changes still replace the run. */
+export function nativeMarkdownTextRunKey(run: NativeMarkdownTextRun, index: number): string {
+ return [
+ index,
+ run.bold,
+ run.italic,
+ run.strikethrough,
+ run.code,
+ run.href,
+ run.externalHost,
+ run.fileIcon,
+ run.skillName,
+ run.skillLabel,
+ run.role,
+ run.headingLevel,
+ run.depth,
+ run.spacing,
+ run.firstLineHeadIndent,
+ run.headIndent,
+ run.paragraphSpacing,
+ ].join(":");
+}
+
+/** The block's end moves during streaming. Its type and start identify the block. */
+export function nativeMarkdownNodeKey(node: MarkdownNode, index: number): string {
+ return `${node.type}:${node.beg ?? `index:${index}`}`;
+}
+
export type NativeMarkdownDocumentChunk =
| {
readonly kind: "selectable";
@@ -698,16 +726,16 @@ export function nativeMarkdownDocumentChunks(
): ReadonlyArray {
const chunks: NativeMarkdownDocumentChunk[] = [];
let selectableNodes: MarkdownNode[] = [];
+ let selectableStartIndex = 0;
const flushSelectable = () => {
- if (selectableNodes.length === 0) {
+ const first = selectableNodes[0];
+ if (!first) {
return;
}
- const first = selectableNodes[0];
- const last = selectableNodes.at(-1);
chunks.push({
kind: "selectable",
- key: `selectable:${first?.beg ?? "start"}:${last?.end ?? "end"}`,
+ key: `selectable:${nativeMarkdownNodeKey(first, selectableStartIndex)}`,
node: {
type: "document",
children: selectableNodes,
@@ -718,6 +746,9 @@ export function nativeMarkdownDocumentChunks(
for (const [index, child] of (document.children ?? []).entries()) {
if (!containsRichBlock(child)) {
+ if (selectableNodes.length === 0) {
+ selectableStartIndex = index;
+ }
selectableNodes.push(child);
continue;
}
@@ -725,7 +756,7 @@ export function nativeMarkdownDocumentChunks(
flushSelectable();
chunks.push({
kind: "rich",
- key: `rich:${child.type}:${child.beg ?? index}:${child.end ?? index}`,
+ key: `rich:${nativeMarkdownNodeKey(child, index)}`,
node: child,
});
}
diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts
index 1e7cb5f3164e..0274ee08964f 100644
--- a/apps/mobile/src/lib/nativeMarkdownText.test.ts
+++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts
@@ -6,11 +6,42 @@ import {
nativeMarkdownDocumentChunks,
nativeMarkdownDocumentRuns,
nativeMarkdownListItemBlocks,
+ nativeMarkdownTextRunKey,
nativeMarkdownTextRuns,
nativeMarkdownWithPreservedSoftBreaks,
} from "@t3tools/mobile-markdown-text/markdown";
describe("nativeMarkdownTextRuns", () => {
+ it("keeps distinct run identities when the streaming tail grows", () => {
+ const before = nativeMarkdownTextRuns({
+ type: "paragraph",
+ children: [
+ { type: "text", content: "Before " },
+ { type: "bold", children: [{ type: "text", content: "bold" }] },
+ { type: "text", content: " and after" },
+ ],
+ });
+ const after = before.map((run, index) =>
+ index === before.length - 1 ? { ...run, text: `${run.text} more text` } : run,
+ );
+ const beforeKeys = before.map(nativeMarkdownTextRunKey);
+ const afterKeys = after.map(nativeMarkdownTextRunKey);
+
+ expect(afterKeys).toEqual(beforeKeys);
+ expect(new Set(afterKeys).size).toBe(after.length);
+ expect(after.map((run) => run.text).join("")).toBe("Before bold and after more text");
+ });
+
+ it("changes run identity when its attributes change", () => {
+ const run = { text: "Docs", href: "https://example.com/docs" };
+ const key = nativeMarkdownTextRunKey(run, 0);
+
+ expect(nativeMarkdownTextRunKey({ ...run, bold: true }, 0)).not.toBe(key);
+ expect(nativeMarkdownTextRunKey({ ...run, href: "https://example.com/other" }, 0)).not.toBe(
+ key,
+ );
+ });
+
it("links a path-shaped code span without changing the same path in prose", () => {
expect(
nativeMarkdownTextRuns({
@@ -486,6 +517,81 @@ describe("nativeMarkdownListItemBlocks", () => {
});
describe("nativeMarkdownDocumentChunks", () => {
+ it.each(["paragraph", "code_block"] as const)(
+ "keeps a streaming %s identity while its end and contents change",
+ (type) => {
+ const before: MarkdownNode = {
+ type,
+ beg: 0,
+ end: 5,
+ children: [{ type: "text", content: "Hello" }],
+ };
+ const after: MarkdownNode = {
+ ...before,
+ end: 11,
+ children: [{ type: "text", content: "Hello world" }],
+ };
+ const [beforeChunk] = nativeMarkdownDocumentChunks({ type: "document", children: [before] });
+ const [afterChunk] = nativeMarkdownDocumentChunks({ type: "document", children: [after] });
+
+ expect(afterChunk?.key).toBe(beforeChunk?.key);
+ expect(
+ nativeMarkdownDocumentRuns(afterChunk!.node)
+ .map((run) => run.text)
+ .join(""),
+ ).toBe("Hello world");
+ },
+ );
+
+ it("keeps the selectable container when a new paragraph extends it", () => {
+ const first: MarkdownNode = {
+ type: "paragraph",
+ beg: 0,
+ end: 5,
+ children: [{ type: "text", content: "Hello" }],
+ };
+ const before = nativeMarkdownDocumentChunks({ type: "document", children: [first] });
+ const after = nativeMarkdownDocumentChunks({
+ type: "document",
+ children: [
+ first,
+ {
+ type: "paragraph",
+ beg: 7,
+ end: 12,
+ children: [{ type: "text", content: "World" }],
+ },
+ ],
+ });
+
+ expect(after).toHaveLength(1);
+ expect(after[0]?.key).toBe(before[0]?.key);
+ expect(
+ nativeMarkdownDocumentRuns(after[0]!.node)
+ .map((run) => run.text)
+ .join(""),
+ ).toBe("Hello\n\nWorld");
+ });
+
+ it.each([
+ ["paragraph", "heading"],
+ ["code_block", "blockquote"],
+ ] as const)("replaces a %s when it becomes a %s", (beforeType, afterType) => {
+ const node: MarkdownNode = {
+ type: beforeType,
+ beg: 0,
+ end: 5,
+ children: [{ type: "text", content: "Hello" }],
+ };
+ const before = nativeMarkdownDocumentChunks({ type: "document", children: [node] });
+ const after = nativeMarkdownDocumentChunks({
+ type: "document",
+ children: [{ ...node, type: afterType }],
+ });
+
+ expect(after[0]?.key).not.toBe(before[0]?.key);
+ });
+
it("renders plain blockquotes as rich blocks so their marker spans wrapped lines", () => {
const blockquote: MarkdownNode = {
type: "blockquote",
@@ -513,7 +619,7 @@ describe("nativeMarkdownDocumentChunks", () => {
).toEqual([
{
kind: "rich",
- key: "rich:blockquote:0:120",
+ key: "rich:blockquote:0",
node: blockquote,
},
]);
@@ -640,10 +746,22 @@ describe("nativeMarkdownDocumentChunks", () => {
expect(chunks[0]).toMatchObject({ kind: "selectable" });
expect(chunks[1]).toEqual({
kind: "rich",
- key: "rich:code_block:11:35",
+ key: "rich:code_block:11",
node: document.children?.[1],
});
expect(chunks[2]).toMatchObject({ kind: "selectable" });
+
+ const growingTail: MarkdownNode = {
+ type: "paragraph",
+ beg: 37,
+ end: 53,
+ children: [{ type: "text", content: "Done. More text." }],
+ };
+ const after = nativeMarkdownDocumentChunks({
+ ...document,
+ children: [...document.children!.slice(0, 2), growingTail],
+ });
+ expect(after.map((chunk) => chunk.key)).toEqual(chunks.map((chunk) => chunk.key));
});
it("keeps a list containing fenced code as one rich AST container", () => {
@@ -677,7 +795,7 @@ describe("nativeMarkdownDocumentChunks", () => {
expect(nativeMarkdownDocumentChunks(document)).toEqual([
{
kind: "rich",
- key: "rich:list:0:45",
+ key: "rich:list:0",
node: document.children?.[0],
},
]);
@@ -705,10 +823,11 @@ describe("nativeMarkdownDocumentChunks", () => {
expect(chunks[0]).toMatchObject({ kind: "selectable" });
expect(chunks[1]).toEqual({
kind: "rich",
- key: "rich:horizontal_rule:1:1",
+ key: "rich:horizontal_rule:index:1",
node: document.children?.[1],
});
expect(chunks[2]).toMatchObject({ kind: "selectable" });
+ expect(new Set(chunks.map((chunk) => chunk.key)).size).toBe(chunks.length);
});
it("keeps offset-free structural lists isolated without promoting the whole document", () => {
@@ -751,7 +870,7 @@ describe("nativeMarkdownDocumentChunks", () => {
expect(chunks[0]).toMatchObject({ kind: "selectable" });
expect(chunks[1]).toEqual({
kind: "rich",
- key: "rich:list:1:1",
+ key: "rich:list:index:1",
node: document.children?.[1],
});
expect(chunks[2]).toMatchObject({ kind: "selectable" });