fix(app): remove blank space above Android turn footers (#8088)

Android ships generated Markdown props without code.fontScale, so native measurement wraps inline code at a larger size than drawing. Regenerate both Android code structs and enforce generated-header parity for both platforms.
This commit is contained in:
Mohamed Boudra
2026-10-28 13:51:41 +07:00
committed by GitHub
parent bede2b90ed
commit 7a0c6fbd82
5 changed files with 187 additions and 33 deletions
@@ -12,6 +12,22 @@ point and Metro workflow in [status-ring](../status-ring/README.md), importing
`MarkdownFormattingRegression` from `./e2e/mobile/markdown-formatting/native`.
Open the same Metro URL in a browser to compare web. Restore `index.ts` afterward.
With this fixture open in an Android Agent Device session, run:
```sh
node packages/app/e2e/mobile/markdown-formatting/android.mjs <session>
```
The runner switches to paragraphs with long inline code at a fixed 300 dp width.
It compares the native Markdown host’s bottom with its last TextView’s bottom,
then repeats in compact mode and after restoring regular text. More than one
pixel of surplus fails. Keep the whole fixture visible: clipped accessibility
bounds cannot establish measurement parity.
`scripts/postinstall-patches.test.mjs` regenerates both platforms’ props and
compares them with the shipped headers. This catches missing C++ fields in the
measurement path, which the Kotlin tests cannot exercise.
The package patch contains `InlineCodeFormattingTest`, which exercises actual
Android spans in Robolectric native graphics mode, covering draw and measurement,
span order, code inside paragraph/heading sizes, bold/italic composition, and
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
const session = process.argv[2];
assert(session, "Pass an Android agent-device session serving native.tsx");
function ad(...args) {
return execFileSync("agent-device", ["--session", session, ...args], {
encoding: "utf8",
timeout: 60_000,
});
}
function assertMeasurementParity(mode) {
const snapshot = JSON.parse(ad("snapshot", "--raw", "--json"));
assert.equal(snapshot.success, true);
const nodes = snapshot.data.nodes;
let last = nodes.find((node) => node.label?.endsWith("End of measurement fixture."));
assert(last?.rect, "The last paragraph must be visible, including its bottom");
// EnrichedMarkdownText exposes paragraph accessibility nodes inside its TextView;
// EnrichedMarkdown exposes the TextViews themselves. Measure the native child.
let host = nodes.find((node) => node.index === last.parentIndex);
if (host?.type === "android.widget.TextView") {
last = host;
host = nodes.find((node) => node.index === last.parentIndex);
}
assert.equal(host?.type, "android.widget.FrameLayout");
const viewport = nodes.find((node) => node.type === "android.widget.ScrollView");
const footer = nodes.find((node) => node.label === "End of Markdown host");
assert(viewport?.rect && footer?.rect, "The fixture and footer must be visible");
assert(
host.rect.y >= viewport.rect.y &&
footer.rect.y + footer.rect.height <= viewport.rect.y + viewport.rect.height,
"The whole fixture must fit in the viewport; clipped bounds cannot prove parity",
);
const hostBottom = host.rect.y + host.rect.height;
const childBottom = last.rect.y + last.rect.height;
console.log(
JSON.stringify({
mode,
host: host.rect,
lastChild: last.rect,
surplus: hostBottom - childBottom,
}),
);
assert(
Math.abs(hostBottom - childBottom) <= 1,
`Markdown reserves blank space in ${mode}: host bottom ${hostBottom}, last child bottom ${childBottom}`,
);
}
ad("press", 'label="Show wrapping fixture"');
ad("wait", "text", "End of Markdown host", "10000");
assertMeasurementParity("regular");
ad("press", 'label="Use compact text"');
ad("wait", "text", "Use regular text", "10000");
assertMeasurementParity("compact");
ad("press", 'label="Use regular text"');
ad("wait", "text", "Use compact text", "10000");
assertMeasurementParity("regular restored");
console.log("PASS Android Markdown host measurement matches its last child");
@@ -1,6 +1,6 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { Button, ScrollView, Text } from "react-native";
import { Button, ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { MarkdownRenderer } from "@/components/markdown/renderer";
@@ -10,6 +10,7 @@ const styles = StyleSheet.create((theme) => ({
scroll: { backgroundColor: theme.colors.surface0 },
content: { padding: 32, paddingTop: 60 },
title: { color: theme.colors.foreground },
wrapping: { width: 300 },
}));
const queryClient = new QueryClient();
@@ -32,7 +33,15 @@ const source = [
"Normal **bold** and *italic* prose.",
].join("\n\n");
const wrappingSource = [
"Measurement parity. Take a repo with a gitignored `.env` in the main checkout and a `.worktreeinclude` containing `.env`.",
"Every creation path goes through `createPaseoWorktreeWorkflow` in `worktree-session.ts:341`, before `createAgent` in `creation/index.ts:160-174` starts.",
"The built-in plugin lives in `plugins/worktree-include/index.server.ts` and copies `paseo.json` beside `.worktreeinclude`. End of measurement fixture.",
].join("\n\n");
export default function MarkdownFormattingRegression() {
const [wrapping, setWrapping] = useState(false);
const toggleWrapping = useCallback(() => setWrapping((value) => !value), []);
const [compact, setCompact] = useState(false);
const toggleCompact = useCallback(() => setCompact((value) => !value), []);
return (
@@ -44,7 +53,14 @@ export default function MarkdownFormattingRegression() {
title={compact ? "Use regular text" : "Use compact text"}
onPress={toggleCompact}
/>
<MarkdownRenderer text={source} compact={compact} />
<Button
title={wrapping ? "Show formatting fixture" : "Show wrapping fixture"}
onPress={toggleWrapping}
/>
<View style={wrapping ? styles.wrapping : undefined}>
<MarkdownRenderer text={wrapping ? wrappingSource : source} compact={compact} />
</View>
<Text style={styles.title}>End of Markdown host</Text>
</ScrollView>
</GestureHandlerRootView>
</QueryClientProvider>
@@ -3827,3 +3827,60 @@ diff --git a/node_modules/react-native-enriched-markdown/ios/generated/ReactCode
auto tmp_color = map.find("color");
if (tmp_color != map.end()) {
fromRawValue(context, tmp_color->second, result.color);
diff --git a/node_modules/react-native-enriched-markdown/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h b/node_modules/react-native-enriched-markdown/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h
--- a/node_modules/react-native-enriched-markdown/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h
+++ b/node_modules/react-native-enriched-markdown/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h
@@ -1639,6 +1639,7 @@
struct EnrichedMarkdownMarkdownStyleCodeStruct {
std::string fontFamily{};
Float fontSize{0.0};
+ Float fontScale{0.0};
SharedColor color{};
SharedColor backgroundColor{};
SharedColor borderColor{};
@@ -1651,6 +1652,7 @@
folly::dynamic result = folly::dynamic::object();
result["fontFamily"] = fontFamily;
result["fontSize"] = fontSize;
+ result["fontScale"] = fontScale;
result["color"] = ::facebook::react::toDynamic(color);
result["backgroundColor"] = ::facebook::react::toDynamic(backgroundColor);
result["borderColor"] = ::facebook::react::toDynamic(borderColor);
@@ -1669,6 +1671,10 @@
auto tmp_fontSize = map.find("fontSize");
if (tmp_fontSize != map.end()) {
fromRawValue(context, tmp_fontSize->second, result.fontSize);
+ }
+ auto tmp_fontScale = map.find("fontScale");
+ if (tmp_fontScale != map.end()) {
+ fromRawValue(context, tmp_fontScale->second, result.fontScale);
}
auto tmp_color = map.find("color");
if (tmp_color != map.end()) {
@@ -6185,6 +6191,7 @@
struct EnrichedMarkdownTextMarkdownStyleCodeStruct {
std::string fontFamily{};
Float fontSize{0.0};
+ Float fontScale{0.0};
SharedColor color{};
SharedColor backgroundColor{};
SharedColor borderColor{};
@@ -6197,6 +6204,7 @@
folly::dynamic result = folly::dynamic::object();
result["fontFamily"] = fontFamily;
result["fontSize"] = fontSize;
+ result["fontScale"] = fontScale;
result["color"] = ::facebook::react::toDynamic(color);
result["backgroundColor"] = ::facebook::react::toDynamic(backgroundColor);
result["borderColor"] = ::facebook::react::toDynamic(borderColor);
@@ -6215,6 +6223,10 @@
auto tmp_fontSize = map.find("fontSize");
if (tmp_fontSize != map.end()) {
fromRawValue(context, tmp_fontSize->second, result.fontSize);
+ }
+ auto tmp_fontScale = map.find("fontScale");
+ if (tmp_fontScale != map.end()) {
+ fromRawValue(context, tmp_fontScale->second, result.fontScale);
}
auto tmp_color = map.find("color");
if (tmp_color != map.end()) {
+36 -31
View File
@@ -10,37 +10,42 @@ const bin = path.resolve("node_modules", ".bin");
const packageName = "react-native-draggable-flatlist";
const patchFile = `${packageName}+4.0.3.patch`;
test("patched Markdown ships iOS props generated from its native specs", () => {
const packageDir = path.resolve("packages/app/node_modules/react-native-enriched-markdown");
const output = mkdtempSync(path.join(tmpdir(), "markdown-ios-codegen-"));
try {
// includesGeneratedCode makes CocoaPods use the shipped headers instead of
// regenerating them after patch-package changes the TypeScript specs.
const result = spawnSync(
process.execPath,
[
path.resolve("node_modules/react-native/scripts/generate-codegen-artifacts.js"),
"-p",
packageDir,
"-t",
"ios",
"-o",
output,
"-s",
"library",
],
{ encoding: "utf8" },
);
assert.equal(result.status, 0, result.stdout + result.stderr);
const header = "ReactCodegen/EnrichedMarkdownTextSpec/Props.h";
assert.equal(
readFileSync(path.join(packageDir, "ios/generated", header), "utf8"),
readFileSync(path.join(output, header), "utf8"),
);
} finally {
rmSync(output, { recursive: true, force: true });
}
});
for (const platform of ["ios", "android"]) {
test(`patched Markdown ships ${platform} props generated from its native specs`, () => {
const packageDir = path.resolve("packages/app/node_modules/react-native-enriched-markdown");
const output = mkdtempSync(path.join(tmpdir(), `markdown-${platform}-codegen-`));
try {
// includesGeneratedCode makes native builds use the shipped headers instead of
// regenerating them after patch-package changes the TypeScript specs.
const result = spawnSync(
process.execPath,
[
path.resolve("node_modules/react-native/scripts/generate-codegen-artifacts.js"),
"-p",
packageDir,
"-t",
platform,
"-o",
output,
"-s",
"library",
],
{ encoding: "utf8" },
);
assert.equal(result.status, 0, result.stdout + result.stderr);
const header =
platform === "ios"
? "ReactCodegen/EnrichedMarkdownTextSpec/Props.h"
: "jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h";
assert.equal(
readFileSync(path.join(packageDir, platform, "generated", header), "utf8"),
readFileSync(path.join(output, header), "utf8"),
);
} finally {
rmSync(output, { recursive: true, force: true });
}
});
}
function createCheckout() {
const root = mkdtempSync(path.join(tmpdir(), "postinstall-patches-"));