fix fewer hooks error for real this time

This commit is contained in:
kmmiio99o 2025-10-24 22:37:39 +02:00
parent 53aa3bdbc1
commit afd051e86c
5 changed files with 200 additions and 187 deletions

View file

@ -77,7 +77,7 @@ const config = {
constModules: {
globals: {
"bunny-build-info": {
version: `"1.1.5-3"`,
version: `"1.1.5-4"`,
},
},
},

View file

@ -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(

View file

@ -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) ? (

View file

@ -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
View file

@ -1,5 +1,5 @@
declare module "bunny-build-info" {
const version: "1.1.5-3";
const version: string;
}
declare module "*.png" {