fix(plugins): restore native plugin setup flows (#3942)

Plugin client bundles bypass Metro, so authored async callbacks reached Hermes without Metro's syntax lowering. Plugin connection inputs also selected bottom-sheet behavior from device form factor instead of their actual render scope.
This commit is contained in:
Mohamed Boudra
2026-08-27 12:50:25 +02:00
committed by GitHub
parent 8e0af11b66
commit 12e3acd188
9 changed files with 232 additions and 116 deletions
@@ -1,7 +1,5 @@
import React, { forwardRef, useCallback, useEffect, useRef } from "react";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { createControlGeometry } from "@/components/ui/control-geometry";
import {
EditingTextInput,
@@ -29,7 +27,6 @@ const ThemedTextInput = withUnistyles(EditingTextInput, (theme) => ({
export const AdaptiveTextInput = forwardRef<EditingTextInputHandle, AdaptiveTextInputProps>(
function AdaptiveTextInputInner(props, ref) {
const isMobile = useIsCompactFormFactor();
const { initialValue, resetKey, style, onChangeText, ...inputProps } = props;
const inputRef = useRef<EditingTextInputHandle | null>(null);
const replacementTextRef = useRef(initialValue ?? "");
@@ -57,7 +54,6 @@ export const AdaptiveTextInput = forwardRef<EditingTextInputHandle, AdaptiveText
initialValue={initialValue}
onChangeText={onChangeText}
style={[styles.outline, style, styles.text]}
variant={isMobile && isNative ? "bottom-sheet" : "default"}
/>
);
},
@@ -58,6 +58,7 @@ vi.mock("@gorhom/bottom-sheet", async () => {
});
import { IsolatedBottomSheetModal, type ContextBridge } from ".";
import { useIsInsideBottomSheet } from "@/components/ui/text-input/bottom-sheet-scope";
/** Renders whatever the portal is currently holding, from its own place in the tree. */
function PortalHostProbe() {
@@ -71,6 +72,10 @@ function Label() {
return <div data-label={useContext(LabelContext)} />;
}
function BottomSheetScopeProbe() {
return <div data-inside-bottom-sheet={useIsInsideBottomSheet().toString()} />;
}
describe("IsolatedBottomSheetModal presentation", () => {
let container: HTMLDivElement;
let root: Root;
@@ -174,4 +179,24 @@ describe("IsolatedBottomSheetModal context bridging", () => {
);
expect(renderSheet(bridge)).toBe("from callsite");
});
it("marks content rendered through the modal as inside a bottom sheet", () => {
act(() => {
root.render(
<>
<BottomSheetScopeProbe />
<IsolatedBottomSheetModal contextBridge={null}>
<BottomSheetScopeProbe />
</IsolatedBottomSheetModal>
<PortalHostProbe />
</>,
);
});
expect(
Array.from(container.querySelectorAll("[data-inside-bottom-sheet]")).map((node) =>
node.getAttribute("data-inside-bottom-sheet"),
),
).toEqual(["false", "true"]);
});
});
@@ -9,6 +9,7 @@ import {
type BottomSheetController,
createBottomSheetVisibilityTracker,
} from "./visibility-tracker";
import { BottomSheetTextInputScope } from "@/components/ui/text-input/bottom-sheet-scope";
type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
@@ -64,7 +65,9 @@ export const IsolatedBottomSheetModal = forwardRef<
enableDismissOnClose
stackBehavior={presentation}
>
{contextBridge ? contextBridge(children) : children}
<BottomSheetTextInputScope>
{contextBridge ? contextBridge(children) : children}
</BottomSheetTextInputScope>
</GorhomBottomSheetModal>
);
@@ -0,0 +1,11 @@
import React, { createContext, useContext, type ReactNode } from "react";
const BottomSheetScopeContext = createContext(false);
export function BottomSheetTextInputScope({ children }: { children: ReactNode }) {
return <BottomSheetScopeContext.Provider value>{children}</BottomSheetScopeContext.Provider>;
}
export function useIsInsideBottomSheet(): boolean {
return useContext(BottomSheetScopeContext);
}
@@ -1,14 +1,43 @@
// @vitest-environment jsdom
import React, { act, createRef } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { EditingTextInput } from "./text-input";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BottomSheetTextInputScope } from "./bottom-sheet-scope";
import { EditingTextInput } from "./text-input.native";
import type { EditingTextInputHandle } from "./types";
const bottomSheetTextInputRender = vi.hoisted(() => vi.fn());
vi.mock("@gorhom/bottom-sheet", async () => {
const ReactModule = await import("react");
return {
BottomSheetTextInput: ReactModule.forwardRef<HTMLInputElement, Record<string, unknown>>(
(props, ref) => {
bottomSheetTextInputRender(props);
return ReactModule.createElement("input", {
...props,
ref,
"data-bottom-sheet-input": true,
});
},
),
};
});
vi.mock("@mattermost/react-native-paste-input", async () => {
const ReactModule = await import("react");
return {
default: ReactModule.forwardRef<HTMLInputElement, Record<string, unknown>>((props, ref) =>
ReactModule.createElement("input", { ...props, ref }),
),
};
});
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => {
bottomSheetTextInputRender.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
@@ -28,6 +57,25 @@ afterEach(() => {
function noop() {}
describe("EditingTextInputNative", () => {
it("uses the bottom-sheet input only inside a bottom sheet", () => {
act(() => {
root?.render(
<>
<EditingTextInput testID="outside" />
<BottomSheetTextInputScope>
<EditingTextInput testID="inside" />
</BottomSheetTextInputScope>
</>,
);
});
expect(
container?.querySelector('[data-testid="outside"]')?.getAttribute("data-bottom-sheet-input"),
).toBeNull();
expect(bottomSheetTextInputRender).toHaveBeenCalledOnce();
expect(bottomSheetTextInputRender.mock.calls[0]?.[0]).toMatchObject({ testID: "inside" });
});
it("clears text via clear() when replaceText receives an empty string", () => {
const handleRef = createRef<EditingTextInputHandle>();
@@ -0,0 +1,111 @@
import React, { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { TextInput } from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import PasteInput, {
type PastedFile,
type PasteTextInputInstance,
} from "@mattermost/react-native-paste-input";
import { useIsInsideBottomSheet } from "./bottom-sheet-scope";
import type { EditingTextInputHandle, EditingTextInputProps } from "./types";
type NativeInput = (TextInput | PasteTextInputInstance) & {
blur(): void;
focus(): void;
isFocused(): boolean;
clear?(): void;
replaceText?(text: string, selection?: { start: number; end: number }): void;
setNativeProps?(props: { text?: string; selection?: { start: number; end: number } }): void;
setSelection?(start: number, end: number): void;
getNativeRef?(): unknown;
};
export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextInputProps>(
function EditingTextInputNative(allProps, ref) {
const isInsideBottomSheet = useIsInsideBottomSheet();
const {
initialValue = "",
onChangeText,
onPasteImages,
onPasteError,
variant = isInsideBottomSheet ? "bottom-sheet" : "default",
value: _,
defaultValue: __,
...props
} = allProps as EditingTextInputProps & { value?: unknown; defaultValue?: unknown };
const inputRef = useRef<NativeInput | null>(null);
const initialTextRef = useRef(initialValue);
const textRef = useRef(initialTextRef.current);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
blur: () => inputRef.current?.blur(),
isFocused: () => inputRef.current?.isFocused() ?? false,
getText: () => textRef.current,
replaceText: (nextText, selection) => {
textRef.current = nextText;
if (inputRef.current?.replaceText) {
inputRef.current.replaceText(nextText, selection);
return;
}
if (nextText === "") {
inputRef.current?.clear?.();
} else {
inputRef.current?.setNativeProps?.({
text: nextText,
...(selection ? { selection } : {}),
});
}
if (selection) inputRef.current?.setSelection?.(selection.start, selection.end);
},
getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current,
}));
const handleChangeText = useCallback(
(nextText: string) => {
textRef.current = nextText;
onChangeText?.(nextText);
},
[onChangeText],
);
const handlePaste = useCallback(
(error: string | null | undefined, files: PastedFile[]) => {
if (error) {
onPasteError?.(error);
} else if (files.length > 0) {
onPasteImages?.(files);
}
},
[onPasteError, onPasteImages],
);
if (onPasteImages || onPasteError) {
return (
<PasteInput
{...props}
ref={inputRef as React.Ref<PasteTextInputInstance>}
defaultValue={initialTextRef.current}
onChangeText={handleChangeText}
onPaste={handlePaste}
/>
);
}
if (variant === "bottom-sheet") {
return (
<BottomSheetTextInput
{...props}
ref={inputRef as unknown as React.Ref<never>}
defaultValue={initialTextRef.current}
onChangeText={handleChangeText}
/>
);
}
return (
<TextInput
{...props}
ref={inputRef as React.Ref<TextInput>}
defaultValue={initialTextRef.current}
onChangeText={handleChangeText}
/>
);
},
);
@@ -1,109 +1 @@
import React, { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { TextInput } from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import PasteInput, {
type PastedFile,
type PasteTextInputInstance,
} from "@mattermost/react-native-paste-input";
import type { EditingTextInputHandle, EditingTextInputProps } from "./types";
type NativeInput = (TextInput | PasteTextInputInstance) & {
blur(): void;
focus(): void;
isFocused(): boolean;
clear?(): void;
replaceText?(text: string, selection?: { start: number; end: number }): void;
setNativeProps?(props: { text?: string; selection?: { start: number; end: number } }): void;
setSelection?(start: number, end: number): void;
getNativeRef?(): unknown;
};
export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextInputProps>(
function EditingTextInputNative(allProps, ref) {
const {
initialValue = "",
onChangeText,
onPasteImages,
onPasteError,
variant = "default",
value: _,
defaultValue: __,
...props
} = allProps as EditingTextInputProps & { value?: unknown; defaultValue?: unknown };
const inputRef = useRef<NativeInput | null>(null);
const initialTextRef = useRef(initialValue);
const textRef = useRef(initialTextRef.current);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
blur: () => inputRef.current?.blur(),
isFocused: () => inputRef.current?.isFocused() ?? false,
getText: () => textRef.current,
replaceText: (nextText, selection) => {
textRef.current = nextText;
if (inputRef.current?.replaceText) {
inputRef.current.replaceText(nextText, selection);
return;
}
if (nextText === "") {
inputRef.current?.clear?.();
} else {
inputRef.current?.setNativeProps?.({
text: nextText,
...(selection ? { selection } : {}),
});
}
if (selection) inputRef.current?.setSelection?.(selection.start, selection.end);
},
getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current,
}));
const handleChangeText = useCallback(
(nextText: string) => {
textRef.current = nextText;
onChangeText?.(nextText);
},
[onChangeText],
);
const handlePaste = useCallback(
(error: string | null | undefined, files: PastedFile[]) => {
if (error) {
onPasteError?.(error);
} else if (files.length > 0) {
onPasteImages?.(files);
}
},
[onPasteError, onPasteImages],
);
if (onPasteImages || onPasteError) {
return (
<PasteInput
{...props}
ref={inputRef as React.Ref<PasteTextInputInstance>}
defaultValue={initialTextRef.current}
onChangeText={handleChangeText}
onPaste={handlePaste}
/>
);
}
if (variant === "bottom-sheet") {
return (
<BottomSheetTextInput
{...props}
ref={inputRef as unknown as React.Ref<never>}
defaultValue={initialTextRef.current}
onChangeText={handleChangeText}
/>
);
}
return (
<TextInput
{...props}
ref={inputRef as React.Ref<TextInput>}
defaultValue={initialTextRef.current}
onChangeText={handleChangeText}
/>
);
},
);
export { EditingTextInput } from "./text-input.native";
@@ -145,3 +145,30 @@ describe("plugin contribution targets", () => {
expect(serverBundle).not.toContain("timeline-card");
});
});
describe("plugin client runtime syntax", () => {
it("lowers async callbacks before Hermes evaluates the client bundle", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-"));
temporaryDirectories.push(directory);
const entryPath = path.join(directory, "index.tsx");
await writeFile(
entryPath,
`import type { PluginContext } from "@getpaseo/plugin";
export default function contribute(plugin: PluginContext) {
plugin.addSurface("probe", () => {
const refresh = async () => "ready";
return refresh;
});
plugin.handle({ name: "probe" } as never, async () => "ready");
return () => undefined;
}
`,
);
const { clientBundle, serverBundle } = await compilePlugin(entryPath);
expect(clientBundle).not.toContain("async () =>");
expect(clientBundle).toContain("__async");
expect(serverBundle).toContain("async () =>");
});
});
@@ -308,6 +308,9 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
format: "cjs",
platform: target === "server" ? "node" : "neutral",
target: target === "server" ? "node20" : "es2020",
// Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro,
// so apply the same compatibility transform before the app evaluates them from source.
supported: target === "client" ? { "async-await": false } : undefined,
external:
target === "client"
? [