Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/UPGRADE-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,14 @@ Every workstream below is a series of small, independently shippable, reversible

**Acceptance per component:** tests green, demo example using the component pixel-equivalent, no new console warnings.

**DONE 2026-09-30, the last seven in one PR.** `ToggleField`, `Upload` and `LocalDraftTableRow` (items 3 and 4), `Data`, `PopupContent` in both its places (the template factory in `engine/components/PopupContent.js` and the static one `mapper.js` declares per popup node), and the demo's `Examples` page. Every `PureComponent` became `memo`, 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 was kept:
- `LocalDraftTableRow` and `Upload` defaulted `translate` to `Active.translate` as read when the module loaded. A render-time read would take the translator of whichever document the engine 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, so the defaults are module constants and not a new `{}` per render.
- `Upload` called the host's `onFocus`/`onBlur` from `setState`'s callback: after the commit, with that commit's props, and 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 one function per instance 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`'s fresh type per popup (`rules.popup-content.test.js`) is kept by creating the memoized function per call. **Measured on the corpus**, before → after, over the 38 examples through the published entry: commits at mount 107 → 107, `translate` calls 1013 → 1013, commits and `translate` calls on an edit 27 → 27 and 64 → 64. The DOM after mount and after an edit is identical in all 38. Two tests called the classes directly, `new ToggleField(...)` and a `ref` to `Upload`, and now go through the rendered component; the ordering one was mutation-checked. **The one class component left in `src` is `Render.js`'s error boundary, which React offers no hook for.**

### 9.3 Workstream B — Decompose the lifecycle engine (`rules.js` / `form/utils.js`)

**Motivation:** the prototype-patching of `UNSAFE_*` methods (`rules.js:593–595, 1182–1220`; `form/utils.js:397, 466, 598–615`) is the single biggest source of: StrictMode incompatibility, React 19 uncertainty, onboarding cost, and the high perceived risk of any change near the engine. 1868 lines across two files carry most of the library's behavior.
Expand Down Expand Up @@ -1773,7 +1781,7 @@ src/
| **1** | ✅ **DONE (2026-08-20).** ~~React 17: dev bump, peer widen (react + moment `^2.29.4`), QA; checkpoint release~~ — the bump and the widening landed (`peerDependencies`: react/react-dom `^16.14.0 || ^17.0.0 || ^18.0.0`, moment `^2.29.4`), and 17 has its own full-suite CI leg (`react-17` / `jest.react17.config.js`). The **checkpoint release** was cancelled by the §10 decision gate of 2026-08-18 — 17 folds into the React 18 release — so nothing publishes at the end of this phase. §5 records "Phase 1 is closed". | 1–2 days | QA checklist clean |
| **2** | ✅ **DONE except the release (re-checked 2026-09-17).** ~~React 18: `createRoot` (demo), RTL 12→16, `defaultProps` fixes, batching regression pass, peer widen, docs; checkpoint release~~ — `createRoot` at `src/demo/main.jsx:12`, `@testing-library/react` `^16.3.2` (+ `@testing-library/dom` `^10.4.1`), peers widened to include `^18.0.0`, no `flushSync` needed; §6 records "steps 1-8 done except the release". The manual publish + Pages deploy still ahead is the release itself, which stays deferred. | 1–2 weeks | CI green on 18, zero act-warnings |
| **3** | ✅ **DONE (re-verified 2026-09-17).** ~~Contract tests on all examples; JSON Schema + dev validation; error boundaries; React 16/17 CI smoke~~ — contract suites over all 38 examples (`examples.dom-contract.test.js`, `examples.behavior-contract.test.js`, `examples.meta-contract.test.js`), `meta.schema.json` at the repo root with dev validation at `src/core/ui-render/validateMeta.js`, the error boundary at `src/core/ui-render/Render.js:92` (`componentDidCatch`, with `Render.error-reporting.test.js` and `UIRender.error-hook.test.js`), and CI jobs `react-16-floor` / `react-17` running the whole suite. Appendix C's Phase 3 bullets are all ✅/☑. | 1–2 weeks | Contract suite in CI |
| **4** | Hooks migration of leaf components; ~~automatic JSX runtime~~ **shipped 2026-09-30 (§9.8)**; ~~SUIR passthrough-prop audit + lint guard (F1 step 0)~~ **shipped** (`docs/SUPPORTED-PROPS.md` + the `no-restricted-imports` guard); **housekeeping H1–H2 (dead code + docs truth)** | ongoing, per-component PRs | — |
| **4** | ~~Hooks migration of leaf components~~ **done 2026-09-30 (§9.2)**; ~~automatic JSX runtime~~ **shipped 2026-09-30 (§9.8)**; ~~SUIR passthrough-prop audit + lint guard (F1 step 0)~~ **shipped** (`docs/SUPPORTED-PROPS.md` + the `no-restricted-imports` guard); **housekeeping H1–H2 (dead code + docs truth)** | ongoing, per-component PRs | — |
| **4-S** | **Structure (§9.9): demo isolation (H3); engine re-home `pages/main`+`ui-render` → `core/engine` (H4, pure `git mv` commits); layer-direction lint (H5)** | ~2–4 days, in a quiet window | **H4 before Phase 6** |
| **TS** | **TypeScript migration track (§9.6): E0 infra after Phase 2 → utils+contract (E1) → components/modules (E2, rides 4/5a) → engine (E3, rides 6) → public-API switchover (E4) → `prop-types` dependency removed (E5); **go/no-go on the E2/E3 tail after E1** | continuous, per-PR | `tsc --noEmit` green throughout; E4 gated by the `dist/index.d.ts` golden diff |
| **5a** | ✅ **DONE (2026-09-08).** SUIR exit, JS side (F1 steps 1–3½): `Table`, `TooltipPop`, `Dropdown` all in-house, then `semantic-ui-react` removed from `dependencies` | ~3–5 weeks | behavioral + visual/a11y suites per step |
Expand Down
149 changes: 75 additions & 74 deletions src/core/engine/Data.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import React, { Component } from 'react'
import React from 'react'
import { type } from '../components'
import { Active } from '../utils'
import UIRenderWithUISetup from './rules'
import LocalDraftTableRow from './components/LocalDraftTableRow'

