Skip to content

Commit ed27959

Browse files
committed
fix(app-shell): every object read the provider serves keeps the switched-off stamps
Measured live against #27842's branch: the record header still offered a switched-off action, because the provider's by-name object read hands out a `structuredClone` of the adapter's schema and that drops symbol keys. The stamps are carried onto the copy by name. The package-scoped list read now runs the same ingestion as the whole list (`ingestObjectList`). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CZny6X72Dx4kxxzLm7
1 parent 15ff1e4 commit ed27959

2 files changed

Lines changed: 70 additions & 16 deletions

File tree

‎ui/packages/app-shell/src/providers/MetadataProvider.tsx‎

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
import { expandViewContainer } from '@objectstack/spec/ui';
1010
import { ActiveOrganizationStorage, TokenStorage, useAuth } from '@object-ui/auth';
1111
import { isMetaItemAbsentError, MetadataNotFoundError, type ObjectStackAdapter } from '@object-ui/data-objectstack';
12-
import { markSwitchedOffActionsByObject, normalizeSchemaReferenceKeys } from '@object-ui/core';
12+
import { isActionSwitchedOff, markSwitchedOffActions, markSwitchedOffActionsByObject, normalizeSchemaReferenceKeys } from '@object-ui/core';
1313
import { errorCodeIs } from '@object-ui/types';
1414
import { resolveInlineMode } from '@object-ui/plugin-form';
1515
import { MetadataCtx, useMetadata, type MetadataContextValue, type MetadataCacheState, type MetadataTypeStatus } from '@object-ui/react';
@@ -360,6 +360,37 @@ export function extractItems(res: unknown): any[] {
360360
return [];
361361
}
362362

