Goldie Blocks

Component File Partitions Must Preserve Locality

When deep extraction is preferred, when a single-consumer partition is allowed for navigation, and which seams still destroy locality

Amendment — 2026-09-28 (owner)

File splits are welcome. The owner relaxed this decision: splitting a large component file by concern is ordinary work and needs no separate design review. Where the split pieces share many values, bundle them into one named Interface (a React context or one typed object) rather than threading them through props; with that bundle, splitting a render into child components is allowed. The GB-84 split of drafting-editor.tsx (CanvasCardContext, canvas-block-card.tsx, canvas-module-card.tsx) is the model. What remains refused: a file that only forwards props unchanged, and cuts at arbitrary line counts. The text below is kept as the earlier record; where it is stricter than this amendment, this amendment wins.

Decision

Prefer deep extraction, but permit a single-consumer file partition when it materially improves navigation and keeps a small, cohesive Interface. A file partition may be organisational rather than a claim that the child is an independently deep CodeModule. That is acceptable; the test is whether the partition preserves locality instead of replacing one large file with a props-shaped conduit between files.

A convenience partition is permitted when:

  • the extracted file owns its subtree completely;
  • its Interface is a modest set of meaningful inputs — roughly 8–10 is a review prompt, not a mechanical limit;
  • mutable refs, state setters and render closures do not cross the seam; and
  • an ordinary change to the extracted concern can be understood and completed without reading and editing both files.

Single-consumer status is therefore not a refusal by itself. It removes the reuse argument, but navigation and context size can still justify the file if the locality test passes. Three specific shapes remain rejected repo-wide:

Rejected splitWhy it is refused
Moving a component's props type to a sibling *.types.tsThe props type is the CodeModule's Interface. Separating it from the Implementation leaves two files that must be read together to answer any question about either.
Splitting a component's render into per-branch child componentsBranches that share state, a closure or a wrapper can only be split by threading that state through props — the conduit-layer disease, restated inside one folder.
Extracting a private subtree through a large forwarding InterfaceSame threading cost, no locality gain: the reader still needs both files, while the props list calcifies an incidental parent closure.

What a component file does give up first: pure clusters with their own tests, rendering vocabularies with more than one consumer, and self-contained subtrees with their own props contract. Those are deep extractions — the file loses a concern entirely and the reader gains a CodeModule they can understand alone. A convenience partition is the secondary tool for a coherent subtree that improves scanning or fits working context better without pretending to have the same architectural leverage.

The *.types.ts pattern shown in Codebase Versioning is not adopted for component props. It has zero uses in the component tree. That doc's concern is cross-version type sharing between v1/ and v2/, which is a real problem when two versions must agree on a wire shape. It is not a licence to file a component's own props away from the component, and adopting it for that reason would set a repo-wide precedent on the strength of one file's line count.

Context

Amendment — 2026-08-03

The original decision treated a single consumer as sufficient reason to refuse every private subtree extraction. The owner rejected that absolute: smaller files can be useful for human navigation and agent context even when reuse is not the goal.

The amendment does not approve arbitrary splitting and does not reverse the specific BlockCard findings below. It changes the deciding question from “does a second caller exist?” to “does this file seam preserve locality with a small cohesive Interface?” The earlier refusals remain evidence about those particular candidate seams, not a prohibition on convenience partitions as a class.

Candidate 9 in the architecture-deepening plan brought block-card/v4 up for decomposition: block-card.tsx at 1,925 lines and block-fields-view.tsx at 846, the two largest files in the repo and both well past the deepening trigger in File Size.

The grill accepted six extractions and refused three splits. The accepted six each pass that doc's honest test — after the move, a reader answers a question about the extracted concern by reading only the new module. chunkContent is a pure markdown reader with its own node test. getDefaultDisplaySource is a pure function with its own node test. The field-type marks are a pure, string-keyed lookup already tested through. DataFieldRow is a self-contained row with its own props.

The three refusals all failed the same test, from different directions.

BlockCardProps is ~185 lines and it is the Interface. Moving it out would shrink block-card.tsx by a tenth and buy nothing: nobody can review a prop's meaning without the component that consumes it, and nobody can review the component without the props. The two would be read together always, edited together always, and versioned together always — which is the definition of one CodeModule.

BlockCard's content and data branches share the renderControlPanel closure and the candidate wrapper. Splitting them into ContentBlockCard and DataBlockCard would mean passing a render closure down as props. That is the shape the BlockAuthoringActions grouping was a reaction against: files whose whole content is restating a signature and forwarding it on.

An earlier draft of this decision also named the facet-height measurement state as shared by both branches. That was wrong, and re-grilling the seam on 2026-08-02 established the correction: the data branch contains zero references to contentFacetRef, facetScrollerRef, contentFacetHeight, grownHeight or isContentFacetActive. The machinery is confined to the content branch.

The correction strengthens the refusal rather than weakening it. It means the height state entangles the content branch specifically — extracting that one would have to thread a ref and a setState across a props seam while the effect reading them stayed behind, which is a shared mutable cell with a props-shaped hole in it, not an interface. And the data branch, freed of this objection, still fails on its own terms: it takes 20 free variables, 12 of them pure forwards to BlockFieldsView. Counted at 1,435 lines, the file's size remains the right shape.

FieldRow in block-fields-view.tsx is 370 stateless lines — 44% of its file, and superficially the most extractable thing in the folder. It takes ~20 props from BlockFieldsView and has exactly one consumer. Extracting it converts an internal call into a 20-prop interface that no second caller will ever use, and a reader chasing a field-row question still ends up in both files. It comes out if and when a second consumer appears; not before.

When a convenience partition still fails

The cleanup does not happen and the Interface calcifies. Once FieldRow has a 20-prop exported contract, every later change to BlockFieldsView negotiates with it, and the props list grows rather than dissolving. Convenience is not enough to justify refs, setters, render closures, or a large forwarding object crossing the seam. The cheap moment to refuse a bad seam is before it exists.

Why not leave the files alone entirely

Because six of the nine candidate seams are genuinely deep, and two of them were already being reached through: content-chunks.node.test.ts and default-display-source.node.test.ts both deep-imported src/entities/block/ui/block-card to get at pure functions. The test surface had already found the module boundary that the file layout was refusing to draw. Refusing the three bad splits is not an argument for refusing the six good extractions.

Why not a v5/

The Interface does not change — the barrel index.ts is untouched and all five external consumers keep importing exactly what they imported before. Codebase Versioning versions on Interface change, and candidate 4 already set this precedent: a pure internal reshape stays in place. No subfolders either; the repo has zero precedent for nesting inside a vN/ directory.

On this page