feat: port ErrorBoundary from dev

This commit is contained in:
pylixonly 2024-10-21 17:37:07 +08:00
parent a20735466d
commit f44791049a
13 changed files with 475 additions and 185 deletions

View file

@ -0,0 +1,21 @@
import ErrorBoundaryScreen from "@core/ui/reporter/components/ErrorBoundaryScreen";
import { after } from "@lib/api/patcher";
import { _lazyContextSymbol } from "@metro/lazy";
import { LazyModuleContext } from "@metro/types";
import { findByNameLazy } from "@metro/wrappers";
function getErrorBoundaryContext() {
const ctxt: LazyModuleContext = findByNameLazy("ErrorBoundary")[_lazyContextSymbol];
return new Promise(resolve => ctxt.getExports(exp => resolve(exp.prototype)));
}
export default function patchErrorBoundary() {
return after.await("render", getErrorBoundaryContext(), function (this: any) {
if (!this.state.error) return;
return <ErrorBoundaryScreen
error={this.state.error}
rerender={() => this.setState({ info: null, error: null })}
/>;
});
}

View file

@ -0,0 +1,52 @@
import { hasStack, isComponentStack } from "@core/ui/reporter/utils/isStack";
import { getDebugInfo, toggleSafeMode } from "@lib/api/debug";
import { BundleUpdaterManager } from "@lib/api/native/modules";
import { settings } from "@lib/api/settings";
import { Codeblock, ErrorBoundary } from "@lib/ui/components";
import { createStyles } from "@lib/ui/styles";
import { tokens } from "@metro/common";
import { Button, Card, SafeAreaView, Text } from "@metro/common/components";
import { ScrollView, View } from "react-native";
import ErrorComponentStackCard from "./ErrorComponentStackCard";
import ErrorStackCard from "./ErrorStackCard";
const useStyles = createStyles({
container: {
flex: 1,
backgroundColor: tokens.colors.BACKGROUND_PRIMARY,
paddingHorizontal: 16,
height: "100%",
padding: 8,
gap: 12
}
});
export default function ErrorBoundaryScreen(props: {
error: Error;
rerender: () => void;
}) {
const styles = useStyles();
const debugInfo = getDebugInfo();
return <ErrorBoundary>
<SafeAreaView style={styles.container}>
<View style={{ gap: 4 }}>
<Text variant="display-lg">Uh oh.</Text>
<Text variant="text-md/normal">A crash occured while rendering a component. This could be caused by a plugin, Bunny or Discord itself.</Text>
<Text variant="text-sm/normal" color="text-muted">{debugInfo.os.name}; {debugInfo.discord.build} ({debugInfo.discord.version}); {debugInfo.bunny.version}</Text>
</View>
<ScrollView fadingEdgeLength={64} contentContainerStyle={{ gap: 12 }}>
<Codeblock selectable={true}>{props.error.message}</Codeblock>
{hasStack(props.error) && <ErrorStackCard error={props.error} />}
{isComponentStack(props.error) ? <ErrorComponentStackCard componentStack={props.error.componentStack} /> : null}
</ScrollView>
<Card style={{ gap: 6 }}>
<Button text="Reload Discord" onPress={() => BundleUpdaterManager.reload()} />
{!settings.safeMode?.enabled && <Button text="Reload in Safe Mode" onPress={() => toggleSafeMode()} />}
<Button variant="destructive" text="Retry Render" onPress={() => props.rerender()} />
</Card>
</SafeAreaView>
</ErrorBoundary>;
}

View file

