diff --git a/scripts/build.mjs b/scripts/build.mjs index 3bf2c8c..263dbb9 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -77,7 +77,7 @@ const config = { constModules: { globals: { "bunny-build-info": { - version: `"1.1.5-3"`, + version: `"1.1.5-4"`, }, }, }, diff --git a/src/core/debug/patches/patchErrorBoundary.tsx b/src/core/debug/patches/patchErrorBoundary.tsx index e6a8da6..2c30ce0 100644 --- a/src/core/debug/patches/patchErrorBoundary.tsx +++ b/src/core/debug/patches/patchErrorBoundary.tsx @@ -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 { 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 ( - { + + // 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( diff --git a/src/core/ui/reporter/components/ErrorBoundaryScreen.tsx b/src/core/ui/reporter/components/ErrorBoundaryScreen.tsx index 0ccd8be..696af69 100644 --- a/src/core/ui/reporter/components/ErrorBoundaryScreen.tsx +++ b/src/core/ui/reporter/components/ErrorBoundaryScreen.tsx @@ -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 ( @@ -149,14 +222,9 @@ export default function ErrorBoundaryScreen(props: { {props.error.message} - {(() => { + {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: { ); - } - + })() + ) : 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 ( @@ -299,99 +332,95 @@ export default function ErrorBoundaryScreen(props: { ); - } - + })() + ) : ( // 3) Show only built-in (core) plugins from the bundle (no externals) - const corePlugins = getCorePlugins(); - const ids = Object.keys(corePlugins); - return ( - - - Core plugins + + + Core plugins + + Toggle built-in plugins below. After toggling, press "Retry + Render" to re-check the crash. + + + {corePluginIds.length === 0 ? ( - Toggle built-in plugins below. After toggling, press "Retry - Render" to re-check the crash. + No core plugins found. + ) : ( + + {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 ? ( - - No core plugins found. - - ) : ( - - {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 ( - { - 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 ( + { + 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}`, + ); + } + }} + /> + ); + })} + + )} - -