Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/mobile
Steps to reproduce
Use a checkout of the commit under Version or commit with its locked dependencies installed. Save the script below outside the checkout as repro.sh and run env -u NODE_ENV sh /path/to/repro.sh from the checkout root. It writes its fixture to a temporary directory outside the checkout, runs Node with a private HOME, TMPDIR and T3CODE_HOME, deletes the fixture on exit, and needs no running server, provider, simulator or device.
For each of five cells beneath a two-column header (| A | B |), with x in column B, the script:
- Converts the table with the exported production function
markdownForNativeRenderingfromapps/mobile/src/lib/markdownDocument.ts, using the exact productionENRICHED_INLINE_FILE_LINK_REGEX, and checks that thefileEmbedLabelbranch (markdownForNativeParsing→markdownWithInlineFileLinks→markdownWithFileEmbeds→markdownWithTableBreaks) produces the same string. - Parses the result with the WASM build of the shared MD4C C++ parser shipped in
react-native-enriched-markdown, using the flagsEnrichedMarkdownTextpasses. - Renders the original input through the web chat renderer (
createMarkdownRendererwith the production remark and rehype plugin lists) for comparison.
The cells are one<br>two (the control), one<p>two</p>, one<div>two</div>, one<summary>two</summary> and <p>one</p><p>two</p>. The assertions pin the current defect, so the run exits 0 and prints DEFECT PRESENT while the defect exists.
Reproduction script: repro.sh
#!/bin/sh
set -eu
# Run from the root of a t3code checkout with dependencies installed.
# Writes nothing inside the checkout; the fixture lives in a temporary directory.
checkout=$(pwd)
fixture=$(mktemp -d)
trap 'rm -rf "$fixture"' EXIT HUP INT TERM
mkdir -p "$fixture/fake-user" "$fixture/tmp" "$fixture/t3home"
cat > "$fixture/check.mjs" <<'JS'
import assert from 'node:assert/strict';
import { createRequire, registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { pathToFileURL } from 'node:url';
// The production web renderer has one extensionless relative TS import.
registerHooks({ resolve(spec, context, next) {
try { return next(spec, context); }
catch (e) {
if (e.code !== 'ERR_MODULE_NOT_FOUND' || !spec.startsWith('.')) throw e;
return next(spec + '.ts', context);
}
}});
const root = process.argv[2];
const mobileRequire = createRequire(root + '/apps/mobile/package.json');
const webRequire = createRequire(root + '/apps/web/package.json');
const fromRoot = p => import(pathToFileURL(root + '/' + p).href);
const load = (req, spec) => import(pathToFileURL(req.resolve(spec)).href);
const native = await fromRoot('apps/mobile/src/lib/markdownDocument.ts');
// Read the exact production caller's regex without loading unrelated React Native modules.
const regexSource = readFileSync(root + '/apps/mobile/src/lib/enrichedLinkPresentation.ts', 'utf8')
.match(/export const ENRICHED_INLINE_FILE_LINK_REGEX =\s*([^;]+);/)[1];
const regex = runInNewContext(regexSource);
const pipeline = await fromRoot('packages/shared/src/markdownPipeline.ts');
const { createMarkdownRenderer } = await fromRoot('apps/web/src/components/chat/markdownRenderer.ts');
const { renderToStaticMarkup } = await load(webRequire, 'react-dom/server');
const renderWeb = createMarkdownRenderer({
remarkPlugins: pipeline.CHAT_MARKDOWN_REMARK_PLUGINS,
rehypePlugins: pipeline.CHAT_MARKDOWN_REHYPE_PLUGINS,
components: {}, urlTransform: s => s,
});
const md4cPath = mobileRequire.resolve('react-native-enriched-markdown/package.json')
.replace(/package\.json$/, 'src/web/wasm/md4c.js');
const { default: createMd4c } = await import(pathToFileURL(md4cPath).href);
const wasm = await createMd4c();
const parse = wasm.cwrap('parseMarkdown', 'string', ['string', ...Array(10).fill('number')]);
// Same shared C++ MD4C parser, flags from EnrichedMarkdownText (math enabled).
const md4c = s => JSON.parse(parse(s, 0, 1, 0, 0, 0, 0, 0, 1, 1, 1));
const text = n => (n.content ?? n.value ?? '') + (n.children ?? []).map(text).join('');
const md4cCells = doc => doc.children[0].children.find(n => n.type === 'TableBody')
.children[0].children.map(text);
const cases = [
['control-br', 'one<br>two'],
['paragraph', 'one<p>two</p>'],
['div', 'one<div>two</div>'],
['summary', 'one<summary>two</summary>'],
['paragraphs-at-cell-start', '<p>one</p><p>two</p>'],
];
for (const [name, cell] of cases) {
const input = '| A | B |\n| - | - |\n| ' + cell + ' | x |';
const output = native.markdownForNativeRendering(input, regex);
// Exercise the fileEmbedLabel branch used by ThreadFeed as well.
const parsed = native.markdownForNativeParsing(input);
const files = native.markdownWithFileEmbeds(native.markdownWithInlineFileLinks(parsed, regex), () => null);
assert.equal(native.markdownWithTableBreaks(files.markdown), output);
const doc = native.parseMarkdownDocument(output);
const nativeDoc = md4c(output);
const cells = md4cCells(nativeDoc);
const paragraphs = nativeDoc.children.slice(1).map(text);
const webHtml = renderToStaticMarkup(renderWeb(input, false));
assert.equal((webHtml.match(/<td>/g) ?? []).length, 2);
assert.ok(webHtml.includes('<td>x</td>'));
console.log(name + '\n input: ' + JSON.stringify(input));
console.log(' native Markdown: ' + JSON.stringify(output));
console.log(' remark row cells: ' + doc.children[0].children[1].children.length);
console.log(' shared native parser row cells: ' + JSON.stringify(cells));
console.log(' shared native parser paragraphs: ' + JSON.stringify(paragraphs));
console.log(' web HTML: ' + webHtml.replace(/\n/g, ''));
if (name === 'control-br') {
assert.deepEqual(cells, ['one\ntwo', 'x']);
assert.deepEqual(paragraphs, []);
assert.equal(doc.children[0].children[1].children.length, 2);
} else {
assert.ok(output.includes('\n\n'));
assert.deepEqual(cells, [name === 'paragraphs-at-cell-start' ? '' : 'one', '']);
assert.equal(doc.children[0].children[1].children.length, 1);
assert.ok(paragraphs.includes('two'));
assert.equal(paragraphs.at(-1), '| x |');
}
}
console.log('DEFECT PRESENT: br preserves both cells; paragraph/div/summary move cell contents outside the table. Both mobile branches agree; web preserves both cells.');
JS
cd "$checkout/apps/mobile"
env -u NODE_ENV HOME="$fixture/fake-user" TMPDIR="$fixture/tmp" T3CODE_HOME="$fixture/t3home" node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON "$fixture/check.mjs" "$checkout"Expected behavior
Each authored row stays one table row with its cell values in their columns: for | one<p>two</p> | x |, column A holds one and two and column B holds x, with nothing displaced below the table. The converter already treats a raw newline as the end of a table row and encodes <br> inside a cell as for that reason (apps/mobile/src/lib/markdownDocument.ts lines 233-234), and markdownWithHtml passes inTableCell for HTML found in a table cell (lines 242-274). The web chat renderer keeps both cells for the same input, producing <td>one<p>two</p></td><td>x</td> and <td>one<div>two</div></td><td>x</td>.
Actual behavior
Summary. On mobile, a Markdown table row whose cell contains a same-line <p>, <div> or <summary> tag, such as | one<p>two</p> | x |, loses its column association: the native-rendering converter inserts blank lines inside the row, so the native parser produces a body row of ["one", ""] followed by ordinary paragraphs two and a literal | x | below the table. <summary> also bolds the displaced text. The web renderer keeps both cells for the same input, and a <br> inside the cell already stays in the row on mobile. Both production mobile branches (with and without fileEmbedLabel) are affected. How often agents emit these tags inside table cells is unknown, and no device screenshot was captured.
For | one<p>two</p> | x | the converter emits | one\n\ntwo\n\n | x |. The shared native parser then pads the truncated row to two columns, ["one", ""], and renders two and the literal | x | as ordinary paragraphs after the table. <div> gives the same result. <summary> emits **two**, so the displaced paragraph is bold. A cell that starts with <p>one</p><p>two</p> leaves both native cells empty and moves one, two and | x | below the table. The <br> control stays intact as ["one\ntwo", "x"]. Both mobile branches produce identical strings.
This input reaches the converter from ordinary message rendering: MobileEnrichedMarkdownText (apps/mobile/src/native/EnrichedMarkdownText.tsx lines 220-237) calls the converter directly, or the same normalization through its fileEmbedLabel branch, and passes the result to EnrichedMarkdownText with flavor="github" (lines 407-430). ThreadFeed.tsx passes assistant Markdown through SelectableMarkdownText to that component and supplies fileEmbedLabel for the chat feed. These are parser results; no iOS or Android UI was launched, so the predicted on-device result (a two-column table with an empty column B followed by the displaced paragraphs) was not observed in pixels.
Evidence
- Expected source: the table-cell
<br>handling and its comment inapps/mobile/src/lib/markdownDocument.tslines 233-234, theinTableCellflag thatmarkdownWithHtmlpasses for HTML inside table cells (lines 242-274), the focused tests inapps/mobile/src/lib/markdownHtml.test.tslines 88-113 that pin intact rows for<br>and anchors, and the web chat renderer's output for the same input in the script. - Failure source: the script output below from the exported production converter and the shared MD4C parser; the paragraph, div, details and summary branches at
apps/mobile/src/lib/markdownDocument.tslines 230-235 return a block break without consultinginTableCell. - Evidence provenance: observed
- Local verification: reproduced
- Reproduction completeness: complete
The script above exited 0 with DEFECT PRESENT on a clean checkout of the tested commit and left the checkout's working tree unchanged. An equivalent earlier version of the script (same assertions, different fixture location) produced byte-identical output on upstream main at 315b06c, where the mobile converter and native caller are unchanged. The existing focused mobile HTML Markdown tests (apps/mobile/src/lib/markdownHtml.test.ts) pass 39/39 at the tested commit, so they do not cover this case. The hypothesis that the block break should take the table-cell path the way <br> does is an inference from the code, not a tested fix.
Related
- [Bug]: Mobile: <br> in a Markdown table cell is dropped, so lists in cells run together #28865 (closed) and fix(mobile): preserve line breaks in Markdown table cells #29355 (merged): fixed authored
<br>breaks inside table cells on mobile; they addressed<br>specifically, and this is a separate remaining failure in the neighboring branches, not a regression shown to come from that fix. - fix(mobile): chat replies keep <details> folds collapsed #28093 (open PR): changes top-level
<details>disclosure handling and leaves this table-cell conversion unchanged. - feat(web): copy a chat table as HTML #33286 (open PR): adds HTML clipboard output for web tables and does not change mobile rendering.
Restoration check
With the script's five inputs, each converted row stays a single table row: the shared native parser returns two body cells per row with x in column B and no paragraphs after the table, for <p>, <div>, <summary> and the cell that starts with <p>, through both mobile branches. The <br> control must stay ["one\ntwo", "x"], and the same block tags outside tables must still produce paragraph breaks. The script's current assertions encode the defect and will fail once it is fixed.
Impact
Minor bug or occasional failure
Version or commit
T3 Code 0.1.9 (@t3tools/mobile 0.0.0) at d9805b2, rerun from a clean checkout; also reproduced on upstream main as noted under Evidence.
Environment
Darwin arm64, Node v26.12.1, the repository's pinned dependencies; exported converter functions, the web renderer and the WASM build of the shared native parser only, with no app, simulator, device or server.
Logs or stack traces
Output of the script at the tested commit:
control-br
input: "| A | B |\n| - | - |\n| one<br>two | x |"
native Markdown: "| A | B |\n| - | - |\n| one two | x |"
remark row cells: 2
shared native parser row cells: ["one\ntwo","x"]
shared native parser paragraphs: []
web HTML: <table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>one<br/>two</td><td>x</td></tr></tbody></table>
paragraph
input: "| A | B |\n| - | - |\n| one<p>two</p> | x |"
native Markdown: "| A | B |\n| - | - |\n| one\n\ntwo\n\n | x |"
remark row cells: 1
shared native parser row cells: ["one",""]
shared native parser paragraphs: ["two","| x |"]
web HTML: <table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>one<p>two</p></td><td>x</td></tr></tbody></table>
div
input: "| A | B |\n| - | - |\n| one<div>two</div> | x |"
native Markdown: "| A | B |\n| - | - |\n| one\n\ntwo\n\n | x |"
remark row cells: 1
shared native parser row cells: ["one",""]
shared native parser paragraphs: ["two","| x |"]
web HTML: <table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>one<div>two</div></td><td>x</td></tr></tbody></table>
summary
input: "| A | B |\n| - | - |\n| one<summary>two</summary> | x |"
native Markdown: "| A | B |\n| - | - |\n| one\n\n**two**\n\n | x |"
remark row cells: 1
shared native parser row cells: ["one",""]
shared native parser paragraphs: ["two","| x |"]
web HTML: <table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>one<summary>two</summary></td><td>x</td></tr></tbody></table>
paragraphs-at-cell-start
input: "| A | B |\n| - | - |\n| <p>one</p><p>two</p> | x |"
native Markdown: "| A | B |\n| - | - |\n| \n\none\n\n\n\ntwo\n\n | x |"
remark row cells: 1
shared native parser row cells: ["",""]
shared native parser paragraphs: ["one","two","| x |"]
web HTML: <table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td><p>one</p><p>two</p></td><td>x</td></tr></tbody></table>
DEFECT PRESENT: br preserves both cells; paragraph/div/summary move cell contents outside the table. Both mobile branches agree; web preserves both cells.
Screenshots, recordings, or supporting files
No response
Workaround
Write the cell with <br> instead of a paragraph, div or summary tag, on a single physical row: | one<br>two | x | keeps one and two in column A and x in column B on mobile, as the script's control case shows. The web client also shows both cells for the original input.
Activity