Skip to content

refactor(app-shell): paramToField's reference-bearing rule derives from core, not the last private copy - #5655

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-5312-paramtofield-derive
Aug 22, 2026
Merged

refactor(app-shell): paramToField's reference-bearing rule derives from core, not the last private copy#5655
os-sales merged 1 commit into
mainfrom
claude/issue-5312-paramtofield-derive

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#5312

packages/app-shell/src/utils/paramToField.ts restated the reference-bearing rule inline as LOOKUP_WIDGET_TYPES.has(type) || type === 'user' — the fourth and last hand-maintained answer to one question: does this widget resolve a foreign key, so hand it reference_to / display_field / the rest of the picker config? The other three converged on @object-ui/core's EXPANDABLE_FIELD_TYPES in #4770 / #4790 / #4815. This face now does too.

The comment was a claim to test, not a fact to inherit — and it was false

The disjunction carried this in-source comment:

It moves in lockstep with plugin-grid's bulkParamToField twin — the two param faces are never split.

Measured on the tip (aa3b81062) before touching anything. It was false in both senses:

paramToField (this file, before)bulkParamToField (the twin)
what the rule readsprivate literal new Set(['lookup','master_detail'])||type === 'user'EXPANDABLE_FIELD_TYPES.has(t) — core's exported object, since #4815
member set{lookup, master_detail, user}{lookup, master_detail, tree, user}
gate that could report a splitnoneidentity pin (bulkParamToField.test.ts:137)
  1. Mechanically split. The twin has read core's Set since finding(plugin-grid): DATA_SOURCE_WIDGET_TYPES 是「哪些 widget 要 DataSource」的第四份私有副本 —— 与表单规则不同集、零 gate #4815; this line read a private literal. The two faces shared no object, so "lockstep" was hand-kept — and nothing anywhere could report a divergence.
  2. Already split by membership, by exactly one member: tree.

So the comment described a guarantee that had stopped existing. It is replaced with what was measured, and lockstep now holds mechanically rather than by assertion.

What changed

  • The rule is now EXPANDABLE_FIELD_TYPES.has(type) — core's object, read, never copied.
  • The comment is replaced with the measurement above, plus the reachability argument and the "OR in a surface-local set, never new Set([...EXPANDABLE_FIELD_TYPES, …])" extension note the twin carries.
  • The module's second rule is deliberately left un-merged.LOOKUP_WIDGET_TYPES still answers a different question — which keys degrade to a text input for want of a declared referenceTo — over an overlapping but different set: user is reference-bearing yet defaults its target to sys_user, so it must never degrade. Its docblock now says so explicitly. Same split the twin keeps between its own LOOKUP_WIDGET_TYPES and widgetNeedsDataSource.

No reachable behaviour change

The derived set is one member wider (tree), and that member can never be a widget key here: it is absent from fields' widget map and mapFieldTypeToFormType sends it to field:lookup, so every key the rule tests — always resolveParamWidgetType output — arrives as lookup. Both halves are pinned, so registering a real tree widget surfaces the change instead of shipping it silently.

Pins, and the ablation legs that prove each can fail

Pinned by identity, not membership: every membership assertion in the file is satisfied by a member-identical private copy — i.e. by the exact state this PR removes — so a value check would have passed on the defect.

