From f73be65a0319bd391b485b7328524c7ecde8878f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 01:48:06 +0000 Subject: [PATCH 1/3] refactor(react): re-export toPredicateInput from @object-ui/core instead of duplicating it The renderer-side copy in packages/react/src/hooks/useExpression.ts had item-for-item identical semantics to the canonical implementation in packages/core/src/evaluator/predicateInput.ts (#3314), held in step only by a 14-shape normalization parity table. A guardrail against drift is not a single source of truth, so the twin is replaced by a re-export. The normalization parity table degenerated once both of its columns called the same function -- every row passed by construction. It is replaced by one identity assertion (react's export IS core's function object), which is strictly stronger. The engine-path vs renderer-path VERDICT parity suite is untouched: sharing a normalizer does not by itself prove the two call paths reach the same verdict. Fixes #3367 --- .../react-predicate-input-reexports-core.md | 5 ++ .../__tests__/actionPredicate.parity.test.tsx | 56 +++++++++---------- packages/react/src/hooks/useExpression.ts | 56 +++++++++---------- 3 files changed, 58 insertions(+), 59 deletions(-) create mode 100644 .changeset/react-predicate-input-reexports-core.md diff --git a/.changeset/react-predicate-input-reexports-core.md b/.changeset/react-predicate-input-reexports-core.md new file mode 100644 index 0000000000..ba9d9218c6 --- /dev/null +++ b/.changeset/react-predicate-input-reexports-core.md @@ -0,0 +1,5 @@ +--- +'@object-ui/react': patch +--- + +`toPredicateInput` is now re-exported from `@object-ui/core` instead of being reimplemented in `@object-ui/react`. Behaviour is byte-for-byte identical — the renderer-side copy in `packages/react/src/hooks/useExpression.ts` had item-for-item the same semantics as the canonical `packages/core/src/evaluator/predicateInput.ts` (booleans short-circuit, bare strings and non-`cel` dialects become `${…}`, a `{ dialect: 'cel', source }` envelope survives so `useCondition` routes it to the canonical `@objectstack/formula` engine, empty/absent predicates become `undefined`), and every existing import path (`import { toPredicateInput } from '@object-ui/react'`) keeps working with an unchanged signature. What changes is that there is now ONE implementation rather than two held in step by a parity table: #3314 is the record of what two normalizations do when left alone — they drift, and the same `visible:` predicate reaches different verdicts depending on whether the action was surfaced by `ActionEngine.getActionsForLocation` or rendered standalone. The 14-shape normalization parity table degenerated once both of its columns called the same function, so it is replaced by a single identity assertion (`react`'s export IS `core`'s function object); the engine-path-vs-renderer-path verdict parity suite is untouched and still proves the two call paths agree (#3367). diff --git a/packages/react/src/hooks/__tests__/actionPredicate.parity.test.tsx b/packages/react/src/hooks/__tests__/actionPredicate.parity.test.tsx index bf171706e3..fc35a9b378 100644 --- a/packages/react/src/hooks/__tests__/actionPredicate.parity.test.tsx +++ b/packages/react/src/hooks/__tests__/actionPredicate.parity.test.tsx @@ -23,13 +23,19 @@ * `{ dialect: 'cel', source }`, that was the common case, not an edge one. * * Two things are pinned here: - * 1. The two normalizers agree input-for-input. `@object-ui/core` now owns - * the canonical `toPredicateInput`; `@object-ui/react` keeps a - * renderer-side twin (hook code must not be forced through the engine - * barrel). This table is what stops them drifting apart again. + * 1. There is only ONE normalizer. `@object-ui/core` owns the canonical + * `toPredicateInput`; since #3367 `@object-ui/react` re-exports it rather + * than keeping a renderer-side twin, so this suite asserts *identity* + * (same function object) instead of enumerating input shapes. The old + * 14-shape normalization table was a guardrail against drift between two + * implementations; with one implementation it compared a function to + * itself and could no longer fail. * 2. The two PATHS agree verdict-for-verdict, including on a predicate where * the engines genuinely disagree (`null < null` faults in CEL, is `false` - * in JS) — so the parity is proven, not merely assumed. + * in JS) — so the parity is proven, not merely assumed. This half is + * untouched by #3367: sharing a normalizer does not by itself prove the + * engine and the renderer reach the same verdict, because they run the + * normalized predicate through different call paths. */ import { describe, it, expect, vi, afterEach } from 'vitest'; @@ -37,31 +43,21 @@ import { renderHook } from '@testing-library/react'; import { ActionEngine, toPredicateInput as coreToPredicateInput } from '@object-ui/core'; import { toPredicateInput, useCondition } from '../useExpression'; -/** Every shape an authored predicate can arrive in. */ -const NORMALIZATION_CASES: { what: string; input: unknown }[] = [ - { what: 'null', input: null }, - { what: 'undefined', input: undefined }, - { what: 'empty string', input: '' }, - { what: 'boolean true', input: true }, - { what: 'boolean false', input: false }, - { what: 'bare expression string', input: 'record.done == true' }, - { what: 'cel envelope', input: { dialect: 'cel', source: 'record.done == true' } }, - { what: 'template envelope', input: { dialect: 'template', source: 'record.done == true' } }, - { what: 'dialect-less envelope', input: { source: 'record.done == true' } }, - { what: 'cel envelope with empty source', input: { dialect: 'cel', source: '' } }, - { what: 'envelope without a source', input: {} }, - { what: 'number 0', input: 0 }, - { what: 'number 1', input: 1 }, - { what: 'array', input: [] }, -]; - -describe('action predicate normalization — core/react parity (#3314)', () => { - it.each(NORMALIZATION_CASES)( - 'normalizes $what identically in @object-ui/core and @object-ui/react', - ({ input }) => { - expect(coreToPredicateInput(input)).toEqual(toPredicateInput(input)); - }, - ); +describe('action predicate normalization — one implementation, not two (#3314 / #3367)', () => { + it('re-exports the canonical core normalizer instead of a renderer-side twin', () => { + // The whole guarantee, in one assertion: the name `@object-ui/react` hands + // to renderers IS `@object-ui/core`'s function object. Same function ⇒ the + // input shapes cannot disagree and there is nothing left to drift, which is + // strictly stronger than the 14-shape table this replaces (#3367). + // + // Enumerating shapes here again would be theatre — after the re-export both + // columns of that table called the same function, so every row passed by + // construction and the table could not fail for any implementation of it. + // Per-shape behaviour is still covered where it is a real assertion: + // `packages/react/src/hooks/__tests__/useExpression.test.ts` (through the + // react export) and the verdict suite below (through both call paths). + expect(toPredicateInput).toBe(coreToPredicateInput); + }); it('preserves the cel dialect instead of flattening it to a `${…}` string', () => { // The one branch #3314 was about: flattening here is what demoted the diff --git a/packages/react/src/hooks/useExpression.ts b/packages/react/src/hooks/useExpression.ts index 35c09f308a..90d92e6413 100644 --- a/packages/react/src/hooks/useExpression.ts +++ b/packages/react/src/hooks/useExpression.ts @@ -51,38 +51,36 @@ export function usePredicateScope(): Record { /** * Normalize a schema-supplied predicate (`visible` / `enabled` / `disabled` / - * `hidden`) into the `${expr}` template form expected by `useCondition`. + * `hidden`) into the form `useCondition` expects — the canonical helper to use + * in renderers so we never end up with `${[object Object]}` after JS + * template-literal interpolation. * - * Accepts: - * - `boolean` → returned as-is (predicate hooks short-circuit on booleans). - * - `string` → wrapped as `${string}` (legacy DX shorthand). - * - `Expression` envelope `{ dialect, source }` (new format from - * `@objectstack/spec`'s normalized predicate inputs) → unwrapped, then - * wrapped as `${source}`. Both `cel` and `template` dialects already use - * compatible variable syntax (`record.x`, etc.). - * - `null` / `undefined` / empty → `undefined` (default visible/enabled). + * **Re-exported from `@object-ui/core`, not reimplemented here.** There is + * exactly ONE implementation — `packages/core/src/evaluator/predicateInput.ts` + * — shared by the engine path (`ActionEngine.getActionsForLocation`) and the + * renderer path (`action-button` / `action-menu` / `action-bar` / …). See that + * file for the accepted input shapes and for why a `cel` envelope must survive + * normalization (#2661 / #3314). * - * This is the canonical helper to use in renderers so we never end up with - * `${[object Object]}` after JS template-literal interpolation. + * ## Why a re-export and not a twin (#3367) + * + * This module used to carry an independent renderer-side implementation with + * item-for-item identical semantics, held in step with the canonical one by a + * 14-shape normalization parity table. That table was a *guardrail against + * drift*, not a single source of truth — and #3314 is the record of what two + * normalizations do when left alone: they drift, and the same `visible:` + * predicate reaches different verdicts depending on which path surfaced the + * action. A re-export cannot drift, so the parity suite now pins identity + * (`react`'s export IS `core`'s function) instead of enumerating shapes, and + * keeps pinning the two PATHS verdict-for-verdict. + * + * Routing hook code through the `@object-ui/core` barrel is not a new coupling: + * this module already imports `ExpressionEvaluator` / `evalRowPredicate` from + * it, and `@object-ui/core` is a declared dependency of `@object-ui/react` + * (the reverse direction is the forbidden one — core declares "Zero React + * dependencies"). */ -export function toPredicateInput( - value: unknown, -): string | boolean | { dialect: 'cel'; source: string } | undefined { - if (value === null || value === undefined || value === '') return undefined; - if (typeof value === 'boolean') return value; - if (typeof value === 'string') return `\${${value}}`; - if (typeof value === 'object' && typeof (value as any).source === 'string') { - const src = (value as any).source as string; - if (!src) return undefined; - // #2661 — preserve a CEL-dialect envelope so `useCondition` routes it to the - // canonical `@objectstack/formula` engine (identical verdict to the server), - // instead of collapsing it to a `${source}` string on the legacy JS path. - // Every other dialect (template / unset) keeps the legacy `${…}` behavior. - if ((value as any).dialect === 'cel') return { dialect: 'cel', source: src }; - return `\${${src}}`; - } - return undefined; -} +export { toPredicateInput } from '@object-ui/core'; /** * Hook for evaluating expressions with dynamic context From 264a5f16e92b278aa642e61ba1e477746a545c5b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 02:04:51 +0000 Subject: [PATCH 2/3] docs(react): tighten the parity suite's header comment wording --- .../src/hooks/__tests__/actionPredicate.parity.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react/src/hooks/__tests__/actionPredicate.parity.test.tsx b/packages/react/src/hooks/__tests__/actionPredicate.parity.test.tsx index fc35a9b378..3f0d504520 100644 --- a/packages/react/src/hooks/__tests__/actionPredicate.parity.test.tsx +++ b/packages/react/src/hooks/__tests__/actionPredicate.parity.test.tsx @@ -24,9 +24,9 @@ * * Two things are pinned here: * 1. There is only ONE normalizer. `@object-ui/core` owns the canonical - * `toPredicateInput`; since #3367 `@object-ui/react` re-exports it rather - * than keeping a renderer-side twin, so this suite asserts *identity* - * (same function object) instead of enumerating input shapes. The old + * `toPredicateInput`, and since #3367 `@object-ui/react` re-exports it + * rather than keeping a renderer-side twin — so this suite asserts + * *identity* (same function object), not input shapes one by one. The old * 14-shape normalization table was a guardrail against drift between two * implementations; with one implementation it compared a function to * itself and could no longer fail. From c2470b274f94f6fed34c74665b2f203e56b9767e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 02:17:11 +0000 Subject: [PATCH 3/3] docs(core): predicateInput docblock states the re-export, not the retired twin The paragraph still described "renderer-side code (@object-ui/react's toPredicateInput, which has identical semantics and is pinned to this one by a parity test)" -- both halves of which this branch removed: there is no twin, and the 14-shape normalization table it referred to is gone. Comment-only, lines 73-77 of the docblock. It now names the re-export and the identity assertion that pins it, and keeps the verdict parity suite explicitly distinct -- sharing a normalizer does not by itself prove the engine and renderer paths agree. --- packages/core/src/evaluator/predicateInput.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/core/src/evaluator/predicateInput.ts b/packages/core/src/evaluator/predicateInput.ts index 0df83d3ea3..324c80d139 100644 --- a/packages/core/src/evaluator/predicateInput.ts +++ b/packages/core/src/evaluator/predicateInput.ts @@ -70,11 +70,19 @@ export type EvaluatorPredicateInput = * action was surfaced by `ActionEngine.getActionsForLocation` or rendered * standalone (#3314). * - * This is the canonical, engine-side helper — `@object-ui/core` is the common - * dependency of every consumer, so renderer-side code (`@object-ui/react`'s - * `toPredicateInput`, which has identical semantics and is pinned to this one - * by a parity test) and engine-side code can share one normalization instead - * of hand-rolling envelope unwrapping per call site. + * This is THE implementation, not one of two — `@object-ui/core` is the common + * dependency of every consumer, so engine-side code and renderer-side code + * share one normalization instead of hand-rolling envelope unwrapping per call + * site. `@object-ui/react`'s `toPredicateInput` is a re-export of this function + * (since #3367; it used to be an independent twin held in step by a 14-shape + * normalization parity table, which is exactly the arrangement the paragraph + * above describes the failure mode of). What pins that now is the identity + * assertion in + * `packages/react/src/hooks/__tests__/actionPredicate.parity.test.tsx` — the + * react export must BE this function object — alongside the engine-path vs + * renderer-path verdict parity suite in the same file, which is a separate + * claim and still earns its keep: sharing a normalizer does not by itself + * prove the two call paths reach the same verdict. */ export function toPredicateInput(value: unknown): EvaluatorPredicateInput { if (value === null || value === undefined || value === '') return undefined;