// The class's `defaultProps` were two shared objects, and their identity is part of what they meant: `data`
// reaches the nested document, which compares what it is given by reference. A default parameter would be a
// new object on every render.
const NO_DATA = {}
const NO_META = {}

/**
* Component to hold independent UI Render Instance Data
*
Expand All @@ -23,84 +29,44 @@ import LocalDraftTableRow from './components/LocalDraftTableRow'
* }
*
*/
export default class Data extends Component {
static propTypes = {
// Identifier for this type of data.
// Data of the same `kind` are grouped into array as list, and used for complex validation (together as group).
kind: type.Id.isRequired,
// The UI Render Instance containing this Data component
instance: type.Object.isRequired,
// The Index of this Data component in the array of rendered data for removing itself
index: type.NumberOrString,
// Data.json to use
data: type.Any,
// Meta.json to use
meta: type.Object,
// Data.json to initialize with
initialValues: type.Any,
// Whether the `name` attribute should use data relative to root UI Render instance, defaults to this instance.
rootData: type.Boolean,

relativePath: type.String,
relativeIndex: type.Number,
/** When true, TableCells draft row uses local state only until Add (no nested form / no parent values leakage). */
localDraft: type.Boolean,
}
export default function Data ({
kind, instance, index, relativeIndex, relativePath, data = NO_DATA, meta: metaIn = NO_META, initialValues = data,
className, style, embedded, useForm, localDraft,
}) {
// Use Active.UIRender to avoid circular import
const UIRender = Active.UIRender

static defaultProps = {
data: {},
meta: {},
}
// Never mutate shared meta from config: nested tables (e.g. one Data/TableCells per outer row) reuse the
// same meta object reference — writing relativePath/relativeIndex on it would leave every row with the
// last-rendered row's paths (mixed data, inputs not updating / wrong targets).
const meta = (metaIn.view === 'TableCells' || metaIn.view === 'Data')
? {...metaIn, relativePath, relativeIndex}
: metaIn

render () {
const {kind, instance, index, relativeIndex, data, meta: metaIn, initialValues = data, className, style, embedded, useForm, localDraft} = this.props
// Use Active.UIRender to avoid circular import
const UIRender = Active.UIRender

// Never mutate shared meta from config: nested tables (e.g. one Data/TableCells per outer row) reuse the
// same meta object reference — writing relativePath/relativeIndex on it would leave every row with the
// last-rendered row's paths (mixed data, inputs not updating / wrong targets).
const meta = (metaIn.view === 'TableCells' || metaIn.view === 'Data')
? {...metaIn, relativePath: this.props.relativePath, relativeIndex: this.props.relativeIndex}
: metaIn

// TableCells only, or Data wrapping TableCells (e.g. renderExtraItem). Draft values stay in React state
// until Add — no final-form fields at dataKind.*[nextIndex], so an empty `{}` is not materialized for the draft row.
if (localDraft) {
const rel = { relativePath: this.props.relativePath, relativeIndex: this.props.relativeIndex }
const draftMeta = metaIn.view === 'TableCells'
? meta
: (metaIn.meta && metaIn.meta.view === 'TableCells' ? { ...metaIn.meta, ...rel } : null)
if (draftMeta) {
return (
<LocalDraftTableRow
meta={draftMeta}
kind={kind}
parentInstance={instance}
/>
)
}
// TableCells only, or Data wrapping TableCells (e.g. renderExtraItem). Draft values stay in React state
// until Add — no final-form fields at dataKind.*[nextIndex], so an empty `{}` is not materialized for the draft row.
if (localDraft) {
const rel = { relativePath, relativeIndex }
const draftMeta = metaIn.view === 'TableCells'
? meta
: (metaIn.meta && metaIn.meta.view === 'TableCells' ? { ...metaIn.meta, ...rel } : null)
if (draftMeta) {
return (
<LocalDraftTableRow
meta={draftMeta}
kind={kind}
parentInstance={instance}
/>
)
}
}

// Table row contexts set `relativeIndex` (field array index); `index` is often unset.
// Prefer relativeIndex over index so row identity stays correct after FieldArray reindex (e.g. remove row).
const rowIndex = relativeIndex != null ? relativeIndex : index

if (useForm) {
return <UIRenderWithUISetup
data={data}
meta={meta}
initialValues={initialValues}
form={{kind}}
parent={instance}
index={rowIndex}
relativeIndex={relativeIndex}
embedded={embedded}
{...{className, style}}
/>
}
// Table row contexts set `relativeIndex` (field array index); `index` is often unset.
// Prefer relativeIndex over index so row identity stays correct after FieldArray reindex (e.g. remove row).
const rowIndex = relativeIndex != null ? relativeIndex : index

return <UIRender
if (useForm) {
return <UIRenderWithUISetup
data={data}
meta={meta}
initialValues={initialValues}
Expand All @@ -112,6 +78,41 @@ export default class Data extends Component {
{...{className, style}}
/>
}

return <UIRender
data={data}
meta={meta}
initialValues={initialValues}
form={{kind}}
parent={instance}
index={rowIndex}
relativeIndex={relativeIndex}
embedded={embedded}
{...{className, style}}
/>
}

Data.propTypes = {
// Identifier for this type of data.
// Data of the same `kind` are grouped into array as list, and used for complex validation (together as group).
kind: type.Id.isRequired,
// The UI Render Instance containing this Data component
instance: type.Object.isRequired,
// The Index of this Data component in the array of rendered data for removing itself
index: type.NumberOrString,
// Data.json to use
data: type.Any,
// Meta.json to use
meta: type.Object,
// Data.json to initialize with
initialValues: type.Any,
// Whether the `name` attribute should use data relative to root UI Render instance, defaults to this instance.
rootData: type.Boolean,

relativePath: type.String,
relativeIndex: type.Number,
/** When true, TableCells draft row uses local state only until Add (no nested form / no parent values leakage). */
localDraft: type.Boolean,
}

// =============================================================================
Expand Down
70 changes: 35 additions & 35 deletions src/core/engine/components/LocalDraftTableRow.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { PureComponent } from 'react'
import React, { memo, useState } from 'react'
import Button from '../../components/Button'
import Input from '../../components/Input'
import InputDate from '../../components/InputDate'
Expand Down Expand Up @@ -51,32 +51,33 @@ function parseRowValue (def, raw) {
return raw
}

// Read once, when this module loads, as the class's `defaultProps` were. `Active.translate` is reassigned by
// every document the engine constructs (rules.js), so reading it at render would hand a draft row whichever
// document was built last.
const DEFAULT_TRANSLATE = Active.translate

const EMPTY_DRAFT = { draft: {}, fieldErrors: {} }

/**
* Table "add row" draft: values live only in React state until the user commits (Add).
* No react-final-form Field registration — avoids leaking draft into parent `values`.
*/
export default class LocalDraftTableRow extends PureComponent {
static defaultProps = {
translate: Active.translate
}

state = {
draft: {},
fieldErrors: {}
}
function LocalDraftTableRow ({ meta, kind, parentInstance, translate = DEFAULT_TRANSLATE }) {
const [state, setState] = useState(EMPTY_DRAFT)
// `this.setState` merged into the state it was given; this keeps that shape, and the updater form.
const update = (partial) => setState((s) => ({ ...s, ...(typeof partial === 'function' ? partial(s) : partial) }))

handleChange = (name, def) => (e) => {
const handleChange = (name) => (e) => {
const v = e && e.target ? e.target.value : e
this.setState((s) => ({
update((s) => ({
draft: { ...s.draft, [name]: v },
fieldErrors: { ...s.fieldErrors, [name]: undefined }
}))
}

handleAdd = () => {
const { meta, kind, parentInstance } = this.props
const handleAdd = () => {
const inputs = collectInputs(meta.items)
const { draft, fieldErrors: prevErr } = this.state
const { draft, fieldErrors: prevErr } = state
const fieldErrors = { ...prevErr }
let hasErr = false

Expand Down Expand Up @@ -111,7 +112,7 @@ export default class LocalDraftTableRow extends PureComponent {
}

if (hasErr) {
this.setState({ fieldErrors })
update({ fieldErrors })
return
}

Expand All @@ -122,7 +123,7 @@ export default class LocalDraftTableRow extends PureComponent {
rowObject: row,
fallbackDataKindPath: ''
})
if (appended !== false) this.setState({ draft: {}, fieldErrors: {} })
if (appended !== false) update({ draft: {}, fieldErrors: {} })
}

/**
Expand All @@ -134,18 +135,17 @@ export default class LocalDraftTableRow extends PureComponent {
* `style={{verticalAlign: 'top'}}`, which is what the metas that actually align already do —
* and expect a visual change, because that one works.
*/
renderInputCell = (def, i) => {
const { translate } = this.props
const renderInputCell = (def, i) => {
const name = def.name
const { draft, fieldErrors } = this.state
const { draft, fieldErrors } = state
const value = draft[name]
const error = fieldErrors[name]
const { className, type, format: _f, validate: _v, ...rest } = def
const common = {
...rest,
name,
value: value === undefined || value === null ? '' : value,
onChange: this.handleChange(name, def),
onChange: handleChange(name),
error,
translate,
className
Expand All @@ -159,12 +159,12 @@ export default class LocalDraftTableRow extends PureComponent {
)
}

renderBranch = (item, i) => {
const renderBranch = (item, i) => {
if (item.view === 'Input' && item.name) {
return this.renderInputCell(item, i)
return renderInputCell(item, i)
}
if (item.view === 'VerticalLayout' || item.view === 'Col3') {
return (item.items || []).flatMap((sub, j) => this.renderBranch(sub, `${i}-${j}`))
return (item.items || []).flatMap((sub, j) => renderBranch(sub, `${i}-${j}`))
}
if (item.view === 'Button') {
const oc = item.onClick
Expand All @@ -173,7 +173,7 @@ export default class LocalDraftTableRow extends PureComponent {
const { onClick: _oc, children, ...btnRest } = item
return (
<Table.Cell key={`btn-${i}`}>
<Button {...btnRest} type="button" onClick={this.handleAdd} translate={this.props.translate}>
<Button {...btnRest} type="button" onClick={handleAdd} translate={translate}>
{children}
</Button>
</Table.Cell>
Expand All @@ -182,14 +182,14 @@ export default class LocalDraftTableRow extends PureComponent {
return null
}

render () {
const { meta } = this.props
if (!meta || !meta.items) return null
const cells = meta.items.flatMap((item, i) => {
const node = this.renderBranch(item, i)
if (node == null) return []
return Array.isArray(node) ? node : [node]
})
return <>{cells}</>
}
if (!meta || !meta.items) return null
const cells = meta.items.flatMap((item, i) => {
const node = renderBranch(item, i)
if (node == null) return []
return Array.isArray(node) ? node : [node]
})
return <>{cells}</>
}

// `memo` skips a render with shallow-equal props, as `PureComponent` did.
export default memo(LocalDraftTableRow)
Loading
Loading