Repository navigation
EPBDS-16211 A: the last seven class components as hooks - #132
Merged
Merged
Conversation
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
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changes
The last seven class components in
srcbecome function components:ToggleFieldmodules/form/inputsPureComponentUploadmodules/upload/viewsPureComponent,contextType, state, aDropzonerefLocalDraftTableRowengine/componentsPureComponentwith stateDataengineComponent, render onlyPopupContent(template factory)engine/components/PopupContent.jsPureComponentclass per callPopupContent(static popups)engine/mapper.jsPureComponentclass per popup nodeExamplesdemo/pagesComponentwith stateThe one class component left in
srcisRender.js's error boundary. React offers no hook forcomponentDidCatch, so it stays a class.Every
PureComponentbecamememo(a shallow props compare, as before). EverydefaultPropsbecame 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
The default
translateofLocalDraftTableRowandUpload. Both defaulted it toActive.translateas read when the module loaded:defaultPropsis evaluated once. The engine reassignsActive.translatein 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.Data's defaultdataandmeta. They were two shared objects. The nested document comparesdataby reference, so the defaults are module constants, not a new{}per render.When
Uploadtells the host about a drag. The class calledonFocus/onBlurfromsetState's callback, which runs:PureComponentskipped 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.What
ToggleFieldhands Field. The class gave Fieldthis.input, one function per instance, so Field's component type never changed. That function read the latestthis.props: all of them, includingonChangeandinstance, which Field is deliberately not given, andvalue, which overrides the field's own. A module-level component keeps the type stable, and a context gives it the props without passinginstanceoronChangethrough Field. The existing leak test still holds.PopupContentkeeps its fresh type per popup, whichrules.popup-content.test.jspins, because the memoized function is still created per call.Measured
Through the published entry, over the 38 examples, before → after:
translatecalls at mounttranslatecalls on an editdist/index.jsis 328,053 → 327,402 bytes.Tests
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.reftoUploadthrough a wrapper class; on a function component that ref would warn. The wrapper and the ref are gone; the assertion is unchanged.inputToggleflip;nestedDataKindholds its input, "Add line" appendsDES-003 | Prototype | 2 | 300, and the draft clears;activeclass;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:react17andtest:react19, 2907 tests each;test:e2e, 41 tests;test:react16lost a worker toSIGSEGVon its first two runs:StandaloneTabs.test.js, thenfields.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