Each control got its own mutation leg. Every leg asserted its anchor count non-zero before mutating, then proved the mutation reached disk in both directions (removed-text count and injected-text count and the file's git hash-object), and restored under an EXIT/INT/TERM trap. No rebuild was needed and none was skipped: the test imports ./paramToField relatively and @object-ui/core through the repo-root vitest alias to packages/core/src, so both legs read source, never a dist/ artefact. (The green identity pin is itself the proof that test and module resolve to one @object-ui/core instance — a split resolution would leave the spy empty.)

legmutationexpectedmeasured
Arule → member-identical private copy new Set(['lookup','master_detail','tree','user'])identity pins red, membership green2 failed / 15 passed — exactly the two identity pins
Brule → EXPANDABLE_FIELD_TYPES.has(type) && type !== 'user'equivalence-with-the-retired-literal probe red1 failed / 16 passed — that probe alone
Calias tree: 'user', so resolveParamWidgetType('tree') !== 'lookup'tree-unreachability probe red2 failed / 15 passed — that probe + the pre-existing :51 assertion of the same fact
Dmerge the two rules (degrade rule → EXPANDABLE_FIELD_TYPES)separation probe red3 failed / 14 passed — the separation probe, the pre-existing implicit-sys_user test, and the identity pin (merging degrades user to text before the branch is reached, so the spy never sees 'user')

Leg A is the load-bearing one: it is the defect this PR removes, and only the identity pins notice it.

Leg C mutated by insertion, not replacement, so its removed-text anchor stayed at 1 by construction; the injected-text count went 1 → 2 and the file hash changed. Recorded as measured rather than smoothed into the table's shape.

All four legs restored to the pristine blob b3e8a51a, verified by hash after each leg and once more by the trap.

Verification — all at 7784e137e

Test scope is a derived superset, not a sample.packages/app-shell has ~488 test files and its full suite exceeds the container's foreground cap. Both changed files are side-effect-free ES modules and vitest isolates the module graph per test file, so a test whose static import graph never reaches a changed file cannot observe this change — there is no other channel (no global written, no registry mutated, no generated artefact). The reverse import closure of the two changed files is therefore a superset of "tests this change can affect". It was over-approximated three further ways so the answer only ever grows: specifiers resolved extension- and index-agnostically, every existing candidate path counted as an edge, and bare @object-ui/* specifiers resolved through the vitest alias table (which is what pulls the whole apps/console project in, via the app-shell barrel).

105 test files — 67 in packages/app-shell, 38 in apps/console. 1127 tests, 0 failures. Run from the repo root (package-cwd vitest is refused by the repo's own guard), paced into nine batches because a single 67-file invocation was SIGTERM'd at the cap with a buffered reporter that had written nothing — the batches use a streaming reporter so a cap kill can never again cost the findings. Each superset file was confirmed to have actually executed by diffing the TAP file names against the derived list (the apps/console project reports paths relative to its own root, so the two lists only reconcile after normalising that prefix — the first comparison "found" 38 missing files that had all run).

gateresult
105-file derived supersetTests 1127 passed, 0 failed
pnpm --filter @object-ui/app-shell type-checkexit 0 (tsc --noEmit && tsc -p tsconfig.test.json, both echoed — not a zero-match no-op)
pnpm --filter @object-ui/app-shell lintexit 0
check-changeset-presence✅ 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed / check-changeset-no-major✅ All workspace packages are in the changeset fixed group. / ✅ No changeset declares a major bump.
check-control-bytes✅ OK (scanned 4695 tracked text file(s); skipped 85 binary)
check-package-self-import✅ No package names itself inside its own src/.
check-phantom-dependencies✅ Every in-scope import is declared by the package that publishes it.
check-spec-symbol-derivation✅ 1290 files scanned against 4912 spec export names
check-lint-coverage✅ 46/46 packages linted, 0 with outstanding errors (0 total)
check-type-check-coverage✅ 45/46 via type-check … ✅ 41/41 packages compile their tests

Recorded because it looks like a discrepancy and is not: an ad-hoc eslint packages/app-shell --no-inline-config from the repo root reports 15 errors across 920 files, while the gate (turbo run lint → per-package eslint .) reports 0. The difference is --no-inline-config stripping pre-existing eslint-disable comments in 13 files this PR does not touch (12 × react-hooks/static-components, plus one no-unused-expressions and one no-console). None are in the changed files, and check-lint-coverage independently reports 0 outstanding errors repo-wide.

The two @typescript-eslint/no-explicit-any warnings on the changed file are pre-existing: linting the base blob through stdin reproduces both at the pre-shift line numbers (63/7673/86).

Scope

The root barrel was not needed and is untouched (packages/app-shell/src/index.ts does not re-export this module, the rule is read inline rather than through a new export, and the pin test imports ./paramToField relatively) — so no serialisation against #5596 is required. packages/app-shell/src/views/metadata-admin/** is untouched; its tests were only run, as part of the derived superset.

One out-of-scope finding filed, not fixed here: #5654ActionParamDialog's isLookupParam restates the picker family over raw param spellings ({lookup, reference}) while the module that performs the degradation tests resolved widget keys ({lookup, master_detail}). The two sets are not in a subset relation either way, and the measured consequence is that a targetless master_detail param degrades to a text input but gets neither the placeholder nor the help text #3405 added for that state. It is a different rule with no authority to derive from, and repairing it changes behaviour — so it is a separate card, not a rider.


Generated by Claude Code

…om core, not the last private copy
`packages/app-shell/src/utils/paramToField.ts` restated the rule inline as
`LOOKUP_WIDGET_TYPES.has(type) || type === 'user'` — the fourth and last
hand-maintained answer to "does this widget resolve a foreign key, so hand it
the reference config". The other three converged on `@object-ui/core`'s
`EXPANDABLE_FIELD_TYPES` in objectui#4770 / #4790 / #4815; this face now does too.
No reachable behaviour change: the shared set is one member wider (`tree`), and
that member can never be a widget key here — absent from `fields`' widget map,
and `mapFieldTypeToFormType` sends it to `field:lookup` — so every key the rule
tests arrives as `lookup`. Both halves pinned.
The module's second rule (which keys degrade to text for want of a declared
`referenceTo`) is a different set over overlapping types and stays un-merged:
`user` defaults its target to `sys_user` and must never degrade. Same split the
plugin-grid twin keeps.
Retires the comment claiming the disjunction "moves in lockstep with
plugin-grid's `bulkParamToField` twin — the two param faces are never split".
Measured false in both senses before this change: the twin had read core's Set
since objectui#4815 while this line read a private literal, and the two member
sets already differed by `tree`. Lockstep now holds mechanically — the pin is on
object identity (a spy on core's `has`), so a member-identical private copy
fails where a value check would pass.
Fixes#5312
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3785.8 KB3867.2 KB
Main entry chunk (gzip)151.6 KB350 KB
Entry fileindex-BVTLEGu2.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)10.04KB3.72KB
app-shell (runtime-config.js)11.22KB3.78KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)507.00KB113.72KB
core (index.js)4.51KB1.80KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.33KB
fields (index.js)238.85KB60.13KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.65KB18.32KB
plugin-chatbot (index.js)181.41KB43.22KB
plugin-dashboard (index.js)128.33KB32.93KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.16KB60.90KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.07KB30.43KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.70KB27.17KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.50KB20.68KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-sales
os-sales marked this pull request as ready for review August 22, 2026 01:14
@os-sales
os-sales added this pull request to the merge queueAug 22, 2026
Merged via the queue into main with commit 7e81168Aug 22, 2026
23 checks passed
@os-sales
os-sales deleted the claude/issue-5312-paramtofield-derive branch August 22, 2026 01:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-sales@claude