@ -0,0 +1,44 @@
import { Strings } from "@core/i18n";
import { Codeblock } from "@lib/ui/components";
import { showSheet } from "@lib/ui/sheets";
import { Button, Card, Stack, Text, TwinButtons } from "@metro/common/components";
import { ReactNode } from "react";
import ErrorDetailsActionSheet from "./ErrorDetailsActionSheet";
export const INDEX_BUNDLE_FILE: string = window.HermesInternal.getFunctionLocation(window.__r).fileName;
interface ErrorCardProps {
error: unknown;
header?: string | ReactNode;
onRetryRender?: () => void;
}
export default function ErrorCard(props: ErrorCardProps) {
return <Card>
<Stack>
{props.header && typeof props.header !== "string"
? props.header
: <Text variant="heading-lg/bold">{props.header ?? Strings.UH_OH}</Text>
}
<Codeblock selectable={true}>{String(props.error)}</Codeblock>
<TwinButtons>
{props.onRetryRender && <Button
variant="destructive"
// icon={findAssetId("RetryIcon")}
text={Strings.RETRY_RENDER}
onPress={props.onRetryRender}
/>}
{props.error instanceof Error ? <Button
text={"Details"}
// icon={findAssetId("CircleInformationIcon-primary")}
onPress={() => showSheet(
"BunnyErrorDetailsActionSheet",
ErrorDetailsActionSheet,
{ error: props.error as Error }
)}
/> : null}
</TwinButtons>
</Stack>
</Card>;
}

View file

@ -0,0 +1,53 @@
import { parseComponentStack } from "@core/ui/reporter/utils/parseComponentStack";
import { findAssetId } from "@lib/api/assets";
import { clipboard } from "@metro/common";
import { Button, Card, Text } from "@metro/common/components";
import { useState } from "react";
import { Image, View } from "react-native";
export default function ErrorComponentStackCard(props: {
componentStack: string;
}) {
const [collapsed, setCollapsed] = useState(true);
let stack: string[];
try {
stack = parseComponentStack(props.componentStack);
stack = collapsed ? stack.slice(0, 4) : stack;
} catch {
return;
}
return <Card>
<View style={{ gap: 8 }}>
<Text variant="heading-lg/bold">
Component Stack
</Text>
<View style={{ gap: 4 }}>
{stack.map(component => (
<View style={{ flexDirection: "row" }}>
<Text variant="text-md/bold" color="text-muted">{"<"}</Text>
<Text variant="text-md/bold">{component}</Text>
<Text variant="text-md/bold" color="text-muted">{"/>"}</Text>
</View>
))}
</View>
{collapsed && <Text>...</Text>}
<View style={{ gap: 8, flexDirection: "row", justifyContent: "center", alignItems: "center" }}>
<Button
variant="secondary"
text={`Show ${collapsed ? "more" : "less"}`}
icon={collapsed ? findAssetId("down_arrow") : <Image
style={{ transform: [{ rotate: `${collapsed ? 0 : 180}deg` }] }}
source={findAssetId("down_arrow")!}
/>}
onPress={() => setCollapsed(v => !v)} />
<Button
variant="secondary"
text="Copy"
icon={findAssetId("CopyIcon")}
onPress={() => clipboard.setString(props.componentStack)} />
</View>
</View>
</Card>;
}

View file

@ -0,0 +1,20 @@
import { hasStack, isComponentStack } from "@core/ui/reporter/utils/isStack";
import { Codeblock } from "@lib/ui/components";
import { ActionSheet, Text } from "@metro/common/components";
import { View } from "react-native";
import ErrorComponentStackCard from "./ErrorComponentStackCard";
import ErrorStackCard from "./ErrorStackCard";
export default function ErrorDetailsActionSheet(props: {
error: Error;
}) {
return <ActionSheet>
<View style={{ gap: 12, paddingVertical: 12 }}>
<Text variant="heading-lg/extrabold">Error</Text>
<Codeblock selectable={true}>{props.error.message}</Codeblock>
{hasStack(props.error) && <ErrorStackCard error={props.error} />}
{isComponentStack(props.error) ? <ErrorComponentStackCard componentStack={props.error.componentStack} /> : null}
</View>
</ActionSheet>;
}

View file

@ -0,0 +1,63 @@
import parseErrorStack, { StackFrame } from "@core/ui/reporter/utils/parseErrorStack";
import { findAssetId } from "@lib/api/assets";
import { clipboard, constants } from "@metro/common";
import { Button, Card, Text } from "@metro/common/components";
import { useState } from "react";
import { Image, Pressable, View } from "react-native";
import { INDEX_BUNDLE_FILE } from "./ErrorCard";
export default function ErrorStackCard(props: {
error: Error & { stack: string };
}) {
const [collapsed, setCollapsed] = useState(true);
let stack: StackFrame[];
try {
const parsedErrorStack = parseErrorStack(props.error.stack);
stack = collapsed ? parsedErrorStack.slice(0, 4) : parsedErrorStack;
} catch {
return null;
}
return <Card>
<View style={{ gap: 12 }}>
<Text variant="heading-lg/bold">
Call Stack
</Text>
<View style={{ gap: 4 }}>
{stack.map((f, id) => <Line id={id} frame={f} />)}
</View>
{collapsed && <Text>...</Text>}
<View style={{ gap: 8, flexDirection: "row", justifyContent: "center", alignItems: "center" }}>
<Button
variant="secondary"
text={`Show ${collapsed ? "more" : "less"}`}
icon={collapsed ? findAssetId("down_arrow") : <Image
style={{ transform: [{ rotate: `${collapsed ? 0 : 180}deg` }] }}
source={findAssetId("down_arrow")!}
/>}
onPress={() => setCollapsed(v => !v)} />
<Button
variant="secondary"
text="Copy"
icon={findAssetId("CopyIcon")}
onPress={() => clipboard.setString(props.error.stack)} />
</View>
</View>
</Card>;
}
function Line(props: { id: number, frame: StackFrame }) {
const [collapsed, setCollapsed] = useState(true);
return <Pressable onPress={() => setCollapsed(v => !v)} key={props.id}>
<Text style={{ fontFamily: constants.Fonts.CODE_BOLD }}>
{props.frame.methodName}
</Text>
<Text style={{ fontFamily: constants.Fonts.CODE_NORMAL }} ellipsizeMode="middle" numberOfLines={collapsed ? 1 : undefined}>
<Text color="text-muted">{props.frame.file === INDEX_BUNDLE_FILE ? "jsbundle" : props.frame.file}:{props.frame.lineNumber}:{props.frame.column}</Text>
</Text>
</Pressable>;
}

View file

@ -0,0 +1,6 @@
export function isComponentStack(error: Error): error is Error & { componentStack: string; } {
return "componentStack" in error && typeof error.componentStack === "string";
}
export function hasStack(error: Error): error is Error & { stack: string; } {
return !!error.stack;
}

View file

@ -0,0 +1,3 @@
export function parseComponentStack(componentStack: string) {
return componentStack.split(/[\s|\n]+?in /).filter(Boolean);
}

View file

@ -0,0 +1,202 @@
/**
* MIT License
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* * furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
export type StackFrame = {
column?: number,
file?: string,
lineNumber?: number,
methodName: string,
collapse?: boolean,
};
type HermesStackLocationNative = {
readonly type: "NATIVE",
};
type HermesStackLocationSource = Readonly<{
type: "SOURCE",
sourceUrl: string,
line1Based: number,
column1Based: number,
}>;
type HermesStackLocationInternalBytecode = Readonly<{
type: "INTERNAL_BYTECODE",
sourceUrl: string,
line1Based: number,
virtualOffset0Based: number,
}>;
type HermesStackLocationBytecode = Readonly<{
type: "BYTECODE",
sourceUrl: string,
line1Based: number,
virtualOffset0Based: number,
}>;
type HermesStackLocation =
| HermesStackLocationNative
| HermesStackLocationSource
| HermesStackLocationInternalBytecode
| HermesStackLocationBytecode;
type HermesStackEntryFrame = Readonly<{
type: "FRAME",
location: HermesStackLocation,
functionName: string,
}>;
type HermesStackEntrySkipped = Readonly<{
type: "SKIPPED",
count: number,
}>;
type HermesStackEntry = HermesStackEntryFrame | HermesStackEntrySkipped;
export type HermesParsedStack = Readonly<{
message: string,
entries: ReadonlyArray<HermesStackEntry>,
}>;
// Capturing groups:
// 1. function name
// 2. is this a native stack frame?
// 3. is this a bytecode address or a source location?
// 4. source URL (filename)
// 5. line number (1 based)
// 6. column number (1 based) or virtual offset (0 based)
const RE_FRAME =
/^ {4}at (.+?)(?: \((native)\)?| \((address at )?(.*?):(\d+):(\d+)\))$/;
// Capturing groups:
// 1. count of skipped frames
const RE_SKIPPED = /^ {4}... skipping (\d+) frames$/;
const RE_COMPONENT_NO_STACK = /^ {4}at .*$/;
function isInternalBytecodeSourceUrl(sourceUrl: string): boolean {
// See https://github.com/facebook/hermes/blob/3332fa020cae0bab751f648db7c94e1d687eeec7/lib/VM/Runtime.cpp#L1100
return sourceUrl === "InternalBytecode.js";
}
function parseLine(line: string): HermesStackEntry | void {
const asFrame = line.match(RE_FRAME);
if (asFrame) {
return {
type: "FRAME",
functionName: asFrame[1],
location:
asFrame[2] === "native"
? { type: "NATIVE" }
: asFrame[3] === "address at "
? isInternalBytecodeSourceUrl(asFrame[4])
? {
type: "INTERNAL_BYTECODE",
sourceUrl: asFrame[4],
line1Based: Number.parseInt(asFrame[5], 10),
virtualOffset0Based: Number.parseInt(asFrame[6], 10),
}
: {
type: "BYTECODE",
sourceUrl: asFrame[4],
line1Based: Number.parseInt(asFrame[5], 10),
virtualOffset0Based: Number.parseInt(asFrame[6], 10),
}
: {
type: "SOURCE",
sourceUrl: asFrame[4],
line1Based: Number.parseInt(asFrame[5], 10),
column1Based: Number.parseInt(asFrame[6], 10),
},
};
}
const asSkipped = line.match(RE_SKIPPED);
if (asSkipped) {
return {
type: "SKIPPED",
count: Number.parseInt(asSkipped[1], 10),
};
}
}
function parseHermesStack(stack: string): HermesParsedStack {
const lines = stack.split(/\n/);
let entries: Array<HermesStackEntryFrame | HermesStackEntrySkipped> = [];
let lastMessageLine = -1;
for (let i = 0; i < lines.length; ++i) {
const line = lines[i];
if (!line) {
continue;
}
const entry = parseLine(line);
if (entry) {
entries.push(entry);
continue;
}
if (RE_COMPONENT_NO_STACK.test(line)) {
// Skip component stacks without source location.
// TODO: This will not be displayed, not sure how to handle it.
continue;
}
// No match - we're still in the message
lastMessageLine = i;
entries = [];
}
const message = lines.slice(0, lastMessageLine + 1).join("\n");
return { message, entries };
}
function convertHermesStack(stack: HermesParsedStack): Array<StackFrame> {
const frames: Array<StackFrame> = [];
for (const entry of stack.entries) {
if (entry.type !== "FRAME") {
continue;
}
const { location, functionName } = entry;
if (location.type === "NATIVE" || location.type === "INTERNAL_BYTECODE") {
continue;
}
frames.push({
methodName: functionName,
file: location.sourceUrl,
lineNumber: location.line1Based,
column:
location.type === "SOURCE"
? location.column1Based - 1
: location.virtualOffset0Based,
});
}
return frames;
}
export default function parseErrorStack(errorStack?: string): Array<StackFrame> {
if (errorStack == null) {
return [];
}
const parsedStack = Array.isArray(errorStack)
? errorStack
: convertHermesStack(parseHermesStack(errorStack));
return parsedStack;
}

View file

@ -1,3 +1,4 @@
import patchErrorBoundary from "@core/debug/patches/patchErrorBoundary";
import initFixes from "@core/fixes";
import { initFetchI18nStrings } from "@core/i18n";
import initSettings from "@core/ui/settings";
@ -13,7 +14,6 @@ import { writeFile } from "@lib/api/native/fs";
import { isPyonLoader, isThemeSupported } from "@lib/api/native/loader";
import { patchJsx } from "@lib/api/react/jsx";
import { logger } from "@lib/utils/logger";
import initSafeMode from "@ui/safeMode";
import { patchSettings } from "@ui/settings";
import * as lib from "./lib";
@ -46,7 +46,7 @@ export default async () => {
initFetchI18nStrings(),
initSettings(),
initFixes(),
initSafeMode(),
patchErrorBoundary(),
updatePlugins()
]).then(
// Push them all to unloader

View file

@ -1,9 +1,7 @@
import { Strings } from "@core/i18n";
import { createLegacyClassComponentStyles, ThemeContext } from "@lib/ui/styles";
import ErrorCard from "@core/ui/reporter/components/ErrorCard";
import { React } from "@metro/common";
import { Button, LegacyFormText } from "@metro/common/components";
import { Codeblock } from "@ui/components";
import { Falsy, ScrollView } from "react-native";
import { ThemeContext } from "@ui/styles";
import { Falsy } from "react-native";
type ErrorBoundaryState = {
hasErr: false;
@ -16,19 +14,6 @@ export interface ErrorBoundaryProps {
children: JSX.Element | Falsy | (JSX.Element | Falsy)[];
}
const getStyles = createLegacyClassComponentStyles({
view: {
flex: 1,
flexDirection: "column",
margin: 10,
},
title: {
fontSize: 20,
textAlign: "center",
marginBottom: 5,
},
});
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
@ -39,24 +24,13 @@ export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, E
static getDerivedStateFromError = (error: Error) => ({ hasErr: true, error });
render() {
const styles = getStyles(this.context);
if (!this.state.hasErr) return this.props.children;
return (
<ScrollView style={styles.view}>
<LegacyFormText style={styles.title}>{Strings.UH_OH}</LegacyFormText>
<Codeblock selectable style={{ marginBottom: 5 }}>{this.state.error.name}</Codeblock>
<Codeblock selectable style={{ marginBottom: 5 }}>{this.state.error.message}</Codeblock>
{this.state.error.stack && <ScrollView style={{ maxHeight: 420, marginBottom: 5 }}>
<Codeblock selectable>{this.state.error.stack}</Codeblock>
</ScrollView>}
<Button
size="md"
variant="destructive"
onPress={() => this.setState({ hasErr: false })}
text={Strings.RETRY}
/>
</ScrollView>
<ErrorCard
error={this.state.error}
onRetryRender={() => this.setState({ hasErr: false })}
/>
);
}
}

View file

@ -1,149 +0,0 @@
import { Strings } from "@core/i18n";
import { toggleSafeMode } from "@lib/api/debug";
import { DeviceManager } from "@lib/api/native/modules";
import { after } from "@lib/api/patcher";
import { settings } from "@lib/api/settings";
import { lazyDestructure } from "@lib/utils/lazy";
import { Button, CompatButton, SafeAreaView } from "@metro/common/components";
import { _lazyContextSymbol } from "@metro/lazy";
import { LazyModuleContext } from "@metro/types";
import { findByNameLazy, findByProps } from "@metro/wrappers";
import { semanticColors } from "@ui/color";
import { Codeblock, ErrorBoundary as _ErrorBoundary } from "@ui/components";
import { createThemedStyleSheet, TextStyleSheet } from "@ui/styles";
import { Text, View } from "react-native";
// Let's just pray they have this.
const { BadgableTabBar } = lazyDestructure(() => findByProps("BadgableTabBar"));
const styles = createThemedStyleSheet({
container: {
flex: 1,
backgroundColor: semanticColors.BACKGROUND_PRIMARY,
paddingHorizontal: 16,
},
header: {
flex: 1,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
marginVertical: 8,
},
headerTitle: {
...TextStyleSheet["heading-md/semibold"],
textAlign: "center",
textTransform: "uppercase",
color: semanticColors.HEADER_PRIMARY,
},
headerDescription: {
...TextStyleSheet["text-sm/medium"],
textAlign: "center",
color: semanticColors.TEXT_MUTED,
},
footer: {
flexDirection: DeviceManager.isTablet ? "row" : "column",
justifyContent: "flex-end",
marginVertical: 8,
},
});
interface Tab {
id: string;
title: () => string;
trimWhitespace?: boolean;
}
interface Button {
text: string;
// TODO: Proper types for the below
color?: string;
size?: string;
onPress: () => void;
}
const tabs: Tab[] = [
{ id: "message", title: () => Strings.MESSAGE },
{ id: "stack", title: () => Strings.STACK_TRACE },
{ id: "componentStack", title: () => Strings.COMPONENT, trimWhitespace: true },
];
function getErrorBoundaryContext() {
const ctxt: LazyModuleContext = findByNameLazy("ErrorBoundary")[_lazyContextSymbol];
return new Promise(resolve => {
ctxt.getExports(exp => {
resolve(exp.prototype);
});
});
}
export default () => after.await("render", getErrorBoundaryContext(), function (this: any, _, ret) {
if (!this.state.error) return;
// Not using setState here as we don't want to cause a re-render, we want this to be set in the initial render
this.state.activeTab ??= "message";
const tabData = tabs.find(t => t.id === this.state.activeTab);
const errorText: string = this.state.error[this.state.activeTab];
// This is in the patch and not outside of it so that we can use `this`, e.g. for setting state
const buttons: Button[] = [
{ text: Strings.RELOAD_DISCORD, onPress: this.handleReload },
...!settings.safeMode?.enabled ? [{ text: Strings.RELOAD_IN_SAFE_MODE, onPress: toggleSafeMode }] : [],
{ text: Strings.RETRY_RENDER, color: "red", onPress: () => this.setState({ info: null, error: null }) },
];
return (
<_ErrorBoundary>
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<ret.props.Illustration style={{ transform: [{ scale: 0.6 }], marginLeft: -40, marginRight: -80 }} />
<View style={{ flex: 2, paddingLeft: 24 }}>
<Text style={styles.headerTitle}>{ret.props.title}</Text>
<Text style={styles.headerDescription}>{ret.props.body}</Text>
</View>
</View>
<View style={{ flex: 6 }}>
<View style={{ paddingBottom: 8 }}>
{/* Are errors caught by ErrorBoundary guaranteed to have the component stack? */}
<BadgableTabBar
tabs={tabs.map(t => ({ ...t, title: t.title() }))}
activeTab={this.state.activeTab}
onTabSelected={(tab: string) => { this.setState({ activeTab: tab }); }}
/>
</View>
<Codeblock
selectable
style={{ flex: 1, textAlignVertical: "top" }}
>
{/*
TODO: I tried to get this working as intended using regex and failed.
When trimWhitespace is true, each line should have it's whitespace removed but with it's spaces kept.
*/}
{tabData?.trimWhitespace ? errorText.split("\n").filter(i => i.length !== 0).map(i => i.trim()).join("\n") : errorText}
</Codeblock>
</View>
<View style={styles.footer}>
{buttons.map(button => {
const buttonIndex = buttons.indexOf(button) !== 0 ? 8 : 0;
return <CompatButton
text={button.text}
color={button.color ?? "brand"}
size={button.size ?? "small"}
onPress={button.onPress}
style={{
...(DeviceManager.isTablet ? {
flex: `0.${buttons.length}`,
marginLeft: buttonIndex
} : {
marginTop: buttonIndex
}),
borderRadius: 16
}}
/>;
})}
</View>
</SafeAreaView>
</_ErrorBoundary>
);
});

View file

@ -4,9 +4,10 @@ const actionSheet = findByPropsLazy("openLazy", "hideActionSheet");
export function showSheet<T extends React.ComponentType<any>>(
key: string,
lazyImport: Promise<{ default: T; }>,
lazyImport: Promise<{ default: T; }> | T,
props?: React.ComponentProps<T>
) {
if (!("then" in lazyImport)) lazyImport = Promise.resolve({ default: lazyImport });
actionSheet.openLazy(lazyImport, key, props ?? {});
}