Agent Rules / Chimeras
Why does my Next.js widget fail to load after a rename?
Chimera CH-11 · Next.js, TypeScript
Rules it tests: AR-37
A dashboard page keeps its widgets in a config list and loads each one with `dynamic(() => import(../widgets/${name})). The file name lives in the config as a string, and the import path is built from it when the slot renders. Rename widgets/Churn.tsx and no import in the project names it, so the rename has no importer to update and the slot fails when it loads. Write one literal import()` per widget in a table keyed by a union of string literals, and type the config with that union.
The code below is from a small example project we wrote for this page. The break is shown by file and line, and we ran the project's own tests and type checks to confirm it.
1. The setup
next/dynamic is a good tool for this job. The Next.js documentation describes it as a composite of React.lazy() and Suspense, and it keeps a widget out of the initial JavaScript for the page. A dashboard has three widgets, a config list and one component that fills a slot.
// before/app/dashboard/widgets.config.ts
export const DASHBOARD_WIDGETS: string[] = ["Revenue", "Churn", "Signups"]; // line 1// before/app/dashboard/page.tsx
{DASHBOARD_WIDGETS.map((name) => ( // line 8
<WidgetSlot key={name} name={name} /> // line 9
))}
// before/components/WidgetSlot.tsx
export function WidgetSlot({ name }: { name: string }) { // line 5
const Widget = dynamic(() => import(`../widgets/${name}`)); // line 6
return <Widget />;
}The widgets are widgets/Revenue.tsx, widgets/Churn.tsx and widgets/Signups.tsx, each with a default export. Their numbers come from small functions in lib/metrics.ts, and tests/metrics.test.ts tests those functions directly.
2. Where the call path hides
- The name is a string in two places.
"Churn"atwidgets.config.ts:1is the only place the widget's file name appears in the app.WidgetSlot.tsx:6joins it to../widgets/at run time, so no line of the project readsimport("../widgets/Churn"). - The import path is a template. The Next.js documentation says that in
import('path/to/component')the path must be explicitly written, and that it can't be a template string or a variable. The same note says theimport()has to be inside thedynamic()call so Next.js can match bundles and module ids to that call and preload them, and thatdynamic()can't be used inside React rendering because it needs to be at the top level of the module, similar toReact.lazy.WidgetSlot.tsx:6is inside a render function, with a template. - A bundler can still do something with it. The webpack documentation describes
import()with a directory prefix and a dynamic part, using./locale/${language}.jsonas the example: every module that could be requested is included, so all.jsonfiles in that directory go into the new chunk. A dynamic request with a prefix is therefore something webpack knows how to bundle, and the widgets directory is the prefix here. The Next.js pages we read do not say what Next.js does with this exact line under webpack or under Turbopack, and we do not claim it. - **The config is a
string[].**DASHBOARD_WIDGETS: string[]accepts"Churn"and"Chrun"alike. Nothing links an entry to a file. - A search for the file finds no importer. A search for importers of
widgets/Churnreturns nothing, and a tool that lists unused files can report it as unused.
3. The edit that breaks it, and which call is left wrong
The widget was renamed to say what it measures. widgets/Churn.tsx became widgets/Retention.tsx, the component became Retention, and it now shows the share of customers kept, from retentionRate in lib/metrics.ts. The direct test, tests/metrics.test.ts:10 to :12, was updated with it:
// before/widgets/Retention.tsx (edit applied)
import { retentionRate } from "../lib/metrics"; // line 1 (was: churnRate)
export default function Retention() { // line 3 (was: Churn)The edit is finished in the files it touches. No import of widgets/Churn existed, so the rename had no importer to rewrite. The compiler is given a string[] and a template and has no file named Churn to look for: with Next.js 16.4.3 installed, tsc --noEmit (TypeScript 5.9.3) reports nothing on before/.
The call left wrong is the string at widgets.config.ts:1. When page.tsx:9 renders the second slot, WidgetSlot.tsx:6 asks for ../widgets/Churn, and no such module exists. The slot fails at that point, at run time, because the module cannot be found. The other two slots name files that exist.
The direct tests still pass, because they test functions in lib/metrics.ts and never load a widget. The case that catches it is tests/widgets.test.ts:14, written as a todo with the reason. It reads the config and checks that widgets/<name>.tsx exists for each entry, and the entry Churn has no file. We wrote it as a file check because loading a Next.js page in a test would need Next itself. With node --test, the before/ suite reports 3 passed and 1 todo; the after/ suite reports 6 passed.
A second edit has the opposite shape. Add a fourth file, widgets/Latency.tsx, and forget the config: nothing reports that the file is never shown. The same string is the only way in.
4. The fix
A table of literal imports, keyed by a union of string literals. Each dynamic() call is at the top level of the module with a path written out, as the Next.js note requires. The config is typed with the same union.
--- before/components/WidgetSlot.tsx+++ after/components/WidgetSlot.tsx "use client"; import dynamic from "next/dynamic";+import type { ComponentType } from "react"; -export function WidgetSlot({ name }: { name: string }) {- const Widget = dynamic(() => import(`../widgets/${name}`));+import type { WidgetKey } from "../widgets/keys";++const WIDGETS = {+ revenue: dynamic(() => import("../widgets/Revenue")),+ retention: dynamic(() => import("../widgets/Retention")),+ signups: dynamic(() => import("../widgets/Signups")),+} satisfies Record<WidgetKey, ComponentType>;++export function WidgetSlot({ name }: { name: WidgetKey }) {+ const Widget = WIDGETS[name]; return <Widget />; }--- before/app/dashboard/widgets.config.ts+++ after/app/dashboard/widgets.config.ts-export const DASHBOARD_WIDGETS: string[] = ["Revenue", "Churn", "Signups"];+import type { WidgetKey } from "../../widgets/keys.ts";++export const DASHBOARD_WIDGETS: WidgetKey[] = ["revenue", "retention", "signups"];// after/widgets/keys.ts
export const WIDGET_KEYS = ["revenue", "retention", "signups"] as const;
export type WidgetKey = (typeof WIDGET_KEYS)[number];page.tsx is unchanged. The table costs one line per widget, and each of those lines is the importer a search or a rename looks for.
5. Why the fix works
- Every widget file has an importer.
WidgetSlot.tsx:9to:11each write the path out. RenameRetention.tsxand a tool that follows imports has a specifier to rewrite at:10. - The name is a union, not a string. The TypeScript handbook shows that combining literals into a union lets a function accept only a known set of values, and that an argument outside the set is an error at compile time.
WidgetKeyis that union,DASHBOARD_WIDGETSisWidgetKey[], andWidgetSlottakes aWidgetKey. A config entry of"churn"is reported where it is written: onafter/, tsc givesTS2322: Type '"churn"' is not assignable to type '"revenue" | "retention" | "signups"'atwidgets.config.ts:3, and an import left pointing at../widgets/ChurngivesTS2307: Cannot find moduleat that line ofWidgetSlot.tsx. - The table must match the union.
satisfiesvalidates that the type of an expression matches a type without changing the resulting type, per the TypeScript 4.9 release notes, andRecord<WidgetKey, ComponentType>has one property for each key. A key with no entry, or an entry with no key, is a mismatch at the table. The release notes' own example shows the extra-key error. We checked the missing-key case: drop thesignupsline and tsc reportsTS1360at the table,Property 'signups' is missing. - The loading rules are met. The
dynamic()calls are at module top level with explicit paths, which is the form the Next.js note describes. The lookup happens in render, and thedynamic()call does not. - The tests read the table.
after/tests/widgets.test.ts:14readsWidgetSlot.tsxas text and checks that each key has a literalimport()of a file that exists.:22checks that noimport(with a template is left.:26checks that each configured name is a key. We wrote them against the table's source because running it needs Next.
6. Rules that apply
- AR-37: in React and Next.js, don't call
import()with a computed path for components agents edit. Keep one literal import per component, in a table keyed by a literal union.
7. References
Next.js (fetched 2026-10-31):
- Lazy loading, Pages Router (the note on explicit paths, template strings,
dynamic()at module top level): https://nextjs.org/docs/pages/guides/lazy-loading - Lazy loading, App Router (
next/dynamicas a composite ofReact.lazy()and Suspense,ssr: falsein Client Components, magic comments; the explicit-path note is not on this page): https://nextjs.org/docs/app/guides/lazy-loading
React (fetched 2026-10-31):
- lazy (declare at the top level of a module, not inside a component;
loadreturns a promise of a module with a default export): https://react.dev/reference/react/lazy
webpack (fetched 2026-10-31):
- Module methods, dynamic expressions in
import(): https://webpack.js.org/api/module-methods/
TypeScript (fetched 2026-10-31):
- TypeScript 4.9 release notes, the
satisfiesoperator: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html - Everyday types, literal types and unions of literals: https://www.typescriptlang.org/docs/handbook/2/everyday-types.html
- Utility types,
Record<Keys, Type>: https://www.typescriptlang.org/docs/handbook/utility-types.html
Last verified