Refactor App.tsx from god component to context-based architecture
Replace prop drilling with React context providers. App.tsx shrinks from 427 lines to ~80 lines of pure layout. Components consume shared state directly via 7 context providers instead of threading 50+ props. Key changes: - 7 context providers wrapping existing hooks (encounter, bestiary, player characters, side panel, theme, bulk import, initiative rolls) - 2 coordinating hooks extracted from App.tsx (useInitiativeRolls, useAutoStatBlock) - All 9 affected components refactored from prop-based to context-based - 6 test files updated to use providers or context mocks - Prop count enforcement script (max 8 per component interface) - Constitution principle II-A added (context-based state flow) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
99
scripts/check-component-props.mjs
Normal file
99
scripts/check-component-props.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Enforce a maximum number of explicitly declared props per component
|
||||
* interface.
|
||||
*
|
||||
* Components should consume shared application state via React context
|
||||
* providers, not prop drilling. Props are reserved for per-instance
|
||||
* configuration (a specific data item, a layout variant, a ref).
|
||||
*
|
||||
* Only scans component files (not hooks, adapters, etc.) and only
|
||||
* counts properties declared directly in *Props interfaces — inherited
|
||||
* or extended HTML attributes are not counted.
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { relative } from "node:path";
|
||||
|
||||
const MAX_PROPS = 8;
|
||||
|
||||
const files = execSync(
|
||||
"git ls-files -- 'apps/web/src/components/*.tsx' 'apps/web/src/components/**/*.tsx'",
|
||||
{ encoding: "utf-8" },
|
||||
)
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean);
|
||||
|
||||
let errors = 0;
|
||||
|
||||
const propsRegex = /^(?:export\s+)?interface\s+(\w+Props)\s*\{/;
|
||||
|
||||
for (const file of files) {
|
||||
const content = readFileSync(file, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
let inInterface = false;
|
||||
let interfaceName = "";
|
||||
let braceDepth = 0;
|
||||
let parenDepth = 0;
|
||||
let propCount = 0;
|
||||
let startLine = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (!inInterface) {
|
||||
const match = propsRegex.exec(line);
|
||||
if (match) {
|
||||
inInterface = true;
|
||||
interfaceName = match[1];
|
||||
braceDepth = 0;
|
||||
parenDepth = 0;
|
||||
propCount = 0;
|
||||
startLine = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (inInterface) {
|
||||
for (const ch of line) {
|
||||
if (ch === "{") braceDepth++;
|
||||
if (ch === "}") braceDepth--;
|
||||
if (ch === "(") parenDepth++;
|
||||
if (ch === ")") parenDepth--;
|
||||
}
|
||||
|
||||
// Count prop lines at brace depth 1 and not inside function params:
|
||||
// Matches " propName?: type" and " readonly propName: type"
|
||||
if (
|
||||
braceDepth === 1 &&
|
||||
parenDepth === 0 &&
|
||||
/^\s+(?:readonly\s+)?\w+\??\s*:/.test(line)
|
||||
) {
|
||||
propCount++;
|
||||
}
|
||||
|
||||
if (braceDepth === 0) {
|
||||
if (propCount > MAX_PROPS) {
|
||||
const rel = relative(process.cwd(), file);
|
||||
console.error(
|
||||
`${rel}:${startLine}: ${interfaceName} has ${propCount} props (max ${MAX_PROPS})`,
|
||||
);
|
||||
errors++;
|
||||
}
|
||||
inInterface = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors > 0) {
|
||||
console.error(
|
||||
`\n${errors} component(s) exceed the ${MAX_PROPS}-prop limit. Use React context to reduce props.`,
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(
|
||||
`check-component-props: all component interfaces within ${MAX_PROPS}-prop limit`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user