Skip to content

EPBDS-16211 A: the last seven class components as hooks - #132

Merged
AlexSamBY merged 1 commit into
masterfrom
a-hooks-seven-classes
Sep 30, 2026
Merged

AlexSamBY merged 1 commit into
masterfrom
a-hooks-seven-classes

Conversation

@AlexSamBY

Copy link
Copy Markdown
Member

What changes

The last seven class components in src become function components:

component where was
ToggleField modules/form/inputs PureComponent
Upload modules/upload/views PureComponent, contextType, state, a Dropzone ref
LocalDraftTableRow engine/components PureComponent with state
Data engine Component, render only
PopupContent (template factory) engine/components/PopupContent.js a PureComponent class per call
PopupContent (static popups) engine/mapper.js a PureComponent class per popup node
Examples demo/pages Component with state

The one class component left in src is Render.js's error boundary. React offers no hook for componentDidCatch, so it stays a class.

Every PureComponent became memo (a shallow props compare, as before). Every defaultProps became a default parameter, which React 19 requires of a function component. Hooks only: TypeScript for the same seven is the next PR, as §9.2 asks (one moving part per PR).

Four semantics the obvious conversion would have changed

  1. The default translate of LocalDraftTableRow and Upload. Both defaulted it to Active.translate as read when the module loaded: defaultProps is evaluated once. The engine reassigns Active.translate in every document's constructor (rules.js), so a render-time read would take the translator of whichever document was built last. The default is a module constant.

  2. Data's default data and meta. They were two shared objects. The nested document compares data by reference, so the defaults are module constants, not a new {} per render.

  3. When Upload tells the host about a drag. The class called onFocus/onBlur from setState's callback, which runs:

    • after the commit;
    • with that commit's props;
    • even when the state was unchanged and PureComponent skipped the render.

    A hook skips the commit in that last case. So the callbacks queue, a layout effect flushes them after every commit (reading the committed props), and a counter forces the commit that runs them. The React 16 persist() handling is unchanged.

  4. What ToggleField hands Field. The class gave Field this.input, one function per instance, so Field's component type never changed. That function read the latest this.props: all of them, including onChange and instance, which Field is deliberately not given, and value, which overrides the field's own. A module-level component keeps the type stable, and a context gives it the props without passing instance or onChange through Field. The existing leak test still holds.

PopupContent keeps its fresh type per popup, which rules.popup-content.test.js pins, because the memoized function is still created per call.

Measured

Through the published entry, over the 38 examples, before → after:

before after
commits at mount 107 107
translate calls at mount 1013 1013
commits on an edit 27 27
translate calls on an edit 64 64
examples whose DOM differs, after mount / after an edit — 0 / 0

dist/index.js is 328,053 → 327,402 bytes.

Tests

  • Two tests reached into the classes and now go through the rendered component:
    • "updates the form input before invoking the external callback" called new ToggleField(...).input(...). It now clicks the rendered toggle and records what the form holds when the callback runs. It was mutation-checked: with the two calls swapped, it fails.
    • "handleKeyPress opens dropzone on Enter" passed an unused ref to Upload through a wrapper class; on a function component that ref would warn. The wrapper and the ref are gone; the assertion is unchanged.
  • Checked in the running demo:
    • both toggles in inputToggle flip;
    • a draft row in nestedDataKind holds its input, "Add line" appends DES-003 | Prototype | 2 | 300, and the draft clears;
    • "Open Popup 1" opens with its content;
    • a drag toggles the upload's active class;
    • a title click expands its example and moves the hash;
    • no console error or React warning at any step.

Gates

All exited 0:

  • lint:js, lint:css, typecheck;
  • docs:props:check, docs:views:check, css:fixture:check;
  • test:env-flags, test:coverage (thresholds included), build;
  • test:pack, test:pack:peers, test:types;
  • test:react17 and test:react19, 2907 tests each;
  • test:e2e, 41 tests;
  • the manifest contract.

