mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 21:23:04 +00:00
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:
@@ -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"
|
||||
? [
|
||||
|
||||
Reference in New Issue
Block a user