mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Give Markdown blocks one consistent gap, roomier on desktop, and a zoom-in cursor on images (#7298)
* fix(scripts): recover installs when a dependency patch changed patch-package cannot apply a patch over a package patched with an earlier version of it, and without --error-on-fail it exits 0 locally. Checkouts kept the stale patch silently: the Markdown code block spacing fix never reached them. The script now fails the install and removes that package so the next npm install reinstalls it clean with the current patch. A patch-package that cannot start fails the install without removing anything. * fix(app): give Markdown blocks one gap and roomier desktop spacing Every Markdown block is followed by one gap, density.blockGap: 12 on compact layouts and 16 wider. The library's element styles and the views stacked outside it (Mermaid segments on native, streaming head and tail) take the same token, so no block type carries its own margins. Desktop was too tight at 12. Markdown images show a zoom-in cursor on web, since tapping opens the lightbox.
This commit is contained in:
@@ -145,6 +145,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Verify dependency patch recovery
|
||||
run: node --test scripts/postinstall-patches.test.mjs
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
|
||||
+3
-1
@@ -535,7 +535,9 @@ For tighter loops, you can rebuild a single workspace:
|
||||
`patches/*.patch` are applied by `scripts/postinstall-patches.mjs` on every install. A patch only
|
||||
runs when its package is actually present, so add the package to that script's `patchedPackages`
|
||||
list when you introduce a new patch — otherwise the file sits in `patches/` and never applies.
|
||||
Regenerate a patch with `npx patch-package <package>` after editing `node_modules/<package>`, and
|
||||
patch-package cannot apply a patch that changed after an earlier version was applied, so the
|
||||
script removes that package and fails the install; the next `npm install` reinstalls it clean
|
||||
with the current patch. Regenerate a patch with `npx patch-package <package>` after editing `node_modules/<package>`, and
|
||||
patch every build the consumers use: Metro resolves the `react-native` field of a package
|
||||
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
|
||||
(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed.
|
||||
|
||||
@@ -4,6 +4,10 @@ Use `MarkdownRenderer` for chat replies, file previews, plans, pull requests and
|
||||
|
||||
Native selection belongs to the library's text view. Keep an assistant message in one row so selection can cross paragraphs, headings and lists. Code blocks and tables use the library's native views and their selection behavior. Web uses browser selection over semantic HTML. Once its WASM parser is initialized, the library parses synchronously for the first commit of each mounted row; an empty interim row would corrupt virtualized measurements and move the reader. Chat owns paced reveal, the web render cap, message identity and list measurement. Native renders completed messages together. While streaming, it keeps closed blocks in one head view and updates a separate tail, then reunites the text on completion. See [native parsing cost](agent-stream-performance.md#native-parsing-cost) for the boundary and pacing constraints.
|
||||
|
||||
## Block spacing
|
||||
|
||||
Every block is separated by one gap, `density.blockGap`: 12 on compact layouts, 16 wider. Inside the library's text, every element style ends with that gap and the library drops it after the last block. Views stacked outside the library (Mermaid segments, streaming head and tail) use the same gap through `MarkdownBlockStack`. Do not give a block type its own margins; change the gap.
|
||||
|
||||
## Links and images
|
||||
|
||||
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact.
|
||||
|
||||
@@ -287,43 +287,22 @@ test("markdown links underline on hover", async ({ page }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// The web code block replaces the library's own block, which must keep its spacing.
|
||||
test("blocks around a web code block keep their spacing", async ({ page }) => {
|
||||
const PIXEL_PNG =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
|
||||
test("Markdown images show a zoom-in cursor", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "markdown-block-spacing-",
|
||||
title: "Markdown block spacing",
|
||||
featureValues: {
|
||||
mockAssistantResponse:
|
||||
"Intro.\n\n```sh\nnpx playwright test\n```\n\n| Step | Result |\n| --- | --- |\n| hover | none |\n\nMiddle.\n\n```ts\nconst a = 1;\n```\n\nClosing.",
|
||||
},
|
||||
initialPrompt: "Show blocks",
|
||||
repoPrefix: "markdown-image-cursor-",
|
||||
title: "Markdown image cursor",
|
||||
featureValues: { mockAssistantResponse: `Before.\n\n\n\nAfter.` },
|
||||
initialPrompt: "Show image",
|
||||
});
|
||||
try {
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await openAgentRoute(page, session);
|
||||
const message = page.getByTestId("assistant-message");
|
||||
await expect(message.getByRole("table")).toBeVisible();
|
||||
const gaps = await measureBlockGaps(message.locator(".enrm-text").first());
|
||||
for (const { between, gap } of gaps) expect(gap, between).toBeGreaterThanOrEqual(8);
|
||||
const image = page.getByTestId("assistant-message").getByRole("button", { name: "pixel" });
|
||||
await expect(image).toHaveCSS("cursor", "zoom-in");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function measureBlockGaps(root: Locator) {
|
||||
return root.evaluate((element) => {
|
||||
const blocks = Array.from(element.children).map((block) => {
|
||||
// Wrappers with display: contents have no box; measure their first rendered child.
|
||||
let box: Element = block;
|
||||
while (box.getBoundingClientRect().height === 0 && box.firstElementChild) {
|
||||
box = box.firstElementChild;
|
||||
}
|
||||
const rect = box.getBoundingClientRect();
|
||||
return { text: (block.textContent ?? "").slice(0, 20), top: rect.top, bottom: rect.bottom };
|
||||
});
|
||||
return blocks.slice(1).map((block, index) => ({
|
||||
between: `${blocks[index].text} -> ${block.text}`,
|
||||
gap: Math.round(block.top - blocks[index].bottom),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { markdownCopyImageSourcesDataSet } from "@/assistant-selection-copy/markup";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { density } from "@/styles/density";
|
||||
|
||||
/** Stacks Markdown blocks rendered as separate views with the same gap the text puts between its blocks. */
|
||||
export function MarkdownBlockStack({
|
||||
imageSources,
|
||||
children,
|
||||
}: {
|
||||
imageSources?: Record<string, string>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={styles.stack}
|
||||
dataSet={isWeb && imageSources ? markdownCopyImageSourcesDataSet(imageSources) : undefined}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
stack: { gap: density.blockGap },
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Pressable, View, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { Pressable, View, type ViewStyle } from "react-native";
|
||||
import { Code, Maximize2, Workflow } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
@@ -68,15 +68,6 @@ function MermaidFenceHostImpl({
|
||||
],
|
||||
[openFullscreen, showSourcePress, t],
|
||||
);
|
||||
const sourceView = useMemo(() => {
|
||||
const { marginTop, marginBottom, marginVertical, ...sourceTextStyle } = textStyle;
|
||||
const margins: ViewStyle = {
|
||||
marginTop: marginTop ?? marginVertical,
|
||||
marginBottom: marginBottom ?? marginVertical,
|
||||
};
|
||||
const text: TextStyle = sourceTextStyle;
|
||||
return { container: [margins, sourceContainerStyle], text };
|
||||
}, [textStyle]);
|
||||
const diagramStyle = useMemo(
|
||||
() => [
|
||||
getDiagramBoxStyle(textStyle),
|
||||
@@ -90,19 +81,17 @@ function MermaidFenceHostImpl({
|
||||
[visible],
|
||||
);
|
||||
const sourceVisible = !diagramVisible;
|
||||
const sourceContainer = showSource ? sourceView.container : sourceContainerStyle;
|
||||
const sourceTextStyle = showSource ? sourceView.text : textStyle;
|
||||
const viewportStyle = diagramVisible ? diagramStyle : measuringStyle;
|
||||
|
||||
return (
|
||||
<>
|
||||
{sourceVisible ? (
|
||||
<View style={sourceContainer}>
|
||||
<View style={sourceContainerStyle}>
|
||||
<HighlightedCodeBlock
|
||||
code={code}
|
||||
language="mermaid"
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={sourceTextStyle}
|
||||
textStyle={textStyle}
|
||||
/>
|
||||
{showSource && canShowDiagram ? (
|
||||
<Pressable
|
||||
|
||||
@@ -6,9 +6,6 @@ export function getDiagramBoxStyle(style: TextStyle): ViewStyle {
|
||||
borderColor: style.borderColor,
|
||||
borderRadius: style.borderRadius,
|
||||
borderWidth: style.borderWidth,
|
||||
marginBottom: style.marginBottom,
|
||||
marginTop: style.marginTop,
|
||||
marginVertical: style.marginVertical,
|
||||
padding: style.padding,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import React, { type ReactElement } from "react";
|
||||
import { View } from "react-native";
|
||||
import { markdownCopyImageSourcesDataSet } from "@/assistant-selection-copy/markup";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export function MarkdownRendererFrame({
|
||||
imageSources,
|
||||
children,
|
||||
}: {
|
||||
imageSources: Record<string, string>;
|
||||
children: ReactElement;
|
||||
}) {
|
||||
return (
|
||||
<View dataSet={isWeb ? markdownCopyImageSourcesDataSet(imageSources) : undefined}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -307,7 +307,7 @@ describe("shared Markdown theme colours", () => {
|
||||
["dark", darkTheme],
|
||||
["light", lightTheme],
|
||||
] as const)("uses %s theme colours for tables and task labels", (_name, theme) => {
|
||||
const { markdownStyle: style } = createMarkdownStyles(theme);
|
||||
const { markdownStyle: style } = createMarkdownStyles(theme, { blockGap: 16 });
|
||||
expect(style.table).toMatchObject({
|
||||
color: theme.colors.foreground,
|
||||
headerTextColor: theme.colors.foreground,
|
||||
@@ -337,7 +337,7 @@ it.each([
|
||||
] as const)(
|
||||
"uses %s theme colours for quotes, admonitions, code, links and dividers",
|
||||
(_name, theme) => {
|
||||
const { markdownStyle: style } = createMarkdownStyles(theme);
|
||||
const { markdownStyle: style } = createMarkdownStyles(theme, { blockGap: 16 });
|
||||
expect(style.blockquote).toMatchObject({
|
||||
color: `${theme.colors.foreground}cc`,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
@@ -364,7 +364,7 @@ it.each([
|
||||
);
|
||||
|
||||
it.each([darkTheme, lightTheme])("supplies the theme selection colour to the renderer", (theme) => {
|
||||
expect(createMarkdownStyles(theme)).toHaveProperty(
|
||||
expect(createMarkdownStyles(theme, { blockGap: 16 })).toHaveProperty(
|
||||
"selectionColor",
|
||||
theme.colors.terminal.selectionBackground,
|
||||
);
|
||||
@@ -431,7 +431,7 @@ it("preserves backslashes and decodes entities inside HTML code", () => {
|
||||
it.each([false, true])(
|
||||
"caps inline images to the paragraph line height (compact: %s)",
|
||||
(compact) => {
|
||||
const { markdownStyle } = createMarkdownStyles(darkTheme, compact);
|
||||
const { markdownStyle } = createMarkdownStyles(darkTheme, { compact, blockGap: 16 });
|
||||
expect(markdownStyle.inlineImage?.size).toBe(markdownStyle.paragraph?.lineHeight);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9,7 +9,8 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { MarkdownRendererFrame } from "./frame";
|
||||
import { density, resolveDensity } from "@/styles/density";
|
||||
import { MarkdownBlockStack } from "./block-stack";
|
||||
import { WEB_RENDERERS } from "./renderers";
|
||||
import { createMarkdownStyles } from "./styles";
|
||||
import { PENDING_MARKDOWN_IMAGE } from "./source";
|
||||
@@ -21,8 +22,11 @@ import { MermaidFence, MermaidStreamingContext, splitMermaidFences } from "./fen
|
||||
const ThemedMarkdown = withUnistyles(EnrichedMarkdownText);
|
||||
const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true };
|
||||
|
||||
const regularStyle = (theme: Theme) => createMarkdownStyles(theme);
|
||||
const compactStyle = (theme: Theme) => createMarkdownStyles(theme, true);
|
||||
// Reading the breakpoint here makes Unistyles restyle the text when the layout crosses it.
|
||||
const markdownStyle = (compact: boolean) => (theme: Theme, rt: { breakpoint?: string }) =>
|
||||
createMarkdownStyles(theme, { compact, blockGap: resolveDensity(rt, density.blockGap) });
|
||||
const regularStyle = markdownStyle(false);
|
||||
const compactStyle = markdownStyle(true);
|
||||
const WEB_CONTAINER = { userSelect: "text" as const };
|
||||
|
||||
export interface MarkdownRendererProps extends MarkdownImageContext {
|
||||
@@ -43,21 +47,20 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe
|
||||
);
|
||||
if (!streaming || boundary === 0) return <MarkdownPart {...props} />;
|
||||
return (
|
||||
<>
|
||||
<MarkdownPart {...props} text={props.text.slice(0, boundary)} continues />
|
||||
<MarkdownBlockStack>
|
||||
<MarkdownPart {...props} text={props.text.slice(0, boundary)} />
|
||||
<MarkdownPart {...props} text={props.text.slice(boundary)} />
|
||||
</>
|
||||
</MarkdownBlockStack>
|
||||
);
|
||||
});
|
||||
|
||||
const MarkdownPart = memo(function MarkdownPart({
|
||||
text,
|
||||
continues = false,
|
||||
compact = false,
|
||||
streaming = false,
|
||||
onLinkPress,
|
||||
...imageContext
|
||||
}: MarkdownRendererProps & { continues?: boolean }) {
|
||||
}: MarkdownRendererProps) {
|
||||
const environment = useAssistantFileLinkEnvironment();
|
||||
const actions = useAssistantFileLinkActions();
|
||||
const client =
|
||||
@@ -86,35 +89,35 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
isWeb ? [{ kind: "markdown", id: "0", source: markdown }] : splitMermaidFences(markdown),
|
||||
[markdown],
|
||||
);
|
||||
const content = (
|
||||
return (
|
||||
<MermaidStreamingContext.Provider value={streaming}>
|
||||
{segments.map((segment) =>
|
||||
segment.kind === "mermaid" ? (
|
||||
<MermaidFence key={`${segment.kind}:${segment.id}`} code={segment.source} />
|
||||
) : (
|
||||
<ThemedMarkdown
|
||||
key={`${segment.kind}:${segment.id}`}
|
||||
markdown={segment.source}
|
||||
containerStyle={isWeb ? WEB_CONTAINER : undefined}
|
||||
uniProps={compact ? compactStyle : regularStyle}
|
||||
flavor="github"
|
||||
selectable
|
||||
allowTrailingMargin={continues && segment === segments[segments.length - 1]}
|
||||
md4cFlags={MD4C_FLAGS}
|
||||
linkRecognition={linkRecognition}
|
||||
enableTaskListItemToggle={false}
|
||||
enableLinkPreview={false}
|
||||
enableBlockContextMenu={false}
|
||||
// JS reveal owns pacing; native fade animation is a separate experiment.
|
||||
streamingAnimation={false}
|
||||
onLinkPress={pressLink}
|
||||
onImagePress={pressImage}
|
||||
renderers={isWeb ? WEB_RENDERERS : undefined}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<MarkdownBlockStack imageSources={imageSources}>
|
||||
{segments.map((segment) =>
|
||||
segment.kind === "mermaid" ? (
|
||||
<MermaidFence key={`${segment.kind}:${segment.id}`} code={segment.source} />
|
||||
) : (
|
||||
<ThemedMarkdown
|
||||
key={`${segment.kind}:${segment.id}`}
|
||||
markdown={segment.source}
|
||||
containerStyle={isWeb ? WEB_CONTAINER : undefined}
|
||||
uniProps={compact ? compactStyle : regularStyle}
|
||||
flavor="github"
|
||||
selectable
|
||||
md4cFlags={MD4C_FLAGS}
|
||||
linkRecognition={linkRecognition}
|
||||
enableTaskListItemToggle={false}
|
||||
enableLinkPreview={false}
|
||||
enableBlockContextMenu={false}
|
||||
// JS reveal owns pacing; native fade animation is a separate experiment.
|
||||
streamingAnimation={false}
|
||||
onLinkPress={pressLink}
|
||||
onImagePress={pressImage}
|
||||
renderers={isWeb ? WEB_RENDERERS : undefined}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</MarkdownBlockStack>
|
||||
{image ? <AttachmentLightbox source={image} onClose={closeImage} /> : null}
|
||||
</MermaidStreamingContext.Provider>
|
||||
);
|
||||
return <MarkdownRendererFrame imageSources={imageSources}>{content}</MarkdownRendererFrame>;
|
||||
});
|
||||
|
||||
@@ -4,23 +4,23 @@ import { isWeb } from "@/constants/platform";
|
||||
|
||||
export function createMarkdownStyles(
|
||||
theme: Theme,
|
||||
compact = false,
|
||||
{ compact = false, blockGap }: { compact?: boolean; blockGap: number },
|
||||
): { markdownStyle: MarkdownStyle; selectionColor: string } {
|
||||
const fontSize = compact ? theme.fontSize.base : theme.fontSize.content;
|
||||
// Every block is followed by the same gap; the library drops it after the last block.
|
||||
const block = { marginTop: 0, marginBottom: blockGap };
|
||||
const paragraph = {
|
||||
fontSize,
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
lineHeight: Math.round(fontSize * 1.4),
|
||||
color: theme.colors.foreground,
|
||||
marginTop: 0,
|
||||
marginBottom: theme.spacing[3],
|
||||
...block,
|
||||
};
|
||||
const heading = (scale: number) => ({
|
||||
...paragraph,
|
||||
fontSize: Math.round(fontSize * scale),
|
||||
lineHeight: Math.round(fontSize * scale * 1.3),
|
||||
fontWeight: "bold",
|
||||
marginTop: theme.spacing[3],
|
||||
});
|
||||
return {
|
||||
selectionColor: theme.colors.terminal.selectionBackground,
|
||||
@@ -93,8 +93,8 @@ export function createMarkdownStyles(
|
||||
borderColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
inlineImage: { size: paragraph.lineHeight },
|
||||
image: { height: 320, resizeMode: "contain", borderRadius: 8 },
|
||||
thematicBreak: { color: theme.colors.border, height: 1, marginTop: 12, marginBottom: 12 },
|
||||
image: { ...block, height: 320, resizeMode: "contain", borderRadius: 8 },
|
||||
thematicBreak: { ...block, color: theme.colors.border, height: 1 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,15 +26,22 @@ export const density = {
|
||||
tightRowHeight: byLayout(36, 28),
|
||||
/** A square icon-only button that sits in a row or toolbar. */
|
||||
iconButton: byLayout(44, 28),
|
||||
/** The space between two blocks of rich text, such as Markdown paragraphs, code and images. */
|
||||
blockGap: byLayout(12, 16),
|
||||
};
|
||||
|
||||
/** Resolves a density value for a prop, which Unistyles cannot resolve on its own. */
|
||||
export function resolveDensity<T>(rt: { breakpoint?: string }, value: { xs: T; md: T }): T {
|
||||
return isCompactBreakpoint(rt.breakpoint) ? value.xs : value.md;
|
||||
}
|
||||
|
||||
/** The width or height of a slot that holds an icon of `size`. */
|
||||
export function densityIconBox(size: DensityIconSize) {
|
||||
return byLayout(COMPACT_ICON_SIZE[size], ICON_SIZE[size]);
|
||||
}
|
||||
|
||||
export function densityIconSize(rt: { breakpoint?: string }, size: DensityIconSize): number {
|
||||
return isCompactBreakpoint(rt.breakpoint) ? COMPACT_ICON_SIZE[size] : ICON_SIZE[size];
|
||||
return resolveDensity(rt, densityIconBox(size));
|
||||
}
|
||||
|
||||
/** Extends a `withUnistyles` icon mapping so the icon also takes its density size. */
|
||||
|
||||
@@ -1333,6 +1333,18 @@ index dd70713..ca0385a 100644
|
||||
const label = language ? `Code block: ${language}` : 'Code block';
|
||||
if (callbacks.onCodeBlockPress == null) {
|
||||
return /*#__PURE__*/_jsx("pre", {
|
||||
@@ -214,7 +230,10 @@ function ImageRenderer({
|
||||
src: url,
|
||||
alt: alt,
|
||||
title: title,
|
||||
- style: imgStyle,
|
||||
+ style: {
|
||||
+ ...imgStyle,
|
||||
+ cursor: 'zoom-in'
|
||||
+ },
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
"aria-label": alt,
|
||||
diff --git a/node_modules/react-native-enriched-markdown/lib/module/web/renderers/BlockRenderers.js.map b/node_modules/react-native-enriched-markdown/lib/module/web/renderers/BlockRenderers.js.map
|
||||
index dc917c4..cf59150 100644
|
||||
--- a/node_modules/react-native-enriched-markdown/lib/module/web/renderers/BlockRenderers.js.map
|
||||
|
||||
@@ -60,30 +60,37 @@ if (!existsSync("patches") || installedPackages.length === 0) {
|
||||
|
||||
const patchFiles = readdirSync("patches").filter((file) => file.endsWith(".patch"));
|
||||
|
||||
// Group patch files by the directory patch-package must run from.
|
||||
const patchFilesByCwd = new Map();
|
||||
for (const { patchPrefix, cwd = "." } of installedPackages) {
|
||||
const isWindows = process.platform === "win32";
|
||||
const cmd = isWindows ? "patch-package.cmd" : "patch-package";
|
||||
|
||||
let failed = false;
|
||||
for (const [index, { nodeModulesPath, patchPrefix, cwd = "." }] of installedPackages.entries()) {
|
||||
const files = patchFiles.filter((file) => file.startsWith(patchPrefix));
|
||||
if (files.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const group = patchFilesByCwd.get(cwd) ?? [];
|
||||
group.push(...files);
|
||||
patchFilesByCwd.set(cwd, group);
|
||||
const outcome = applyPatches(cwd, files, index);
|
||||
if (outcome === "applied") {
|
||||
continue;
|
||||
}
|
||||
failed = true;
|
||||
if (outcome === "unavailable") {
|
||||
continue;
|
||||
}
|
||||
// patch-package cannot apply a patch that changed after an earlier version of it was
|
||||
// applied. Without the package, the next install fetches it clean and applies the patch.
|
||||
rmSync(nodeModulesPath, { recursive: true, force: true });
|
||||
console.error(
|
||||
`postinstall-patches: removed ${nodeModulesPath} because its patch did not apply. ` +
|
||||
"Run npm install again to reinstall it with the current patch. " +
|
||||
"If it fails again on the clean package, the patch is out of date and must be regenerated.",
|
||||
);
|
||||
}
|
||||
|
||||
if (patchFilesByCwd.size === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
const cmd = isWindows ? "patch-package.cmd" : "patch-package";
|
||||
|
||||
let groupIndex = 0;
|
||||
for (const [cwd, files] of patchFilesByCwd) {
|
||||
groupIndex += 1;
|
||||
const tempPatchDir = join(".tmp", `postinstall-patches-${process.pid}-${groupIndex}`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
||||
function applyPatches(cwd, files, index) {
|
||||
const tempPatchDir = join(".tmp", `postinstall-patches-${process.pid}-${index}`);
|
||||
mkdirSync(tempPatchDir, { recursive: true });
|
||||
for (const patchFile of files) {
|
||||
copyFileSync(join("patches", patchFile), join(tempPatchDir, patchFile));
|
||||
@@ -91,7 +98,7 @@ for (const [cwd, files] of patchFilesByCwd) {
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = spawnSync(cmd, ["--patch-dir", relative(cwd, tempPatchDir)], {
|
||||
result = spawnSync(cmd, ["--patch-dir", relative(cwd, tempPatchDir), "--error-on-fail"], {
|
||||
cwd,
|
||||
shell: isWindows,
|
||||
stdio: "inherit",
|
||||
@@ -103,10 +110,7 @@ for (const [cwd, files] of patchFilesByCwd) {
|
||||
|
||||
if (result.error) {
|
||||
console.error("postinstall-patches: patch-package failed to spawn:", result.error.message);
|
||||
return "unavailable";
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
return result.status === 0 ? "applied" : "rejected";
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
|
||||
const script = path.resolve("scripts", "postinstall-patches.mjs");
|
||||
const bin = path.resolve("node_modules", ".bin");
|
||||
const packageName = "react-native-draggable-flatlist";
|
||||
const patchFile = `${packageName}+4.0.3.patch`;
|
||||
|
||||
function createCheckout() {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "postinstall-patches-"));
|
||||
writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "checkout" }));
|
||||
const packageDir = path.join(root, "node_modules", packageName);
|
||||
mkdirSync(packageDir, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(packageDir, "package.json"),
|
||||
JSON.stringify({ name: packageName, version: "4.0.3" }),
|
||||
);
|
||||
writeFileSync(path.join(packageDir, "index.js"), "export const value = 1;\n");
|
||||
mkdirSync(path.join(root, "patches"));
|
||||
return { root, packageDir };
|
||||
}
|
||||
|
||||
function writePatch(root, from, to) {
|
||||
const file = `node_modules/${packageName}/index.js`;
|
||||
writeFileSync(
|
||||
path.join(root, "patches", patchFile),
|
||||
[
|
||||
`diff --git a/${file} b/${file}`,
|
||||
`--- a/${file}`,
|
||||
`+++ b/${file}`,
|
||||
"@@ -1 +1 @@",
|
||||
`-export const value = ${from};`,
|
||||
`+export const value = ${to};`,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function install(root, PATH = `${bin}${path.delimiter}${process.env.PATH}`) {
|
||||
return spawnSync(process.execPath, [script], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, PATH },
|
||||
});
|
||||
}
|
||||
|
||||
test("a patch that changed since it was applied removes the package for a clean reinstall", () => {
|
||||
const { root, packageDir } = createCheckout();
|
||||
try {
|
||||
writePatch(root, 1, 2);
|
||||
assert.equal(install(root).status, 0);
|
||||
assert.equal(
|
||||
readFileSync(path.join(packageDir, "index.js"), "utf8"),
|
||||
"export const value = 2;\n",
|
||||
);
|
||||
|
||||
writePatch(root, 1, 3);
|
||||
const result = install(root);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /Run npm install again/);
|
||||
assert.equal(existsSync(packageDir), false);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("an already applied patch leaves the package in place", () => {
|
||||
const { root, packageDir } = createCheckout();
|
||||
try {
|
||||
writePatch(root, 1, 2);
|
||||
assert.equal(install(root).status, 0);
|
||||
assert.equal(install(root).status, 0);
|
||||
assert.equal(
|
||||
readFileSync(path.join(packageDir, "index.js"), "utf8"),
|
||||
"export const value = 2;\n",
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a missing patch-package fails the install and leaves the package in place", () => {
|
||||
const { root, packageDir } = createCheckout();
|
||||
try {
|
||||
writePatch(root, 1, 2);
|
||||
const result = install(root, root);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /failed to spawn/);
|
||||
assert.equal(existsSync(packageDir), true);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user