Repository navigation
EPBDS-16211 Phase 6 step 2: dissolve the engine/form-modules import cycle - #69
Merged
Merged
Conversation
…ycle §9.3 step 2 has two halves. This is the cycle half; the pure-logic extraction (`showIf`, payload assembly, data processing into utils) is NOT started, and the plan now says so rather than reading as one finished item. THE CYCLE. `modules/form/utils.js` imported `errorsProcessing` from `engine/utils` and `clearErrorsMap`/`formsStorage` from `engine/rules`, while the engine imported `storedTouched` and `withForm` back. Both sides reached across for registries that happened to be declared on the other side, so the three of them — `formsStorage`, `errorsMap`, `storedTouched` — move to a new `src/core/state/` layer that both may import, and are re-exported from where they were declared so no call site moves. `errorsProcessing` deliberately did NOT move, which is where this departs from the item's wording. It reads a meta node and writes the error map: engine business. A neutral module would have put engine logic BELOW the layer that uses it. The engine hands it to `withForm` as a callback instead, through the options bag that was already there, and `withFormSetup` forwards it. WHAT THE CYCLE WAS HIDING, AND IT IS A REAL DEFECT. `engine/rules.js` registered six `FIELD.TYPE` names — AutoSubmit, Data, Icon, Image, Popup, TableCells — that `engine/mapper.js` dispatches on, while `rules.js` imports `mapper.js`. The resolver depended on a constant its own importer installed, and the cycle guaranteed the importer always loaded. Cut the cycle and anything loading the mapper without `rules.js` gets a `switch` comparing against `undefined` for those six: six documented views silently unreachable. Two mapper tests failed within a minute. The constants now sit with the other thirty in `modules/variables/fields.js`, the generated view reference is byte-identical, and the generator's declaring-sites tripwire was updated. THE LAYER IS ENFORCED, NOT INTENDED. `src/core/state/**` has its own `no-restricted-imports` entry and `utils` gained a ban on importing it, so the stack still reads one way: utils < state < components < modules < engine. Verified by adding an illegal import and watching lint go red, then removing it — §9.9-H5 is where a layer rule sat dead because nothing checked it fired. Two tests changed shape rather than being repointed. `utils.form-lifecycle` mocked the engine modules only because of the cycle; it now mocks the registry where it lives, with getters, because both `let` bindings are REPLACED by their owners and a plain property hands the test the object from before the replacement. `utils.field-lifecycle` asserted "refreshes meta errors" by counting a second `getRegisteredFields()` call that the real `errorsProcessing` happened to make — a side effect of the collaborator standing in for the collaboration. It now names the callback.
AlexSamBY
added a commit
that referenced
this pull request
Sep 24, 2026
…s baseline (#74) `storedTouched` and `formInitialValues` were both module-level, and they are **the same mechanism**: the registry remembers touches, the baseline decides when to throw them away. Converting one without the other would have left the clearing keyed on a global owned by whichever form initialised last. Both are per form now, keyed by the final-form object like the error map before them. Two commits on purpose: the first records the measurement that decided the shape, the second does the work. ### The only demonstrable symptom in this family — and it is the baseline, not the sharing Four scenarios failed to show any cross-document effect from the *shared registry*: two documents with the same field name at mount; the second re-rendering afterwards; a fresh document opened after another was touched; and a second document appearing beside a touched one. The display condition is `touchedFor(form)[name] || touched || !pristine`, and final-form's own `touched` dominates it while the field is mounted. The *shared baseline* is a different story. A second document mounting with its own `initialValues` looked like a re-initialisation, and **wiped the first document's touched fields and errors**. `utils.form-lifecycle`'s *"does not let a second form reset the first one when it initialises"* pins it, and fails on the code it replaces. ### A third instance of #69's defect class, found by removing an import `engine/utils.js` imported `storedTouched` from the form module. Dropping that import broke **six Select-reordering tests** in a suite that never mentions forms. The cause: `engine/utils.js` compares a meta node against `FIELD.TYPE.SELECT`, which `modules/form/constants.js` registered — so the comparison worked only because the engine happened to import the form module for something else. Those five field views now sit in `variables/fields.js` with the rest of the vocabulary; the generated view reference is byte-identical and the generator's declaring-sites tripwire was updated. **This is the third time an "unnecessary" import turned out to be registering a constant its consumer depends on** (§9.3 step 2 found the first two). It is recorded in the plan as a class, not an incident. ### Fields without an instance A field reaches its form through the `instance` the engine gives every field — `mapper.js:640` passes it on every `renderField` call. A field built without one, which happens only in tests, has no remembered touches to consult; the date-field test now says that explicitly instead of poking a global. ### The registry mock is gone It existed to spy on a `clearErrorsMap` that cleared one shared object, and across two conversions it fell behind the real module **twice**. The test now asks the real registry what a form holds — which is what the code under test does. Fourteen more harnesses stopped resetting a global between cases. ### Gates All 17 green by exit code, plus the example-manifest contract after staging. 2592 tests. Published `static/all.css` unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
AlexSamBY
added a commit
that referenced
this pull request
Sep 24, 2026
…s own (#76) `Decorator` wrote thirteen methods, five getters and a state shape straight onto `Class.prototype` — the class `rules.js` had just declared, patched in place as a side effect of importing the module. It now installs them on a **subclass**, so the class handed in is left exactly as written. ### The measurement changed the shape, and the item's proposal with it §9.3 step 5 proposes an HOC, `withUIRenderLifecycle(Component)`. For the engine that is the wrong instrument: | decorator | consumers | | --- | --- | | `Decorator` | 1 — `UIRender` | | `withDataKind` | 1 | | `withFormSetup` | 1 | | `@withTimer` | **7 components** | There is no reuse for an HOC to provide, and an extra component layer is precisely what has caught this codebase out twice already — the wrapper-versus-instance distinction in #69 and #74. Only `@withTimer` is a genuine shared HOC, and it already cleans up correctly. ### The load-bearing detail, found before the edit and not named in the item `engine/Data.js` renders nested documents through `Active.UIRender`, which pointed at the **raw** class — it worked only because the prototype was mutated. `Active.UIRender` is now the layer, which is what it always meant: the engine class *without* the form wrapper. Three test suites reached for `UIRender.prototype` for the same reason and read the layer through the same channel now. ### Also measured: three of the five "call the original" guards were dead Instrumenting `Decorator` at decoration time: ``` componentWillUnmount: undefined UNSAFE_componentWillMount: undefined UNSAFE_componentWillUpdate: undefined UNSAFE_componentWillReceiveProps: function ``` Only two had a real original. With a subclass those captures read genuine parent methods — which is what "the original" always meant. ### Still open, and said so in the plan rather than implied The bodies are installed on the subclass's prototype rather than written as class members, so the file still reads as assignment rather than declaration. Moving seven hundred lines into member position is a separate change with its own risks. What this one removes is **the mutation escaping to somebody else's class**. ### Gates `rules.lifecycle-layer.test.js` pins both halves — the declared class is untouched, the layer carries everything — and fails on the code it replaces. All 17 gates green by exit code, plus the example-manifest contract after staging. 2599 tests. Published `static/all.css` unchanged at `dcdb0a40…`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
AlexSamBY
added a commit
that referenced
this pull request
Sep 24, 2026
The first of the extractions §9.3 step 2 asks for.
`showIf` was the **opening fifty-three lines of `RenderComponent`**, a
function seven hundred lines
long, and there was no way to reach it except by rendering a tree. Its
three accepted shapes, the
array-row path prefixing and the **two separate** `relativeData: false`
escapes were described in
comments and asserted nowhere.
It is now `engine/showIf.js`: a function of its arguments, reading
nothing module-level and writing
nothing. `instance` is an argument like any other — read twice, and only
to ask the engine what
`{state.xxx}` interpolates to and what the live form holds.
### It lives in the engine, not in `ui-utils-pack` as the item says
`showIf` is meta semantics rather than a general-purpose helper, and
`utils` sits **below** the
engine. Putting it there would place engine logic under the layer that
consumes it — the same reason
`errorsProcessing` stayed put when the registries moved out in #69. The
item's *"pure functions in
`ui-utils-pack`"* is read as *"pure functions, at the right layer"*, and
the plan now says so.
### The tests were mutation-checked, not trusted for passing
Sixteen direct cases, all green on the first run — which is not evidence
of anything on its own. So
four mutations of the extracted logic were applied and each had to be
caught:
| mutation | caught |
| --- | --- |
| ignore `equal`, always test truthiness | ✅ |
| drop the array-row path prefix | ✅ |
| ignore `relativeData` on a string `showIf` | ✅ |
| skip the merge with live form data | ✅ |
What says the extraction moved no *behaviour* is the 38-example DOM
snapshot, unchanged, plus the
whole suite at 2599.
### One behaviour preserved deliberately, and now stated rather than
implied
A `showIf` that is neither a string nor an object — a number, a boolean
— gates nothing. The
original fell through every branch and rendered; so does this, and a
test says so.
### Still to extract in this half
Payload assembly and the data processing in `rules.js`. Neither has
moved.
### Gates
All 17 green by exit code, plus the example-manifest contract after
staging. `test:react17` failed
once on a jest worker `SIGSEGV` with all 2598 of its tests passing — the
same infrastructure crash
seen in #72, in a different file; two clean re-runs confirm it.
Published `static/all.css` unchanged
at `dcdb0a40…`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
AlexSamBY
added a commit
that referenced
this pull request
Oct 2, 2026
## What changes **The checklists in `docs/UPGRADE-PLAN.md` now match what the plan records as done.** Several boxes were still open, or said "still open", after the work had landed and been recorded elsewhere in the plan. - Each box closed here cites the evidence that closes it. - Each box that stays open now says why. There is no code change. In `package.json`, only the text of lint messages changes. ### Closed, with the record that closes them **§9.3 step 3, de-globalizing instance state, is marked done.** - Its header read "STARTED 2026-09-23: `Active.translate` is done, the rest is not". - But every item under it had been closed or measured not to leak, and §11 retired R14 on that evidence (2026-10-01). **One item in step 3 had no record: the translation registries.** I checked them now, and there is nothing to convert: - Every `localiseTranslation` call in `src/core` runs at module top level. - `Active.LANG`, the language the phrases are read in, is assigned nowhere outside the tests. - The same search found no other write to `Active` from an instance, apart from the `translate` fallback that step 3 already records. `renderField`, `Field` and `UIRender` are code, and the same for every document. `passwordCheck` is set by the host. - Each zero-hit search was proven with a control: the same pattern run on the tests finds their writes. **Phase 6: five boxes are ticked.** - **State per instance:** §9.3 step 3 and R14, as above. - **Runtime hazards:** §9.3 step 4 records "This step is now empty", and R17 was retired on 2026-09-23. - **The engine ⇄ form-modules import cycle:** gone since #69 (2026-09-23). Re-checked: - No source file under `src/core/modules` imports the engine. Only its tests do, to render whole documents. - No suppression of the layer rule is left in `src`. - **Two instances on one page:** four tests cover it: `rules.two-instances.test.js`, `rules.two-instances-errors.test.js`, `popup-root.two-instances.test.js`, and the two-documents case in `examples.strict-mode.test.js`. - **StrictMode:** done 2026-09-29 (§9.3 step 7). The test has run on virtual time since #149. **The go/no-go on the E2/E3 tail of the TS migration.** It was decided GO on 2026-09-30 (§9.6, Governance), and the tail is done. **H1/H6: the pack `Tabs`.** - #60 (2026-09-22) renamed it `StandaloneTabs`, because the two copies are not interchangeable. - Its one importer is the demo's `NavTabs`. - `view: 'Tabs'` renders the engine's copy. **H1/H6: grepping consumer metas for orphan names is moot, as measured.** - The mapper registered none of the twelve component files that #53 deleted. - Eleven of the names were never views. Each renders the same "… field does not exist!" placeholder on the commit before #53 and on master. - The twelfth, `TabList`, resolves to the engine's copy on both. - So whatever a consumer's metas contain, the deletion changed nothing they render. **Phase 1: the react-refresh dev loop, checked on 18.** - The demo has developed against React 18.3 since #31, and the checkpoint release on 17 was cancelled (§10). So there is no dev loop on 17 left to check. - In the running demo, an edit to `src/demo/pages/Examples.jsx` arrived as a hot update, and so did reverting it. - Neither reloaded the page: a value set on `window` survived both, and so did a number typed into a field. **Ongoing invariant: the alias grep stays at zero.** - It reads 0 today. - No `ui-*-pack` alias is configured in any webpack config or in jest, and no such package is installed. So an import of one fails to resolve. **Two findings are marked resolved.** - **E3's `colGroup` propType:** E5 deleted the propTypes, and the TS type, `TableColGroupProps['colGroup']`, says `style`. - **F1's tooltip half:** closed at F1 step 2. `omitProps` keeps `view`, `index` and `symbol` off the bubble. ### Left open, now saying why - **F2:** "The gate did not open: `moment` stays (2026-09-30)". Its two boxes wait for a reopening. The F2-seam invariant now says it applies only if F2 reopens. - **F4:** postponed on 2026-09-30, and no longer a React 19 prerequisite. Its two boxes stay open. ### `package.json`: the layer lint's messages Only the message text changes, in 14 messages: - **The `core/modules` message** said "Two sites in modules/form/utils.tsx predate this rule and carry an eslint-disable". #69 (§9.3 step 2) removed both. - **The `core/state` override's three messages** began "core/utils must not import". They were copied from the utils override. - **The layer direction every message quotes left out `core/state`.** It now matches CLAUDE.md, and the bans configured in the overrides. I put a probe import of the engine in `src/core/state` and another in `src/core/modules`. Both still fail lint, with the new text, and the probes are removed. Rewriting `package.json` through JSON leaves it byte-identical when nothing is changed, so nothing but these 14 lines moved. ## Gates All exited 0, run on this branch: - `lint:js`, `lint:css`, `typecheck`, `typecheck:contract`; - `docs:props:check`, `docs:views:check`, `css:fixture:check`; - `test:env-flags`, `build`; - `test:coverage`, thresholds included; - `test:pack`, `test:pack:peers`, `test:types`; - `test:react16`, `test:react17`, `test:react19`, 2854 tests each, with no worker crash; - `test:e2e`, 41 tests; - the manifest contract. The number of tests is unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
AlexSamBY
added a commit
that referenced
this pull request
Oct 2, 2026
## What changes **Five sentences in `docs/UPGRADE-PLAN.md` no longer say that the `Tabs` duplicate is open.** - They still said that the pack `Tabs.js` was on disk, and that the H6 duplicate was only half resolved. - In fact #60 renamed the pack copy `StandaloneTabs` on 2026-09-22, and §9.9-H6 records the item as closed. | Where | It said | It now adds | |---|---|---| | §1, executive summary | "the pack `Tabs.js` is still present" | the rename, and why the copies were renamed rather than merged | | §2.6, finding 14 | "the pack `Tabs.js` survives" | the same | | §9.2, step 1 | "`Tabs` is **still open** … the gate above still applies" | the gate is met, and the copy was converted to hooks on 2026-09-28 (§9.3 step 6) | | Appendix A, structure facts | "**H6 is not closed**" | H6's `Tabs`/`TabList` item is closed, and its other renames are still open | | Appendix C, archived snapshot | "only half reconciled" | the rename | Each sentence is struck through and followed by what closed it, as the plan marks its other closed items. **How I checked that none is left:** I searched the plan for the stale phrases: - "H6 is not closed"; - "half resolved" and "half reconciled"; - "`Tabs` is **still open**"; - "`Tabs.js` is still" and "`Tabs.js` survives". They occur on six lines, and on each one only inside a strikethrough. #150 struck the sixth. ### Found, and not changed here - **The orphan scan in Appendix B scans nothing now.** It loops over `src/core/components/*.js`, and since the TypeScript migration the pack has no `.js` file: it has 42 `.ts`/`.tsx` files. - In zsh the glob fails. - In bash the loop runs once, on the literal pattern. - Its `Tabs` example also predates the rename. - **Appendix A's structure facts are stale beyond `Tabs`.** They still list three things that are gone: - the engine ⇄ form-modules cycle, which #69 removed; - an import of `modules` from `components/Text.js:4`, while `Text.tsx` imports nothing from it; - the demo entries at the `src/` root, which H3 moved to `src/demo/`. ## Gates All exited 0, run on this branch: - `lint:js`, `lint:css`, `typecheck`, `typecheck:contract`; - `docs:props:check`, `docs:views:check`, `css:fixture:check`; - `test:env-flags`, `build`; - `test:coverage`, thresholds included; - `test:pack`, `test:pack:peers`, `test:types`; - `test:react16`, `test:react17`, `test:react19`, 2854 tests each, with no worker crash; - `test:e2e`, 41 tests; - the manifest contract. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
AlexSamBY
added a commit
that referenced
this pull request
Oct 2, 2026
## What changes These are the two findings that #151 recorded and left for a change of their own. Both are in `docs/UPGRADE-PLAN.md`. ### The orphan scan in Appendix B reads TypeScript again **It had checked no component since the TypeScript migration.** It looped over `src/core/components/*.js`, and the pack has no `.js` file now: it has 42 `.ts`/`.tsx` files. - **In zsh**, the glob fails: `no matches found: src/core/components/*.js`, and nothing is printed. - **In bash**, the loop runs once, on the literal pattern. It prints one line, `198 *`: the pattern becomes `/*'`, which matches every file that contains a `'`. **The new command finds the pack's `.ts` and `.tsx` files** and strips either extension. It matches an import in either quote style, and leaves out the component's own file under either extension. How I checked it: - **Run as the plan prints it,** in bash and in zsh, it scans all 42 files, with the same output in both shells. - **It reports nothing as an orphan today:** no pack file reads 0. - **It can still fail:** a file I planted in the pack with no importer read 0. I then deleted it. **Its notes on reading the output are re-measured.** The old ones described what the scan showed on 2026-09-17: - **`index`** used to be "the only remaining 0". It now reads 5, and none of the five is the pack's barrel: three import the engine's `index`, and two are doc comments. The barrel itself is imported by directory path (`from '../components'`), which the pattern cannot see. - **The same-name trap still applies, with a current example.** `files` reads 5, and two of the five import `modules/variables/files.ts`. The old example, `Tabs`, went with the H6 rename. - **A count includes the demo.** `StandaloneTabs` reads 1, and that one importer is the demo's `NavTabs`. ### Appendix A's structure facts Each fact that is gone is struck through and followed by the change that removed it, as the plan marks its other closed items: - **The engine ⇄ form-modules cycle** went with #69 (§9.3 step 2, 2026-09-23). - **`components/Text.js:4` importing `modules`** went with #64 (H5, 2026-09-23). The layer lint rejects both of these imports now. - **The demo entries at the `src/` root** moved to `src/demo/` with #63 (H3, 2026-09-23). Two more fixes in the same bullet: - **The orphan-scan result** is the re-run above. It used to say the barrel read 0. - **The engine `TabList`'s path** now also gives its current one, `src/core/engine/components/TabList.tsx`. This one was not in #151's list. There is no code change. ## Gates All exited 0 on this branch, `test:coverage` on its second run: - `lint:js`, `lint:css`, `typecheck`, `typecheck:contract`; - `docs:props:check`, `docs:views:check`, `css:fixture:check`; - `test:env-flags`, `build`; - `test:coverage`, thresholds included; - `test:pack`, `test:pack:peers`, `test:types`; - `test:react16`, `test:react17`, `test:react19`, 2854 tests each, with no worker crash; - `test:e2e`, 41 tests; - the manifest contract. **The first `test:coverage` run failed one test, and this PR is not the cause.** `examples.strict-mode.test.js` compares each example's DOM after an edit, with and without StrictMode. On `tableForm` the two runs read different DOM: - the plain run read the date picker still open, with the typed text; - the strict run read it closed, with the value reverted. This is the same kind of race #149 fixed, through animation frames instead of timers: - The picker opens, aligns and closes on animation frames, through `rc-util/raf`. #149 left `requestAnimationFrame` on real time. - The 400 ms the test waits after an edit are virtual, and they take 1–3 ms of real time. Whether a real frame lands inside them is chance. With the test's own timer setup and no coverage, 2 of 3 plain runs had no frame there, and read the picker open. - With frames suppressed, both modes read the picker open. So the comparison depends on whether a frame ran. The rerun passed all 2854 tests. The fix belongs in the test, in a change of its own. 🤖 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.
§9.3 step 2 has two halves. This is the cycle half only — the pure-logic extraction (
showIf,payload assembly, data processing into
utils) is not started, and the plan now says so rather thanreading as one finished item.
The cycle
modules/form/utils.jsimportederrorsProcessingfromengine/utilsandclearErrorsMap/formsStoragefromengine/rules, while the engine importedstoredTouchedandwithFormback. Both sides reached across for registries that happened to be declared on the other.The three registries —
formsStorage,errorsMap,storedTouched— move to a newsrc/core/state/layer both may import, and are re-exported from where they were declared, sono call site moves.
errorsProcessingdeliberately did not move, which is where this departs from the item'swording. It reads a meta node and writes the error map — engine business. A neutral module would
have put engine logic below the layer that uses it. The engine hands it to
withFormas acallback instead, through the options bag that already existed.
What the cycle was hiding — a real defect, not a test artifact
engine/rules.jsregistered sixFIELD.TYPEnames (AutoSubmit,Data,Icon,Image,Popup,TableCells) thatengine/mapper.jsdispatches on — whilerules.jsimportsmapper.js. Theresolver depended on a constant its own importer installed, and the cycle guaranteed the importer
always loaded.
Cut the cycle, and anything loading the mapper without
rules.jsgets aswitchcomparing againstundefinedfor those six: six documented views silently unreachable. Two mapper tests failed withina minute of the cut. The constants now sit with the other thirty in
modules/variables/fields.js;the generated view reference is byte-identical and the generator's declaring-sites tripwire was
updated to match.
The layer is enforced, not intended
src/core/state/**has its ownno-restricted-importsentry, andutilsgained a ban on importingit, so the stack still reads one way:
Verified by adding an illegal import and watching lint go red, then removing it. §9.9-H5 is where a
layer rule sat dead because nothing ever checked that it fired.
Two tests changed shape rather than being repointed
utils.form-lifecyclemocked the engine modules only because of the cycle. It now mocks theregistry where it lives, through getters: both
letbindings are REPLACED by their owners, and aplain property hands the test the object from before the replacement.
utils.field-lifecycleasserted "refreshes meta errors" by counting a secondgetRegisteredFields()call that the realerrorsProcessinghappened to make — a side effect ofthe collaborator standing in for the collaboration. It now names the callback.
Gates
All 17 green by exit code in one clean run, plus the example-manifest contract re-run after staging.
Published
static/all.cssunchanged at sha256dcdb0a40….🤖 Generated with Claude Code