Skip to content

Commit aecc934

Browse files
authored
fix(actions): read objectstack#3962's single-wrapped /actions responses (#2971)
Companion to objectstack#3969: /actions failures now speak HTTP and success is a single wrap — body.data IS the handler's return value. interpretActionResponse / readActionPayload treat the single wrap as the primary shape; the pre-#3962 double envelope is detected narrowly (isLegacyActionEnvelope: boolean success, no keys beyond the envelope's own) and unwrapped for older runtimes, so a handler value that merely contains a success key passes through untouched. The 400-rejection shape resolves to a string toast via actionErrorDetail (no React #31 regression). ActionResult.data's depth quirk self-heals on #3962 servers. Also fixes the PeoplePicker keyboard flake at the root: the cursor reset on new results lived in a useEffect, which flushes asynchronously — a reset queued by the records arriving could land AFTER a subsequent ArrowDown and wipe the just-set cursor (the earlier signature-keyed fix closed the too-often resets; this was the too-late one). The reset now runs in the render phase ("adjusting state during render"), same render that shows the new rows, so it can never race a keypress. Semantics pinned by a new test; LookupField's length-keyed effect reset is deliberately unchanged (different semantics).
1 parent a136322 commit aecc934

6 files changed

Lines changed: 119 additions & 18 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
---
4+
5+
fix(actions): read objectstack#3962's single-wrapped /actions responses; legacy double wrap detected narrowly
6+
7+
objectstack#3962 made `/actions` failures speak HTTP (400 rejection / 404 / 403
8+
/ 503 / 500) and single-wrapped success — `body.data` IS the handler's return
9+
value. `interpretActionResponse` / `readActionPayload` now treat that as the
10+
primary shape: the pre-#3962 double envelope is detected NARROWLY (a boolean
11+
`success` and no keys beyond the envelope's own) and unwrapped for older
12+
runtimes, so a handler value that merely contains a `success` key is
13+
handler-owned and passes through untouched. `ActionResult.data`'s depth quirk
14+
self-heals on #3962 servers.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@object-ui/fields": patch
3+
---
4+
5+
fix(fields): PeoplePicker's keyboard cursor can no longer be eaten by a late reset
6+
7+
The cursor reset on new results lived in a `useEffect`. Effects flush
8+
asynchronously after the render that delivered the records — so a reset queued
9+
by their arrival could land AFTER a subsequent ArrowDown and wipe the just-set
10+
cursor. That was the residual ArrowDown→Enter flake in
11+
`PeoplePicker.test.tsx` (the earlier signature-keyed fix closed the
12+
too-often resets, not the too-late one), and a real fast-fingers UX bug: rows
13+
appear, the user presses ArrowDown, the highlight vanishes.
14+
15+
The reset now runs in the render phase (the "adjusting state during render"
16+
pattern), in the same render that shows the new rows — by the time a row is
17+
visible, the reset has already happened, so it can never race a keypress.
18+
Semantics unchanged and now pinned by a test: a replaced result set does not
19+
inherit the previous set's cursor.

packages/app-shell/src/utils/__tests__/actionResponse.test.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import { interpretActionResponse, readActionPayload } from '../actionResponse';
1212
const ok = { ok: true, status: 200 };
1313

1414
describe('interpretActionResponse — failure shapes', () => {
15-
it('catches a business rejection hiding under HTTP 200 (the reported bug)', () => {
16-
// `res.ok` is TRUE and the OUTER `success` is TRUE. Only the inner
17-
// envelope reports the failure — miss it and the ActionRunner fires a
18-
// green "completed" toast over a failed action.
15+
it('catches a LEGACY business rejection hiding under HTTP 200 (the reported bug)', () => {
16+
// Pre-objectstack#3962 servers only: `res.ok` is TRUE and the OUTER
17+
// `success` is TRUE. Current servers answer 400, which `res.ok`
18+
// catches — this branch keeps the console honest against older
19+
// runtimes.
1920
const out = interpretActionResponse(ok, {
2021
success: true,
2122
data: { success: false, error: "Action 'log_call' on object '*' not found" },
@@ -25,6 +26,19 @@ describe('interpretActionResponse — failure shapes', () => {
2526
expect(out.error).toBe("Action 'log_call' on object '*' not found");
2627
});
2728

29+
it('catches a 400 rejection (#3962) and resolves the nested {message} to a STRING', () => {
30+
const rejection = interpretActionResponse({ ok: false, status: 400 }, {
31+
success: false,
32+
error: {
33+
message: 'ValidationError: issued_on is required',
34+
code: 400,
35+
details: { code: 'VALIDATION_FAILED', fields: [{ field: 'issued_on' }] },
36+
},
37+
}, 'Action "submit_signoff"');
38+
expect(rejection.ok).toBe(false);
39+
expect(rejection.error).toBe('ValidationError: issued_on is required');
40+
});
41+
2842
it('catches a dispatch failure and resolves the nested {message} to a STRING', () => {
2943
// Handing the `{message, code}` OBJECT to toast.error() as a React
3044
// child crashes the page (React #31).
@@ -66,18 +80,24 @@ describe('interpretActionResponse — success', () => {
6680
});
6781
});
6882

69-
describe('readActionPayload — the double-wrap trap', () => {
70-
it('reaches the handler value through BOTH envelopes', () => {
71-
// The bug: reading one level lands on `{success, data}`, where only
72-
// `success` and `data` ever live — so an action returning
73-
// `{ redirectUrl }` was silently ignored, because
74-
// `body.data.redirectUrl` is never set by anything.
83+
describe('readActionPayload — legacy double wrap vs #3962 single wrap', () => {
84+
it('unwraps the LEGACY double envelope to reach the handler value', () => {
85+
// Pre-#3962 servers double-wrapped; an action returning
86+
// `{ redirectUrl }` lived one level below where every reader looked.
7587
const envelope = { success: true, data: { redirectUrl: 'https://example.test/sso' } };
7688

7789
expect((envelope as any).redirectUrl).toBeUndefined(); // the old read
7890
expect(readActionPayload(envelope)).toEqual({ redirectUrl: 'https://example.test/sso' });
7991
});
8092

93+
it('passes a #3962 single-wrapped handler value through — even one with success-ish keys', () => {
94+
// Current servers put the handler value directly in `body.data`. Only
95+
// the EXACT legacy shape is unwrapped; a handler value that merely
96+
// contains a boolean `success` plus its own keys is handler-owned.
97+
const payload = { success: true, rows: 3, data: { id: 'x' }, extra: 'mine' };
98+
expect(readActionPayload(payload)).toEqual(payload);
99+
});
100+
81101
it('passes a non-enveloped body through rather than inventing undefined', () => {
82102
expect(readActionPayload({ id: 'x' })).toEqual({ id: 'x' });
83103
expect(readActionPayload(null)).toBeNull();

packages/app-shell/src/utils/actionResponse.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* action fired a green "completed" toast on every list/page surface. Two copies
99
* of a subtle envelope rule is a bug generator; this is the rule, once.
1010
*
11-
* ## The envelope is DOUBLE, and that is the trap
11+
* ## The legacy envelope was DOUBLE, and that was the trap (pre-objectstack#3962)
1212
*
1313
* ```
1414
* { ← transport envelope
@@ -62,14 +62,26 @@ export interface ActionResponseOutcome {
6262
* that level is constructed by the server and only ever holds `success`/`data`.
6363
*/
6464
export function readActionPayload(envelope: unknown): unknown {
65-
if (envelope && typeof envelope === 'object' && !Array.isArray(envelope) && 'success' in envelope) {
65+
// objectstack#3962 servers single-wrap: `body.data` IS the handler value,
66+
// so most of the time this is the identity function. Only the exact LEGACY
67+
// action envelope (pre-#3962: a boolean `success` and no keys beyond the
68+
// envelope's own) is unwrapped one more level — a handler value that
69+
// merely contains a `success` key passes through untouched.
70+
if (isLegacyActionEnvelope(envelope)) {
6671
return (envelope as { data?: unknown }).data;
6772
}
68-
// A server that did not double-wrap (or a stubbed response in a test):
69-
// treat what we have as the payload rather than inventing an undefined.
7073
return envelope;
7174
}
7275

76+
const LEGACY_ENVELOPE_KEYS = new Set(['success', 'data', 'error', 'code', 'fields']);
77+
78+
/** The pre-objectstack#3962 inner envelope, detected narrowly. */
79+
export function isLegacyActionEnvelope(v: unknown): boolean {
80+
return !!v && typeof v === 'object' && !Array.isArray(v)
81+
&& typeof (v as any).success === 'boolean'
82+
&& Object.keys(v).every((k) => LEGACY_ENVELOPE_KEYS.has(k));
83+
}
84+
7385
/**
7486
* Classify an `/actions` response. `label` names the action for the fallback
7587
* message (e.g. `Action "log_call" failed`).
@@ -82,7 +94,11 @@ export function interpretActionResponse(
8294
label: string,
8395
): ActionResponseOutcome {
8496
const envelope = json?.data;
85-
const innerFailed = !!envelope && typeof envelope === 'object' && (envelope as any).success === false;
97+
// Pre-#3962 servers reported a rejection as HTTP 200 with the inner
98+
// `{success:false, error}`; current servers answer a real status, which
99+
// `res.ok` catches first. Detected narrowly so a handler value that merely
100+
// contains `success: false` is not misread as a failure.
101+
const innerFailed = isLegacyActionEnvelope(envelope) && (envelope as any).success === false;
86102

87103
if (!res.ok || (json && json.success === false) || innerFailed) {
88104
// A business rejection carries its message on the INNER envelope; a

packages/fields/src/widgets/PeoplePicker.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,26 @@ describe('PeoplePicker', () => {
189189
expect(onSelect).toHaveBeenCalledWith('u1');
190190
});
191191

192+
it('keyboard: cursor resets when the results are replaced by a search', async () => {
193+
// Pins the semantics the render-phase reset must preserve: a NEW result
194+
// set does not inherit the previous set's cursor. (The reset moved out of
195+
// an effect into the render phase so it can never land AFTER a subsequent
196+
// ArrowDown — the CI flake on the test above.)
197+
const ds = makeDataSource();
198+
render(
199+
<PeoplePicker {...baseProps} dataSource={ds} onSelect={vi.fn()} onSelectRecords={vi.fn()} onOpenChange={vi.fn()} />,
200+
);
201+
await waitFor(() => expect(screen.getByText('Amy Lin')).toBeTruthy());
202+
const search = screen.getByTestId('people-picker-search');
203+
fireEvent.keyDown(search, { key: 'ArrowDown' });
204+
await waitFor(() =>
205+
expect(screen.getAllByTestId('person-row')[0].getAttribute('data-active')).toBe('true'),
206+
);
207+
fireEvent.change(search, { target: { value: 'bob' } });
208+
await waitFor(() => expect(screen.getAllByTestId('person-row')).toHaveLength(1));
209+
expect(screen.getAllByTestId('person-row')[0].getAttribute('data-active')).not.toBe('true');
210+
});
211+
192212
it('keyboard: Backspace on empty search removes the last chip (multi)', async () => {
193213
const ds = makeDataSource();
194214
render(

packages/fields/src/widgets/PeoplePicker.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,10 +320,22 @@ export function PeoplePicker({
320320
() => query.records.map(r => String(getPersonId(r, idField))).join(''),
321321
[query.records, idField],
322322
);
323-
useEffect(() => {
323+
// The reset runs in the RENDER PHASE (the "adjusting state during render"
324+
// pattern), NOT in an effect. An effect flushes asynchronously after the
325+
// render that delivered the records — so a reset queued by their arrival
326+
// could land AFTER a subsequent ArrowDown and eat the just-set cursor.
327+
// That is the residual ArrowDown→Enter flake the signature key above did
328+
// not close (CI reproduced it under load), and a real fast-fingers UX bug:
329+
// rows appear, the user presses ArrowDown, the highlight vanishes.
330+
// Resetting during the same render that shows the new rows makes the
331+
// ordering deterministic — by the time the rows are visible, the reset
332+
// has already happened.
333+
const cursorEpoch = `${query.search}\u0000${recordsSignature}`;
334+
const [seenCursorEpoch, setSeenCursorEpoch] = useState(cursorEpoch);
335+
if (seenCursorEpoch !== cursorEpoch) {
336+
setSeenCursorEpoch(cursorEpoch);
324337
setActiveIndex(-1);
325-
// eslint-disable-next-line react-hooks/exhaustive-deps
326-
}, [query.search, recordsSignature]);
338+
}
327339
// Keep it in range if the list shrinks.
328340
useEffect(() => {
329341
setActiveIndex(i => (i >= navList.length ? navList.length - 1 : i));

0 commit comments

Comments
 (0)