Skip to content

[Bug]: On mobile, a <p>, <div> or <summary> tag inside a Markdown table cell splits the row and moves the rest of it below the table #35496

Closed

Description

@coygeek

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:

  1. Converts the table with the exported production function markdownForNativeRendering from apps/mobile/src/lib/markdownDocument.ts, using the exact production ENRICHED_INLINE_FILE_LINK_REGEX, and checks that the fileEmbedLabel branch (markdownForNativeParsing → markdownWithInlineFileLinks → markdownWithFileEmbeds → markdownWithTableBreaks) produces the same string.
  2. Parses the result with the WASM build of the shared MD4C C++ parser shipped in react-native-enriched-markdown, using the flags EnrichedMarkdownText passes.
  3. Renders the original input through the web chat renderer (createMarkdownRenderer with 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 &#10; 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 in apps/mobile/src/lib/markdownDocument.ts lines 233-234, the inTableCell flag that markdownWithHtml passes for HTML inside table cells (lines 242-274), the focused tests in apps/mobile/src/lib/markdownHtml.test.ts lines 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.ts lines 230-235 return a block break without consulting inTableCell.
  • 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

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&#10;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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions