fix fewer hooks error for real this time
This commit is contained in:
parent
53aa3bdbc1
commit
afd051e86c
5 changed files with 200 additions and 187 deletions
|
|
@ -77,7 +77,7 @@ const config = {
|
|||
constModules: {
|
||||
globals: {
|
||||
"bunny-build-info": {
|
||||
version: `"1.1.5-3"`,
|
||||
version: `"1.1.5-4"`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import React from "react";
|
||||
import ErrorBoundaryScreen from "@core/ui/reporter/components/ErrorBoundaryScreen";
|
||||
import { after } from "@lib/api/patcher";
|
||||
import { _lazyContextSymbol } from "@metro/lazy";
|
||||
|
|
@ -26,6 +27,23 @@ function getErrorBoundaryContext(): Promise<any> {
|
|||
return Promise.reject(new Error("ErrorBoundary context not found"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Small stable wrapper component used when mounting our UI from the patched render.
|
||||
* Defining a top-level component ensures that hook order inside ErrorBoundaryScreen
|
||||
* is always respected (React expects components to be declared at module scope).
|
||||
*
|
||||
* We avoid using any hooks here; this wrapper simply forwards props to the real screen.
|
||||
*/
|
||||
const ErrorBoundaryMount: React.FC<{
|
||||
error: any;
|
||||
rerender: () => void;
|
||||
}> = ({ error, rerender }) => {
|
||||
return React.createElement(ErrorBoundaryScreen, {
|
||||
error,
|
||||
rerender,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Patch Discord's ErrorBoundary.render to return ShiggyCord's custom screen.
|
||||
* Add defensive logging and fallback behavior so the patch won't break startup
|
||||
|
|
@ -53,29 +71,33 @@ export default function patchErrorBoundary() {
|
|||
|
||||
const unpatch = after.await("render", ctxPromise, function (this: any) {
|
||||
try {
|
||||
// Defensive checks: only render our screen when an error is actually present.
|
||||
if (!this || !this.state || !this.state.error) return;
|
||||
|
||||
try {
|
||||
console.log(
|
||||
"[ShiggyCord] patchErrorBoundary: rendering custom error screen",
|
||||
this.state.error,
|
||||
);
|
||||
} catch {}
|
||||
return (
|
||||
<ErrorBoundaryScreen
|
||||
error={this.state.error}
|
||||
// try to reset state in a way that plays nicely with various ErrorBoundary shapes
|
||||
rerender={() => {
|
||||
|
||||
// Return a stable component element (ErrorBoundaryMount). Using a named,
|
||||
// module-scoped component helps React ensure hook ordering for nested components.
|
||||
return React.createElement(ErrorBoundaryMount, {
|
||||
error: this.state.error,
|
||||
rerender: () => {
|
||||
try {
|
||||
// Attempt to reset common ErrorBoundary state shapes safely.
|
||||
try {
|
||||
// common variant: some boundaries track `hasErr` or `error`
|
||||
this.setState?.({ info: null, error: null, hasErr: false });
|
||||
} catch {
|
||||
try {
|
||||
this.setState?.({ error: null });
|
||||
} catch {}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} catch {}
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
try {
|
||||
console.error(
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ export default function ErrorBoundaryScreen(props: {
|
|||
error: Error;
|
||||
rerender: () => void;
|
||||
}) {
|
||||
// Hooks MUST be at the top level and unconditionally called.
|
||||
const styles = useStyles();
|
||||
const debugInfo = getDebugInfo();
|
||||
const navigation = NavigationNative.useNavigation();
|
||||
|
|
@ -132,6 +133,78 @@ export default function ErrorBoundaryScreen(props: {
|
|||
const batches = bisectBatches ?? [];
|
||||
const currentBatch = batches[bisectIndex] ?? [];
|
||||
|
||||
// Move all error-analysis computations into stable hooks (useMemo). This
|
||||
// ensures we don't conditionally call hooks later in render and keeps hook order stable.
|
||||
const explicitPluginId = React.useMemo(() => {
|
||||
try {
|
||||
return (
|
||||
(props.error as any)?.pluginId ??
|
||||
(window as any)?.__SHIGGY_LAST_UNCAUGHT_ERROR?.pluginId ??
|
||||
(window as any)?.__SHIGGY_CURRENT_PLUGIN
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [props.error]);
|
||||
|
||||
const parsedFrames = React.useMemo(() => {
|
||||
try {
|
||||
return parseErrorStack((props.error as any).stack) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [props.error]);
|
||||
|
||||
const suspectedPlugins = React.useMemo(() => {
|
||||
const suspected: Array<{ id: string; name: string }> = [];
|
||||
try {
|
||||
if (parsedFrames && parsedFrames.length) {
|
||||
for (const [id, manifest] of registeredPlugins) {
|
||||
try {
|
||||
const jsPath = (manifest as any).jsPath;
|
||||
if (!jsPath) continue;
|
||||
for (const f of parsedFrames) {
|
||||
if (!f.file) continue;
|
||||
if (
|
||||
f.file.includes(jsPath) ||
|
||||
f.file.includes(`/plugins/scripts/${id}`) ||
|
||||
f.file.includes(`bunny-plugin/${id}`)
|
||||
) {
|
||||
suspected.push({
|
||||
id,
|
||||
name:
|
||||
(manifest as any).display?.name ??
|
||||
(manifest as any).name ??
|
||||
id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (suspected.length) break;
|
||||
} catch {
|
||||
// ignore per-manifest errors
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// parsing failed — keep suspected empty
|
||||
}
|
||||
return suspected;
|
||||
}, [parsedFrames]);
|
||||
|
||||
const corePlugins = React.useMemo(() => {
|
||||
try {
|
||||
return getCorePlugins();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const corePluginIds = React.useMemo(
|
||||
() => Object.keys(corePlugins),
|
||||
[corePlugins],
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<SafeAreaView style={styles.container}>
|
||||
|
|
@ -149,14 +222,9 @@ export default function ErrorBoundaryScreen(props: {
|
|||
<ScrollView fadingEdgeLength={64} contentContainerStyle={{ gap: 12 }}>
|
||||
<Codeblock selectable={true}>{props.error.message}</Codeblock>
|
||||
|
||||
{(() => {
|
||||
{explicitPluginId && registeredPlugins.has(explicitPluginId) ? (
|
||||
// 1) Prefer explicit annotation on the error (set by plugin loader) or current runtime marker.
|
||||
const explicitPluginId =
|
||||
(props.error as any)?.pluginId ??
|
||||
(window as any)?.__SHIGGY_LAST_UNCAUGHT_ERROR?.pluginId ??
|
||||
(window as any)?.__SHIGGY_CURRENT_PLUGIN;
|
||||
|
||||
if (explicitPluginId && registeredPlugins.has(explicitPluginId)) {
|
||||
(() => {
|
||||
const manifest = registeredPlugins.get(explicitPluginId)!;
|
||||
const name =
|
||||
manifest.display?.name ?? manifest.name ?? explicitPluginId;
|
||||
|
|
@ -206,46 +274,11 @@ export default function ErrorBoundaryScreen(props: {
|
|||
</View>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
})()
|
||||
) : suspectedPlugins.length > 0 ? (
|
||||
// 2) Fallback: heuristic based on stack frames (best-effort)
|
||||
let suspected: Array<{ id: string; name: string }> = [];
|
||||
try {
|
||||
const frames = parseErrorStack((props.error as any).stack);
|
||||
if (frames && frames.length) {
|
||||
for (const [id, manifest] of registeredPlugins) {
|
||||
try {
|
||||
const jsPath = (manifest as any).jsPath;
|
||||
if (!jsPath) continue;
|
||||
for (const f of frames) {
|
||||
if (!f.file) continue;
|
||||
if (
|
||||
f.file.includes(jsPath) ||
|
||||
f.file.includes(`/plugins/scripts/${id}`) ||
|
||||
f.file.includes(`bunny-plugin/${id}`)
|
||||
) {
|
||||
suspected.push({
|
||||
id,
|
||||
name:
|
||||
(manifest as any).display?.name ??
|
||||
(manifest as any).name ??
|
||||
id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (suspected.length) break;
|
||||
} catch {
|
||||
// ignore per-manifest errors
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// parsing failed — continue to show suggestions
|
||||
}
|
||||
|
||||
if (suspected.length > 0) {
|
||||
const s = suspected[0];
|
||||
(() => {
|
||||
const s = suspectedPlugins[0];
|
||||
return (
|
||||
<Card>
|
||||
<View style={{ gap: 8 }}>
|
||||
|
|
@ -299,99 +332,95 @@ export default function ErrorBoundaryScreen(props: {
|
|||
</View>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
})()
|
||||
) : (
|
||||
// 3) Show only built-in (core) plugins from the bundle (no externals)
|
||||
const corePlugins = getCorePlugins();
|
||||
const ids = Object.keys(corePlugins);
|
||||
return (
|
||||
<Card>
|
||||
<View style={{ gap: 8 }}>
|
||||
<Text variant="heading-md/bold">Core plugins</Text>
|
||||
<Card>
|
||||
<View style={{ gap: 8 }}>
|
||||
<Text variant="heading-md/bold">Core plugins</Text>
|
||||
<Text variant="text-sm/normal" color="text-muted">
|
||||
Toggle built-in plugins below. After toggling, press "Retry
|
||||
Render" to re-check the crash.
|
||||
</Text>
|
||||
|
||||
{corePluginIds.length === 0 ? (
|
||||
<Text variant="text-sm/normal" color="text-muted">
|
||||
Toggle built-in plugins below. After toggling, press "Retry
|
||||
Render" to re-check the crash.
|
||||
No core plugins found.
|
||||
</Text>
|
||||
) : (
|
||||
<TableRowGroup title="Core plugins">
|
||||
{corePluginIds.map((id) => {
|
||||
const entry = (corePlugins as any)[id];
|
||||
const name =
|
||||
entry?.default?.manifest?.display?.name ??
|
||||
entry?.default?.manifest?.name ??
|
||||
id;
|
||||
let enabled = false;
|
||||
try {
|
||||
enabled = Boolean(isPluginEnabled(id));
|
||||
} catch {
|
||||
enabled = false;
|
||||
}
|
||||
// Protect specific core plugins from being toggled (same heuristic as PluginCard)
|
||||
const idLower = ((id || "") as string).toLowerCase();
|
||||
const isProtectedCore =
|
||||
idLower.includes("quickinstall") ||
|
||||
idLower === "bunny.badges";
|
||||
|
||||
{ids.length === 0 ? (
|
||||
<Text variant="text-sm/normal" color="text-muted">
|
||||
No core plugins found.
|
||||
</Text>
|
||||
) : (
|
||||
<TableRowGroup title="Core plugins">
|
||||
{ids.map((id) => {
|
||||
const entry = (corePlugins as any)[id];
|
||||
const name =
|
||||
entry?.default?.manifest?.display?.name ??
|
||||
entry?.default?.manifest?.name ??
|
||||
id;
|
||||
let enabled = false;
|
||||
try {
|
||||
enabled = Boolean(isPluginEnabled(id));
|
||||
} catch {
|
||||
enabled = false;
|
||||
}
|
||||
// Protect specific core plugins from being toggled (same heuristic as PluginCard)
|
||||
const idLower = ((id || "") as string).toLowerCase();
|
||||
const isProtectedCore =
|
||||
idLower.includes("quickinstall") ||
|
||||
idLower === "bunny.badges";
|
||||
|
||||
return (
|
||||
<TableSwitchRow
|
||||
key={id}
|
||||
label={name}
|
||||
value={enabled}
|
||||
disabled={isProtectedCore}
|
||||
onValueChange={async (v: boolean) => {
|
||||
try {
|
||||
if (v) {
|
||||
await enablePlugin(id, true);
|
||||
} else {
|
||||
await disablePlugin(id);
|
||||
}
|
||||
// lightweight refresh so UI reflects the change
|
||||
setBisectBatches((prev) =>
|
||||
prev ? [...prev] : [],
|
||||
);
|
||||
showToast(
|
||||
`${v ? "Enabled" : "Disabled"} ${name}`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"[ShiggyCord][ErrorBoundaryScreen] failed to toggle core plugin",
|
||||
id,
|
||||
e,
|
||||
);
|
||||
showToast(
|
||||
`Failed to ${v ? "enable" : "disable"} ${name}`,
|
||||
);
|
||||
return (
|
||||
<TableSwitchRow
|
||||
key={id}
|
||||
label={name}
|
||||
value={enabled}
|
||||
disabled={isProtectedCore}
|
||||
onValueChange={async (v: boolean) => {
|
||||
try {
|
||||
if (v) {
|
||||
await enablePlugin(id, true);
|
||||
} else {
|
||||
await disablePlugin(id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableRowGroup>
|
||||
)}
|
||||
// lightweight refresh so UI reflects the change
|
||||
setBisectBatches((prev) =>
|
||||
prev ? [...prev] : [],
|
||||
);
|
||||
showToast(
|
||||
`${v ? "Enabled" : "Disabled"} ${name}`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"[ShiggyCord][ErrorBoundaryScreen] failed to toggle core plugin",
|
||||
id,
|
||||
e,
|
||||
);
|
||||
showToast(
|
||||
`Failed to ${v ? "enable" : "disable"} ${name}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableRowGroup>
|
||||
)}
|
||||
|
||||
<View style={{ flexDirection: "row", gap: 8 }}>
|
||||
<Button
|
||||
text="Refresh list"
|
||||
onPress={() => {
|
||||
try {
|
||||
// Force UI refresh of core list
|
||||
setBisectBatches((prev) => (prev ? [...prev] : []));
|
||||
showToast("Core plugin list refreshed");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ flexDirection: "row", gap: 8 }}>
|
||||
<Button
|
||||
text="Refresh list"
|
||||
onPress={() => {
|
||||
try {
|
||||
// Force UI refresh of core list
|
||||
setBisectBatches((prev) => (prev ? [...prev] : []));
|
||||
showToast("Core plugin list refreshed");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{hasStack(props.error) && <ErrorStackCard error={props.error} />}
|
||||
{isComponentStack(props.error) ? (
|
||||
|
|
|
|||
|
|
@ -9,52 +9,14 @@ export const CustomPageRenderer = React.memo(() => {
|
|||
const navigation = NavigationNative.useNavigation();
|
||||
const route = NavigationNative.useRoute();
|
||||
|
||||
// Accept `render` as a render-function (recommended) or a component.
|
||||
// Respect `noErrorBoundary` param to allow callers to opt out of wrapping.
|
||||
const {
|
||||
render: PageComponent,
|
||||
noErrorBoundary = false,
|
||||
...args
|
||||
} = route.params ?? {};
|
||||
const { render: PageComponent, ...args } = route.params;
|
||||
|
||||
React.useEffect(
|
||||
() => void navigation.setOptions({ ...args }),
|
||||
[navigation, args],
|
||||
React.useEffect(() => void navigation.setOptions({ ...args }), []);
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<PageComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
// Normalize to a React element. PageComponent may be:
|
||||
// - a function returning a React element (render wrapper): () => <Comp />
|
||||
// - a React component type: MyComponent
|
||||
// - a React element (unlikely), in which case return as-is.
|
||||
const element = React.useMemo(() => {
|
||||
if (!PageComponent) return null;
|
||||
try {
|
||||
// If caller provided a render function (common in this codebase), invoke it to get the element.
|
||||
if (typeof PageComponent === "function") {
|
||||
// When PageComponent is a component type, invoking it directly could be unsafe for class components.
|
||||
// However, most callers pass a zero-arg render function that returns an element (e.g. () => <MyComp />),
|
||||
// so prefer calling it. If it returns something that looks like a React element, use it.
|
||||
const maybeEl = PageComponent();
|
||||
return maybeEl;
|
||||
}
|
||||
|
||||
// Otherwise assume it's already a React element.
|
||||
return PageComponent as any;
|
||||
} catch {
|
||||
// As a last resort, attempt JSX-style rendering so component types still work.
|
||||
try {
|
||||
return <PageComponent />;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}, [PageComponent]);
|
||||
|
||||
if (noErrorBoundary) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return <ErrorBoundary>{element}</ErrorBoundary>;
|
||||
});
|
||||
|
||||
export function wrapOnPress(
|
||||
|
|
|
|||
2
src/modules.d.ts
vendored
2
src/modules.d.ts
vendored
|
|
@ -1,5 +1,5 @@
|
|||
declare module "bunny-build-info" {
|
||||
const version: "1.1.5-3";
|
||||
const version: string;
|
||||
}
|
||||
|
||||
declare module "*.png" {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue