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
5 changes: 5 additions & 0 deletions .changeset/react-predicate-input-reexports-core.md
Original file line number Diff line number Diff line change
@@ -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).
18 changes: 13 additions & 5 deletions packages/core/src/evaluator/predicateInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
56 changes: 26 additions & 30 deletions packages/react/src/hooks/__tests__/actionPredicate.parity.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,45 +23,41 @@
* `{ 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`, 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.
* 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';
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
Expand Down
56 changes: 27 additions & 29 deletions packages/react/src/hooks/useExpression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,38 +51,36 @@ export function usePredicateScope(): Record<string, any> {

/**
* 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
Expand Down
Loading