363+
/**
364+
* The ingestion every OBJECT list read runs — the whole list and a package
365+
* scope's alike, so a scoped definition is shaped exactly like the same object
366+
* in `objects`. In place.
367+
*
368+
* 1. a legacy `reference_to` / `referenceTo` is folded onto `reference`;
369+
* 2. [objectstack#27504] the envelope's `disabledActions`, keyed by object:
370+
* each object's switched-off action entries are stamped (symbol key), so
371+
* the action gates stop offering them.
372+
*/
373+
function ingestObjectList(items: any[], res: unknown): void {
374+
for (const it of items) normalizeSchemaReferenceKeys(it);
375+
if (res && typeof res === 'object' && !Array.isArray(res)) {
376+
markSwitchedOffActionsByObject(items, (res as { disabledActions?: unknown }).disabledActions);
377+
}
378+
}
379+
380+
/**
381+
* A private copy of an adapter-served object schema. `structuredClone` drops
382+
* symbol-keyed properties, so the switched-off stamps the adapter put on the
383+
* action entries (objectstack#27504) are carried onto the copy by name.
384+
*/
385+
function cloneServedObject(schema: unknown): unknown {
386+
const copy = structuredClone(schema);
387+
const actions = (schema as { actions?: unknown }).actions;
388+
if (Array.isArray(actions)) {
389+
markSwitchedOffActions(copy, actions.filter(isActionSwitchedOff).map((a: { name?: unknown }) => a.name));
390+
}
391+
return copy;
392+
}
393+
363394
function extractItem(res: unknown): any | null {
364395
if (res == null) return null;
365396
if (typeof res === 'object' && 'item' in res) {
@@ -783,7 +814,7 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }:
783814
const readObjectThroughAdapter = useCallback((name: string): Promise<unknown> => {
784815
adapterObjectNamesRef.current.add(name);
785816
return adapterRef.current.getObjectSchema(name).then(
786-
(schema: unknown) => ({ item: schema == null ? schema : structuredClone(schema) }),
817+
(schema: unknown) => ({ item: schema == null ? schema : cloneServedObject(schema) }),
787818
(err: unknown) => {
788819
throw err instanceof MetadataNotFoundError ? (err.details?.originalError ?? err) : err;
789820
},
@@ -896,16 +927,9 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }:
896927
// object field defs at ingestion (the store-side choke point,
897928
// mirroring the adapter's getObjectSchema pass), so
898929
// `useMetadata().objects` consumers read `reference` alone
899-
// (objectui#11070 round 4). Idempotent, in place.
900-
if (type === 'object') {
901-
for (const it of items) normalizeSchemaReferenceKeys(it);
902-
// [objectstack#27504] The list envelope's `disabledActions`, keyed
903-
// by object: each object's switched-off action entries are stamped
904-
// (symbol key) so the action gates stop offering them.
905-
if (res && typeof res === 'object' && !Array.isArray(res)) {
906-
markSwitchedOffActionsByObject(items, (res as { disabledActions?: unknown }).disabledActions);
907-
}
908-
}
930+
// (objectui#11070 round 4), and stamp the switched-off actions
931+
// (objectstack#27504) — `ingestObjectList`. Idempotent, in place.
932+
if (type === 'object') ingestObjectList(items, res);
909933
entry.items = items;
910934
entry.status = 'ready';
911935
entry.error = null;
@@ -1053,7 +1077,7 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS }:
10531077
.then((res: unknown) => {
10541078
if (scopesRef.current.get(packageId) !== entry) return;
10551079
const items = extractItems(res);
1056-
for (const it of items) normalizeSchemaReferenceKeys(it);
1080+
ingestObjectList(items, res);
10571081
const listed = new Set(items.map((it: any) => it?.name));
10581082
// The by-name extras stay until their own answer replaces them.
10591083
const extras = entry.items.filter((it: any) => !listed.has(it?.name) && entry.names.has(it?.name));

‎ui/packages/app-shell/src/providers/__tests__/MetadataProvider.disabledActions-27504.test.tsx‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import { describe, it, expect, vi } from 'vitest';
1414
import { render, waitFor } from '@testing-library/react';
15-
import { isActionSwitchedOff } from '@object-ui/core';
15+
import { isActionSwitchedOff, markSwitchedOffActions } from '@object-ui/core';
1616
import { MetadataProvider, useMetadata } from '../MetadataProvider';
1717

1818
type Ctx = ReturnType<typeof useMetadata>;
@@ -22,11 +22,13 @@ const TASK = {
2222
actions: [{ name: 'showcase_mark_done' }, { name: 'showcase_log_time' }],
2323
};
2424

25-
function makeAdapter(listBody: Record<string, unknown>) {
25+
function makeAdapter(listBody: Record<string, unknown>, served: Record<string, any> = {}) {
2626
return {
2727
clearCache: vi.fn(),
2828
invalidateCache: vi.fn(),
29-
getObjectSchema: () => Promise.resolve(null),
29+
// The adapter's by-name read, already stamped from its own envelope
30+
// (`getObjectSchema.disabledActions-27504.test.ts` pins that half).
31+
getObjectSchema: (name: string) => Promise.resolve(served[name] ?? null),
3032
getClient: () => ({
3133
meta: {
3234
getItems: (type: string) =>
@@ -37,6 +39,21 @@ function makeAdapter(listBody: Record<string, unknown>) {
3739
} as unknown as Parameters<typeof MetadataProvider>[0]['adapter'];
3840
}
3941

42+
async function mount(listBody: Record<string, unknown>, served: Record<string, any> = {}): Promise<Ctx> {
43+
let ctx: Ctx | null = null;
44+
function Probe() {
45+
ctx = useMetadata();
46+
return null;
47+
}
48+
render(
49+
<MetadataProvider adapter={makeAdapter(listBody, served)}>
50+
<Probe />
51+
</MetadataProvider>,
52+
);
53+
await waitFor(() => expect(ctx).not.toBeNull());
54+
return ctx as unknown as Ctx;
55+
}
56+
4057
async function objectsServed(listBody: Record<string, unknown>): Promise<any[]> {
4158
let ctx: Ctx | null = null;
4259
function Probe() {
@@ -69,4 +86,17 @@ describe('MetadataProvider stamps the list envelope disabledActions (objectstack
6986
const [task] = await objectsServed({ type: 'object', items: [TASK] });
7087
expect(task.actions.some(isActionSwitchedOff)).toBe(false);
7188
});
89+
90+
it('keeps the adapter\'s stamp on the by-name read, which hands out a private copy', async () => {
91+
// `structuredClone` drops symbol keys: without carrying the stamp across,
92+
// the record header (`useMetadataItem` → this read) kept a live button.
93+
const ctx = await mount(
94+
{ type: 'object', items: [] },
95+
{ showcase_task: markSwitchedOffActions(structuredClone(TASK), ['showcase_mark_done']) },
96+
);
97+
const item = await ctx.getItem('object', 'showcase_task');
98+
const byName = (n: string) => item.actions.find((a: any) => a.name === n);
99+
expect(isActionSwitchedOff(byName('showcase_mark_done'))).toBe(true);
100+
expect(isActionSwitchedOff(byName('showcase_log_time'))).toBe(false);
101+
});
72102
});

0 commit comments

Comments
 (0)