diff --git a/packages/app/src/components/adaptive-text-input.tsx b/packages/app/src/components/adaptive-text-input.tsx index b100c366c9..aa33fb31b7 100644 --- a/packages/app/src/components/adaptive-text-input.tsx +++ b/packages/app/src/components/adaptive-text-input.tsx @@ -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( function AdaptiveTextInputInner(props, ref) { - const isMobile = useIsCompactFormFactor(); const { initialValue, resetKey, style, onChangeText, ...inputProps } = props; const inputRef = useRef(null); const replacementTextRef = useRef(initialValue ?? ""); @@ -57,7 +54,6 @@ export const AdaptiveTextInput = forwardRef ); }, diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx index e0ed8e6e26..7567601eca 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx @@ -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
; } +function BottomSheetScopeProbe() { + return
; +} + 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( + <> + + + + + + , + ); + }); + + expect( + Array.from(container.querySelectorAll("[data-inside-bottom-sheet]")).map((node) => + node.getAttribute("data-inside-bottom-sheet"), + ), + ).toEqual(["false", "true"]); + }); }); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx index b1bee58c3b..1f70a6babe 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx @@ -9,6 +9,7 @@ import { type BottomSheetController, createBottomSheetVisibilityTracker, } from "./visibility-tracker"; +import { BottomSheetTextInputScope } from "@/components/ui/text-input/bottom-sheet-scope"; type GorhomBottomSheetModalMethods = ElementRef; @@ -64,7 +65,9 @@ export const IsolatedBottomSheetModal = forwardRef< enableDismissOnClose stackBehavior={presentation} > - {contextBridge ? contextBridge(children) : children} + + {contextBridge ? contextBridge(children) : children} + ); diff --git a/packages/app/src/components/ui/text-input/bottom-sheet-scope.tsx b/packages/app/src/components/ui/text-input/bottom-sheet-scope.tsx new file mode 100644 index 0000000000..1d25bf7598 --- /dev/null +++ b/packages/app/src/components/ui/text-input/bottom-sheet-scope.tsx @@ -0,0 +1,11 @@ +import React, { createContext, useContext, type ReactNode } from "react"; + +const BottomSheetScopeContext = createContext(false); + +export function BottomSheetTextInputScope({ children }: { children: ReactNode }) { + return {children}; +} + +export function useIsInsideBottomSheet(): boolean { + return useContext(BottomSheetScopeContext); +} diff --git a/packages/app/src/components/ui/text-input/text-input.native.test.tsx b/packages/app/src/components/ui/text-input/text-input.native.test.tsx index fa1d47bb4a..8b9d976748 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.test.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.test.tsx @@ -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>( + (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>((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( + <> + + + + + , + ); + }); + + 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(); diff --git a/packages/app/src/components/ui/text-input/text-input.native.tsx b/packages/app/src/components/ui/text-input/text-input.native.tsx new file mode 100644 index 0000000000..4b97bcdc29 --- /dev/null +++ b/packages/app/src/components/ui/text-input/text-input.native.tsx @@ -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( + 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(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 ( + } + defaultValue={initialTextRef.current} + onChangeText={handleChangeText} + onPaste={handlePaste} + /> + ); + } + if (variant === "bottom-sheet") { + return ( + } + defaultValue={initialTextRef.current} + onChangeText={handleChangeText} + /> + ); + } + return ( + } + defaultValue={initialTextRef.current} + onChangeText={handleChangeText} + /> + ); + }, +); diff --git a/packages/app/src/components/ui/text-input/text-input.tsx b/packages/app/src/components/ui/text-input/text-input.tsx index 6ab169550b..eb3f68be82 100644 --- a/packages/app/src/components/ui/text-input/text-input.tsx +++ b/packages/app/src/components/ui/text-input/text-input.tsx @@ -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( - function EditingTextInputNative(allProps, ref) { - const { - initialValue = "", - onChangeText, - onPasteImages, - onPasteError, - variant = "default", - value: _, - defaultValue: __, - ...props - } = allProps as EditingTextInputProps & { value?: unknown; defaultValue?: unknown }; - const inputRef = useRef(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 ( - } - defaultValue={initialTextRef.current} - onChangeText={handleChangeText} - onPaste={handlePaste} - /> - ); - } - if (variant === "bottom-sheet") { - return ( - } - defaultValue={initialTextRef.current} - onChangeText={handleChangeText} - /> - ); - } - return ( - } - defaultValue={initialTextRef.current} - onChangeText={handleChangeText} - /> - ); - }, -); +export { EditingTextInput } from "./text-input.native"; diff --git a/packages/server/src/server/plugins/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts index 27c6121d3a..f79fbe1812 100644 --- a/packages/server/src/server/plugins/compiler.test.ts +++ b/packages/server/src/server/plugins/compiler.test.ts @@ -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 () =>"); + }); +}); diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index 467ee8a87c..499271fe37 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -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" ? [