Skip to content

refactor(react): one config-bag predicate, and a pin against a seventh spelling - #6769

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-6761-config-bag-predicate
Aug 29, 2026
Merged

refactor(react): one config-bag predicate, and a pin against a seventh spelling#6769
os-sales merged 1 commit into
mainfrom
claude/issue-6761-config-bag-predicate

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes #6761

Arm taken: converge. One exported predicate, all six occurrences reading it, and a pin that fails when a seventh spelling appears.

Re-derived on this branch's base (b98352a15), and the count is six, not five

The card names five sites. On its own base (b76ca6764) and on mine there are six: utils/unevaluatedExpression.ts carries twoscanBag's negated early return (the one the card names) and the hoisted dedupe read 40 lines below it, spelled positively. The file was counted once. The sixth is not a rounding error: ablating it silences a diagnostic (site 6 below).

# site on b98352a15 spelling
1 SchemaRenderer.tsx module-private isConfigBag (already the merge of the properties guard + propsWithoutCanonicalKeys, #6752 / PR #6763) truthiness
2 SchemaRenderer.tsx winningVisibilityKey, inline != null
3 utils/propsBagDiagnostic.ts private twin (#6708) truthiness
4 utils/unevaluatedExpression.ts scanBag (#4795) negated
5 utils/unevaluatedExpression.ts hoistednot in the card's inventory truthiness

(#6752 merged what the card calls sites 1 and 2 into row 1 before this branch started, so the surviving textual count is six occurrences in four spellings.)

!= null and truthiness cannot disagree here, and the difference is worth naming rather than smoothing over: they part company only on a falsy value, and every falsy value except document.all fails typeof x === 'object' in the very next conjunct. The texts differ; the answers cannot.

What landed

  • packages/react/src/utils/configBag.ts — the one definition, with the history and the equivalence argument.
  • All six occurrences now read it (9 call sites).
  • packages/react/src/utils/configBag.pin.test.tsthe part that matters. It scans every production source under packages/react/src for the typeof x + Array.isArray(x) conjunction on the same operand, in either order and either polarity, and requires the set to equal a declared allowlist. A seventh spelling fails it. A second leg pins that the three converged modules still import the predicate, so deleting the import and re-inlining trips leg 1 and deleting the check entirely trips leg 2.
  • Changeset with empty frontmatter — declared as releasing nothing, because nothing observable moved.

Deliberately NOT merged: the two row sites

SchemaRenderer's boundRecord scope entry and usePredicateRecordContext (hooks/useExpression.ts) ask the same SHAPE question about a data row, not a config bag. Their answer carries its own pinned meaning — binding NOTHING rather than an empty row is what keeps a host-supplied record from being shadowed. If "config bag" ever narrows (rejecting a class instance, say), those two must not follow; merging them would make that one edit with two rulings behind it. They are named in the pin's allowlist with that reason, so the next reader finds a decision rather than an oversight.

No behaviour change, and the #6752 pin is the oracle

SchemaRenderer.degeneratePropsBag.test.tsx (from #6752 / PR #6763) is untouched by this PR — git diff --name-only over the commit does not list it — and green. It is also demonstrably the oracle: it is the file that goes red in three of the nine ablations below.

Ablations — every call site, one at a time

Method per site: commit first, then replace isConfigBag(x) at exactly one call site with bare truthiness, prove the mutation reached disk (anchor count 1 to 0, mutation count 0 to 1, plus git diff --numstat), run the targeted suite, restore with git checkout HEAD -- ABSOLUTE_PATH, prove the restore with git diff HEAD empty. Driver in a try/finally so a kill mid-mutation still restores. No build step is involved: vitest.config.mts aliases @object-ui/* to each package's src/, and five of the nine ablations going red is the live proof that a source mutation reaches the run.

Targeted suite = the 7 files that cover these modules; baseline 7 passed / 123 passed, VITEST_EXIT=0.

ablated call site targeted suite what moved
properties evaluation guard (row 1) 1 failed / 122degeneratePropsBag schema.properties stops holding the authored value
props evaluation guard (row 1, #6752's twin) 3 failed / 120degeneratePropsBag the nine indexed props return
propsWithoutCanonicalKeys, props bag (row 1) 2 failed / 121degeneratePropsBag ditto, the other half
propsWithoutCanonicalKeys, properties bag (row 1) 123 passed — no test covers it probed: real difference, below
winningVisibilityKey (row 2) 123 passed — no test covers it probed: no observable difference, below
collectDroppedPropsKeys, authored bag (row 3) 2 failed / 121propsBagDiagnostic "returns null for a string / an array authored bag"
collectDroppedPropsKeys, outgoing bag (row 3) 123 passed — no test covers it probed: real difference, below
scanBag early return (row 4) 1 failed / 122unevaluatedExpressionDiagnostic "ignores non-string values and degenerate bags"
hoisted dedupe read (row 5) 123 passed — no test covers it probed: real difference, below

The four with no coverage, measured directly rather than assumed

Reading a diff shows structure, not which half is load-bearing (#6752's lesson). Each was measured through the real renderer or the exported function, guarded vs ablated, same probe both times:

  • propsWithoutCanonicalKeys' properties guard — load-bearing. Node props: { '0': 'ZERO', title: 'T' } with properties: 'ab': guarded the element receives 0: "ZERO"; ablated it receives 0: "a". With properties: ['X']: 0: "ZERO" guarded, 0: "X" ablated. A degenerate bag was subtracting the author's own key and the hoist's copy took its place.
  • winningVisibilityKey's guard — NOT load-bearing. Five shapes ('visible', ['${data.missing}'], 42, true, and a real bag), rendered outcome and console.warn text byte-identical both ways. It is doubly dominated: hasOwnProperty on a primitive or an array cannot hold a key named visible / hidden / …, and its only consumer is already gated on rawPropertiesBag, which is isConfigBag-derived. Defence-in-depth, kept (converging it costs nothing and removes a spelling), but its reason is now stated honestly rather than assumed by symmetry.
  • collectDroppedPropsKeys' outgoing guard — load-bearing for a direct caller, unreachable from the renderer. Direct calls: guarded returns null for a string and for an array outgoing bag; ablated returns ["0"..."8"] and ["0","1"] — exactly the "tells the author schema.0 is undefined, which is true and useless" failure the module's docblock names for the authored bag. From SchemaRenderer it cannot fire, because propsWithoutCanonicalKeys has returned an object at every exit since finding(react): a non-object props is object-spread into indexed React props — props: "text" reaches the element as 0, 1, 2, … #6752.
  • The uncounted sixth (hoisted) — load-bearing. collectUnevaluatedExpressions({ '0': '${data.x}' }, ['${data.x}']): guarded reports the residual expression on the schema channel; ablated returns no findings at all — the dedupe read ['${data.x}']['0'] === '${data.x}' matches and skips it. Ablating the spelling the card did not count silences a diagnostic.

Commands and exit codes

All heavy runs serialized through the container's shared verify lock; verdict lines quoted, not a bare $?.

pnpm exec vitest run packages/react/                      REACT_SUITE_EXIT=0
                                                          Test Files 67 passed (67) · Tests 989 passed (989)
pnpm --filter @object-ui/react run type-check             TYPECHECK_EXIT=0   (tsc --noEmit && tsc -p tsconfig.test.json)
pnpm exec eslint FIVE_CHANGED_FILES --format json         ESLINT_EXIT=0      5 files linted, 0 errors, 16 warnings
node scripts/check-changeset-presence.mjs                 EXIT=0
node scripts/check-changeset-no-major.mjs                 NOMAJOR_EXIT=0
node scripts/check-control-bytes.mjs                      CONTROLBYTES_EXIT=0
node scripts/check-type-check-coverage.mjs                TCCOV_EXIT=0
node scripts/check-lint-coverage.mjs                      LINTCOV_EXIT=0

The full-suite and gate runs above were made at f6b22b4db, this branch's final commit, on a clean tree.

Two measurements that would otherwise be "green but nothing measured":

  • The 16 eslint warnings are all pre-existing no-explicit-any in SchemaRenderer.tsx, at lines this PR does not touch; the two new files report zero messages.
  • type-check really covers the new files: tsc -p tsconfig.test.json --listFiles lists both utils/configBag.ts and utils/configBag.pin.test.ts.

Declared narrowing: repo-wide pnpm lint was not run locally — eslint was scoped to the five changed files. The narrowing is a measurement, not an omission: (1) the population is eslint's own config, files: ['**/*.{ts,tsx}'] plus its ignores, not my guess at what counts; (2) the file count comes from --format json (5 results); (3) the config declares no parserOptions.project and no projectService, so linting is not type-aware and this diff cannot move the verdict for any file it does not contain. CI runs the whole farm regardless.


Generated by Claude Code

…h spelling

"Is this value a real config bag (an object, not an array, not null)?" was
asked in packages/react in six places in four spellings. All six now read one
exported predicate, `utils/configBag.ts`, and `utils/configBag.pin.test.ts`
fails when a new spelling appears anywhere in `packages/react/src`.

The two sites that ask the same SHAPE question about a data ROW are
deliberately not merged; the pin names them with that reason.

No behaviour change: every spelling computed the same answer, and each site was
ablated individually to confirm what it is load-bearing for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 48 chunks) 3180.2 KB 3222.7 KB
Main entry chunk (gzip) 148.2 KB 350 KB
Entry file index-DJBrPHkd.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 11.89KB 4.50KB
app-shell (runtime-config.js) 20.61KB 7.35KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 511.68KB 116.30KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.10KB 47.96KB
fields (index.js) 240.93KB 60.76KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.89KB 12.91KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 190.33KB 45.10KB
plugin-dashboard (index.js) 133.44KB 34.48KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 245.43KB 62.45KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.96KB 32.63KB
plugin-gantt (index.js) 165.20KB 40.37KB
plugin-grid (index.js) 201.53KB 54.54KB
plugin-kanban (index.js) 53.11KB 14.62KB
plugin-list (index.js) 113.11KB 27.58KB
plugin-map (index.js) 20.17KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 27.19KB 7.82KB
plugin-tree (index.js) 9.00KB 3.08KB
plugin-view (index.js) 85.87KB 21.12KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 72.12KB 23.98KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 2.44KB 1.21KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.72KB 2.24KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(react): five spellings of one "is this a real config bag?" predicate in packages/react

2 participants