test:react16 lost a worker to SIGSEGV on its first two runs: StandaloneTabs.test.js, then fields.test.js, 12 tests each, hence 2895. That is the Node 24 V8 crash (nodejs/node#62393). The third run passed 2907/2907.

🤖 Generated with Claude Code

ToggleField, Upload, LocalDraftTableRow, Data, PopupContent in both its places
(the template factory and the static one mapper.js declares per popup node),
and the demo's Examples page. The one class component left in src is
Render.js's error boundary, which React offers no hook for.

Every PureComponent became `memo`, and every defaultProps a default parameter,
which React 19 requires of a function. Four of them had a semantic the obvious
conversion would have changed, and each is kept:
- LocalDraftTableRow and Upload defaulted `translate` to Active.translate as
  read at module load. A render-time read would take the translator of the
  document constructed last, so the default is a module constant.
- Data's default `data` and `meta` were shared objects, and the nested
  document compares `data` by reference. They are module constants, not a
  new {} per render.
- Upload called the host's onFocus/onBlur from setState's callback: after the
  commit, with that commit's props, even when the state was unchanged and
  PureComponent skipped the render. A queue flushed by a layout effect, with
  a forced commit, keeps all three.
- ToggleField handed Field a per-instance function that read the latest props,
  `value` included, which overrides the field's own. A module-level component
  keeps the type stable, and a context gives it the props without passing
  `instance` or `onChange` through Field.
PopupContent keeps its fresh type per popup, the thing
rules.popup-content.test.js pins, by creating the memoized function per call.

Measured on the corpus (38 examples, published entry), before and after:
- commits at mount 107 -> 107, `translate` calls 1013 -> 1013;
- an edit, commits 27 -> 27 and `translate` calls 64 -> 64;
- the DOM after mount and after an edit is identical in all 38.

Two tests reached into the classes and now go through the rendered component:
`new ToggleField(...).input(...)`, and an unused `ref` on Upload that would
warn on a function. The rewritten ordering test was mutation-checked: with the
two calls swapped, it fails.

Checked in the running demo: both toggles flip, a draft row holds its input
and "Add line" appends it, a popup opens with its content, a drag toggles the
upload's `active` class, and a title click expands its example.
@AlexSamBY
AlexSamBY merged commit d4017fe into master Sep 30, 2026
5 checks passed
@AlexSamBY
AlexSamBY deleted the a-hooks-seven-classes branch September 30, 2026 13:09
AlexSamBY added a commit that referenced this pull request Sep 30, 2026
## What changes

The five library components #132 converted to hooks become TypeScript.
All five are renamed with `git mv`, so their history follows.

| file | notes |
|---|---|
| `engine/Data.tsx` | `DataProps`; its `instance` typed as what the
draft row reads off it |
| `engine/components/PopupContent.tsx` | `PopupContentProps`; the
memoized function is still created per call |
| `engine/components/LocalDraftTableRow.tsx` | `DraftItem`,
`LocalDraftTableRowProps`, typed state and validators |
| `modules/upload/views/Upload.tsx` | `UploadProps`, and a
`DropzoneHandle` for the ref |
| `modules/form/inputs/ToggleField.tsx` | `ToggleFieldProps`, and a
typed context for the inner component |

Out of this batch, on purpose:
- **`mapper.js`'s own `PopupContent`** waits for `mapper.js` itself
(E3).
- **The demo's `Examples`** stays JSX, which the definition of done
allows.

The pilot's conventions apply (§9.6 governance):
- exported prop types;
- still-JavaScript components cast to open prop bags, each cast to be
deleted when its component converts;
- **`propTypes` kept**, on the inner function, which React checks
through `memo`. E5 removes them separately.

## Two defects of my own, caught on the way and fixed

1. **A module-level cast of a cyclic import.** `Data` first cast its
import of `rules.js` in a top-level `const`. But `rules.js` →
`mapper.js` → `Data` → `rules.js` is an import cycle, so while `Data` is
evaluated that export is still undefined, and the `const` kept the
undefined for good. Every nested form-backed document then rendered
nothing, and six engine tests failed on it. The cast now happens at
render, which sees the live binding as the `.js` did; so does
`PopupContent`'s cast of `Render`. `CLAUDE.md` states the rule for the
rest of the TypeScript tail. Components can be cast at the top of a
file: none imports the engine (checked).
2. **Expressions restructured for the checker.**
- A type guard on the add button, `typeof oc === 'object'`, dropped the
case where the engine hands the action as a function named `addData`.
- Splitting `e && e.target ? … : e` into two conditionals added a branch
the tests never take, and `LocalDraftTableRow`'s 93% branch floor caught
it.

   Both are the original expressions again, with casts.

## Measured

- **The corpus is identical to master.** Over the 38 examples through
the published entry: commits at mount 107, `translate` calls 1013, and
27 / 64 on an edit. The DOM is identical after mount and after an edit.
- **The TypeScript share grew.** `src/core` + `src/library` hold 35
TypeScript files, 7,586 lines (was 30 and 6,677), against 122 JavaScript
files, 12,052 lines. That is 38.6% of the lines, up from 34.4%.
- **The bundle is essentially unchanged.** `dist/index.js` is 327,402 →
327,475 bytes: the render-time casts and one restated guard.

## Found, and left for a change of its own

`LocalDraftTableRow` treats `maxLength`, a validator **factory**
(`maxLength(length)` returns the validator), as a validator. A draft
whose meta says `validate: 'maxLength'` therefore records a function as
the field's error and can never be added. No tracked example does it.
The typed cast there says so rather than hiding it.

## Housekeeping

- The coverage thresholds in `jest.config.js` follow the renamed files.
- `docs/SUPPORTED-PROPS.md` is regenerated; only the paths changed.
- Comments that named the old `.js` paths name the new ones.

## Gates

All exited 0:
- `lint:js`, `lint:css`, `typecheck`, `typecheck:contract`;
- `docs:props:check`, `docs:views:check`, `css:fixture:check`;
- `test:env-flags`, `test:coverage` (thresholds included), `build`;
- `test:pack`, `test:pack:peers`, `test:types`;
- `test:react16`, `test:react17`, `test:react19`, 2907 tests each;
- `test:e2e`, 41 tests;
- the manifest contract.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant