Skip to content

Commit 39fbfac

Browse files
committed
fix(app-shell): the Studio decides a screen field's visibleWhen live, through the runner's renderer, over the screen's declared fields
The Studio screen preview and the Debug run's screen pause judged each field's `visibleWhen` once, against the run's variables, dropped the fields judged hidden and stripped the predicate from the rest, so a sibling-field predicate (`createOpportunity == true`, `discount > 0`) stayed hidden however the author ticked or typed. `buildScreenSpec` now carries the predicate raw and `ScreenView` decides it live; the Studio's own evaluator for this key (`isFieldVisibleWhen`, `fieldVisibility`, `evalVisibleWhen`) is removed. The Debug run's screen step names, as an error, a predicate that references a name that is not a field declared on this screen, or a shape `registerFlow` refuses; a sibling predicate is no longer reported. `ScreenView` exports `screenPredicateScope`, unchanged. The runner's fallback direction is untouched. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
1 parent 704e05b commit 39fbfac

11 files changed

Lines changed: 611 additions & 235 deletions

‎.changeset/10692-debug-run-routing-parity.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ so the field is hidden. A non-string `visibleWhen` is refused the same way. A bl
4646
as `false`: that is the installed spec 17.4.0 reading. objectstack main refuses both at
4747
`registerFlow`, so such a flow never runs there.
4848

49+
⚠️ **Dated note, 2026-09-27 — the Studio no longer decides a screen field's visibility up
50+
front — objectui#10743.** Later in this same release the screen preview and the Debug run's
51+
screen pause hand `visibleWhen` to `ScreenView` raw, and the flow runner's own renderer
52+
decides it live over the screen's declared fields and the values being collected — the
53+
spec's scope, "evaluated by the CLIENT against the screen's live collected values". A
54+
predicate that cannot be evaluated is no longer read as hidden by the Studio: one that
55+
references a name that is not a field on this screen is an error on the screen step, and
56+
whether the renderer shows or hides its field is `ScreenView`'s fallback (objectui#8069).
57+
The rest of this entry is kept as the reading of this change; the objectui#10743 entry
58+
states what the Studio now does, and that the runtime's resume door still reads the run's
59+
variables until objectstack-ai/objectstack#20178 lands.
60+
4961
**Template tokens.** A `NOW()`, `$User.*`, function or arithmetic token inside a longer
5062
string rendered as `''`, so `'at {NOW()}'` became `'at '` where the runtime writes the
5163
time. The Debug run still does not model these tokens; it now keeps them as written,
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): the Studio screen preview and the Debug run decide a screen field's `visibleWhen` live, through the flow runner's own renderer, over the screen's declared fields
6+
7+
**What was wrong.** The Studio's screen preview (`ScreenPreview`) and the Debug run's screen
8+
pause judged each field's `visibleWhen` once, up front, against the run's variables — the
9+
reading the runtime's resume door takes when a screen is resumed — dropped every field they
10+
judged hidden and stripped the predicate from the ones they kept. A predicate over a sibling
11+
screen field, the only shape any shipped producer writes (`createOpportunity == true` in the
12+
flows guide, `discount > 0` in the console sample), names a value the run does not hold at
13+
the pause, so the field was judged hidden and stayed hidden however the author ticked or
14+
typed: the preview's checkbox changed state and nothing appeared. Where the flow's own
15+
variable of the same name was declared `false`, as the guide recommends, the field was judged
16+
`false` and frozen just the same. Measured through the real `FlowSimulator` and
17+
`ScreenPreview` on both sample shapes, with a no-predicate field as the control.
18+
19+
**What changed.** The spec declares the contract: `ScreenFieldSpec.visibleWhen` is "bare CEL
20+
over the screen's own field names", "evaluated by the CLIENT against the screen's live
21+
collected values — not by the server". The Studio now follows it with one client evaluator:
22+
23+
- `buildScreenSpec` keeps every authored field and carries its `visibleWhen` raw, as the
24+
runtime `screen` executor sends it to the client. `ScreenView` — the renderer the flow
25+
runner uses — decides it live over the screen's declared fields and the values being
26+
collected, so ticking the sibling checkbox or typing a discount above zero reveals the
27+
field in the preview exactly as it does for the end user, and the "N fields hidden by
28+
their visible when condition" hint follows the values.
29+
- The Studio no longer has an evaluator of its own for this key: `isFieldVisibleWhen`,
30+
`fieldVisibility` and the simulator's `evalVisibleWhen` are removed. `buildScreenSpec`
31+
takes no `variables`; `hiddenFieldCount` takes the built spec and the collected values.
32+
- The Debug run's screen step judges SCOPE, never value: `unevaluableVisibleWhen` names, as
33+
an error on the paused step, a field whose predicate references an identifier that is not
34+
a field declared on this screen (a run variable such as `needsApproval == true`, the
35+
runtime's `vars.*` root), does not parse as CEL (a `{var}` brace), or is a shape
36+
`registerFlow` refuses — with the nearest declared field where one is close. A predicate
37+
over a sibling field (`discount > 0`, `record.discount > 0`) is not reported; it used to
38+
be named as "hidden because it could not be evaluated".
39+
- `ScreenView` exports `screenPredicateScope`, unchanged, so the Studio reads the roots a
40+
predicate may name off the renderer's own scope instead of restating them.
41+
42+
**What did not change.** Whether the renderer shows or hides a predicate it cannot evaluate
43+
is `ScreenView`'s fallback and objectui#8069's open question; the Studio now renders through
44+
that fallback and rules no direction of its own. The runner (`ScreenView`, `FlowRunner`) has
45+
no behaviour change.
46+
47+
**The server side.** The runtime's resume door (`refuseInvalidScreenInput` in
48+
`@objectstack/service-automation`) still evaluates `visibleWhen` over the run's variables
49+
with the submitted values layered on top; narrowing it to the declared scope, and refusing
50+
an undeclared identifier at `registerFlow` and `objectstack validate`, is
51+
objectstack-ai/objectstack#20178 and is not on objectstack main at this change. Until it
52+
lands, a predicate over a run variable is an error in the Studio and shown by the runner,
53+
while the server waives its `required`.

‎packages/app-shell/README.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -603,10 +603,13 @@ be faithfully modelled is surfaced loudly instead of faked.
603603
registration (objectui#10615); an assignment interpolates `{var}` tokens
604604
inside nested objects and arrays too, as the runtime's `interpolate` does,
605605
and a token it does not model (`NOW()`, `$User.*`, arithmetic) is kept as
606-
written and named on the step; a paused screen gates each field's
607-
`visibleWhen` with the same CEL call, and one that cannot be evaluated hides
608-
the field, as the runtime's resume door reads it (the flow runner's own
609-
`ScreenView` still shows it);
606+
written and named on the step; a paused screen renders through the flow
607+
runner's own `ScreenView`, which decides each field's `visibleWhen` live over
608+
the screen's declared fields and the values being typed (one client
609+
evaluator, objectui#10743), and the screen step names as an error a predicate
610+
that references a name that is not a field on this screen or a shape
611+
`registerFlow` refuses — the runtime's resume door still evaluates over the
612+
run's variables until objectstack#20178 lands;
610613
side-effect nodes write their mock to `outputVariable` (the legacy script
611614
`outputVariables[]` list is ignored — the engine never binds those names,
612615
framework#4278);

‎packages/app-shell/src/views/ScreenView.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,13 @@ export function visibleScreenFields(
117117
* Fail-open is still the behaviour we want for a genuinely broken predicate —
118118
* a syntax error, or a name that is not a field on this screen. Seeding only
119119
* the declared names keeps that split intact.
120+
*
121+
* Exported for the Studio's screen diagnostics
122+
* (`metadata-admin/previews/screen-spec.ts`), which read its keys as the names
123+
* a predicate may reference (objectui#10743); the evaluation itself stays in
124+
* {@link visibleScreenFields}.
120125
*/
121-
function screenPredicateScope(
126+
export function screenPredicateScope(
122127
screen: ScreenSpec,
123128
values: Record<string, unknown>,
124129
): Record<string, unknown> {
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The Debug run's paused screen decides a field's `visibleWhen` live, through
5+
* the flow runner's own renderer, over the screen's declared fields and the
6+
* values the author is collecting — never over the run's variables
7+
* (objectui#10743). Every run goes through the real `FlowSimulator`, and every
8+
* screen through the real `ScreenPreview` → `ScreenView`, the way
9+
* `FlowSimulatorPanel` mounts them (`node` + `snapshot.variables`).
10+
*
11+
* The three cases are the ruling's three pins, each with a control field that
12+
* declares no predicate in the same tree:
13+
*
14+
* 1. a sibling-field predicate reveals its field live (the console sample's
15+
* `note`, `visibleWhen: 'discount > 0'`, and the docs shape
16+
* `createOpportunity == true`) — the Debug run used to judge the predicate
17+
* once against the run's variables, drop the field and strip the predicate,
18+
* so nothing the author typed or ticked ever brought it back;
19+
* 2. a predicate over a run variable is not read from the run's variables, and
20+
* the screen step names it as an error;
21+
* 3. the control: a field with no predicate is drawn throughout.
22+
*
23+
* ⛔ Whether the renderer SHOWS or HIDES a predicate it cannot evaluate is its
24+
* own fallback and objectui#8069's question. Pin 2 therefore reads the SCOPE
25+
* only: the render is the same whether the run variable is `true` or `false`.
26+
*/
27+
28+
import { describe, it, expect, vi, afterEach } from 'vitest';
29+
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
30+
31+
vi.mock('../../../providers/AdapterProvider', () => ({
32+
useAdapter: () => ({ fake: 'adapter' }),
33+
}));
34+
35+
vi.mock('../../../providers/MetadataProvider', () => ({
36+
useMetadata: () => ({ objects: [] }),
37+
}));
38+
39+
import { ScreenPreview } from './ScreenPreview';
40+
import { FlowSimulator } from './simulator/flow-simulator';
41+
import type { SimEdge, SimNode } from './simulator/flow-sim-types';
42+
43+
afterEach(() => cleanup());
44+
45+
/** The console preview sample's `review` screen (apps/console `preview-samples.ts`), plus a control field. */
46+
const consoleSample: SimNode = {
47+
id: 'review',
48+
type: 'screen',
49+
label: 'CSM review',
50+
config: {
51+
fields: [
52+
{ name: 'discount', label: 'Discount %', type: 'number', required: false },
53+
{ name: 'note', label: 'Note', type: 'text', required: true, visibleWhen: 'discount > 0' },
54+
{ name: 'comment', label: 'Comment', type: 'text' },
55+
],
56+
},
57+
};
58+
59+
/** The docs' lead-conversion screen (`content/docs/automation/flows.mdx`), plus a control field. */
60+
const docsShape: SimNode = {
61+
id: 'screen_1',
62+
type: 'screen',
63+
label: 'Conversion Details',
64+
config: {
65+
fields: [
66+
{ name: 'createOpportunity', label: 'Create Opportunity?', type: 'boolean', required: true, defaultValue: false },
67+
{ name: 'opportunityName', label: 'Opportunity Name', type: 'text', required: true, visibleWhen: 'createOpportunity == true' },
68+
{ name: 'plain', label: 'Plain', type: 'text' },
69+
],
70+
},
71+
};
72+
73+
/** A predicate over a run variable — nothing on this screen declares `needsApproval`. */
74+
const runVariableShape: SimNode = {
75+
id: 'gate',
76+
type: 'screen',
77+
label: 'Gate',
78+
config: {
79+
fields: [
80+
{ name: 'reason', label: 'Reason', type: 'text', required: true, visibleWhen: 'needsApproval == true' },
81+
{ name: 'plain', label: 'Plain', type: 'text' },
82+
],
83+
},
84+
};
85+
86+
/** start → `node` → end, run to the screen pause with `seed` as the run's variables. */
87+
function pauseAt(node: SimNode, seed: Record<string, unknown> = {}) {
88+
const nodes: SimNode[] = [{ id: 'start', type: 'start' }, node, { id: 'end', type: 'end' }];
89+
const edges: SimEdge[] = [
90+
{ id: 'e1', source: 'start', target: node.id },
91+
{ id: 'e2', source: node.id, target: 'end' },
92+
];
93+
const sim = new FlowSimulator(nodes, edges);
94+
sim.reset(seed);
95+
sim.runToEnd();
96+
expect(sim.state.status).toBe('paused');
97+
expect(sim.state.pausedReason).toBe('screen');
98+
const step = sim.state.steps.find((st) => st.nodeId === node.id);
99+
return { sim, step, node, variables: sim.state.variables };
100+
}
101+
102+
describe('the Debug run screen decides a sibling-field visibleWhen live (objectui#10743)', () => {
103+
it('console sample: typing a discount above zero reveals Note; zero hides it; Comment (no predicate) is drawn throughout', () => {
104+
const { node, variables, step } = pauseAt(consoleSample);
105+
const { container } = render(<ScreenPreview node={node} variables={variables} />);
106+
const discount = container.querySelector('#ff-discount') as HTMLInputElement | null;
107+
expect(discount).not.toBeNull();
108+
expect(screen.getByText('Comment')).toBeInTheDocument();
109+
110+
fireEvent.change(discount!, { target: { value: '5' } });
111+
expect(screen.getByText('Note')).toBeInTheDocument();
112+
expect(screen.getByText('Comment')).toBeInTheDocument();
113+
114+
fireEvent.change(discount!, { target: { value: '0' } });
115+
expect(screen.queryByText('Note')).not.toBeInTheDocument();
116+
expect(screen.getByText('Comment')).toBeInTheDocument();
117+
118+
// The sibling predicate is the renderer's to decide, not a step error, and
119+
// the pause never names `note` as a field it hid.
120+
expect(step?.status).toBe('paused');
121+
expect(step?.error).toBeUndefined();
122+
expect(step?.note ?? '').not.toMatch(/"note"/);
123+
});
124+
125+
it('docs shape: ticking Create Opportunity reveals Opportunity Name; Plain (no predicate) is drawn throughout', () => {
126+
const { node, variables, step } = pauseAt(docsShape);
127+
const { container } = render(<ScreenPreview node={node} variables={variables} />);
128+
expect(screen.queryByText('Opportunity Name')).not.toBeInTheDocument();
129+
expect(screen.getByText('Plain')).toBeInTheDocument();
130+
expect(screen.getByText(/hidden by .*visible when/i)).toBeInTheDocument();
131+
132+
fireEvent.click(container.querySelector('#ff-createOpportunity')!);
133+
expect(screen.getByText('Opportunity Name')).toBeInTheDocument();
134+
expect(screen.getByText('Plain')).toBeInTheDocument();
135+
expect(screen.queryByText(/hidden by .*visible when/i)).not.toBeInTheDocument();
136+
137+
expect(step?.error).toBeUndefined();
138+
expect(step?.note ?? '').not.toMatch(/"opportunityName"/);
139+
});
140+
141+
it('docs shape with the flow variable declared (`createOpportunity: false` in the run, as the docs recommend): the tick still reveals the field', () => {
142+
// This is the shape that froze hardest: the run held `false`, the predicate
143+
// evaluated false against it, the field was dropped, and the checkbox the
144+
// author ticked in the preview changed nothing.
145+
const { node, variables } = pauseAt(docsShape, { createOpportunity: false });
146+
expect(variables).toEqual({ createOpportunity: false });
147+
const { container } = render(<ScreenPreview node={node} variables={variables} />);
148+
expect(screen.queryByText('Opportunity Name')).not.toBeInTheDocument();
149+
fireEvent.click(container.querySelector('#ff-createOpportunity')!);
150+
expect(screen.getByText('Opportunity Name')).toBeInTheDocument();
151+
expect(screen.getByText('Plain')).toBeInTheDocument();
152+
});
153+
});
154+
155+
describe('the Debug run screen does not read a visibleWhen from the run variables, and names it (objectui#10743)', () => {
156+
it('the render is the same whether the run holds needsApproval true or false; Plain (no predicate) is drawn in both', () => {
157+
const t = pauseAt(runVariableShape, { needsApproval: true });
158+
const withTrue = render(<ScreenPreview node={t.node} variables={t.variables} />);
159+
const reasonWithTrue = withTrue.queryByText('Reason') !== null;
160+
expect(withTrue.getByText('Plain')).toBeInTheDocument();
161+
withTrue.unmount();
162+
163+
const f = pauseAt(runVariableShape, { needsApproval: false });
164+
const withFalse = render(<ScreenPreview node={f.node} variables={f.variables} />);
165+
const reasonWithFalse = withFalse.queryByText('Reason') !== null;
166+
expect(withFalse.getByText('Plain')).toBeInTheDocument();
167+
168+
// Scope, not direction: the run variable is not what decides the field.
169+
expect(reasonWithFalse).toBe(reasonWithTrue);
170+
});
171+
172+
it('the screen step names the field whose visibleWhen references a name that is not a field on this screen, whatever the run holds', () => {
173+
for (const seed of [{}, { needsApproval: true }, { needsApproval: false }]) {
174+
const { step } = pauseAt(runVariableShape, seed);
175+
expect(step?.status).toBe('paused');
176+
expect(step?.error).toMatch(/"reason"/);
177+
expect(step?.error).toMatch(/needsApproval/);
178+
expect(step?.error).not.toMatch(/"plain"/);
179+
}
180+
});
181+
});

0 commit comments

Comments
 (0)