Skip to content

Commit 338feda

Browse files
fix(sdui-parser): containment reads the declared children input, not isContainer; lockstep pins the predicate (#20065)
Fixes #19969 Clause-②: no ## What this does The save gate's `validateTree` in `packages/sdui-parser` now decides `not-a-container` by the component's declared `children` input. It no longer reads `isContainer`, and there is no fallback to the flag. This is the byte-equal port of objectui `5ea623ea` (objectui#9910 Q1-A, re-cut as objectui PR #10270), which is already inside the console pin `f8a9d0fb0596` (PR #20036). The objectui side it mirrors is `packages/sdui-parser/src/validate.ts` at the pin: `CHILD_LIST_KEY` at `:33`, `acceptsChildren` at `:57-59` and the containment branch at `:220`. - **Predicate semantics mirrored exactly.** The test is `comp.inputs.some((input) => input.name === CHILD_LIST_KEY)`. It matches on the **name** `children` alone, whatever the input's coarse type. The dispatch brief said "name `children`, type `slot`". objectui deliberately matches on the name only, because the five page kinds publish `children` typed `array`, and objectui pins that case. This port follows objectui, not the brief. - **A component the manifest has no entry for** draws `unknown-component` and gets no containment verdict. This is unchanged and is the same on both sides. - **`isContainer`** stays on `ManifestComponent`, re-described as layout containment in the same words objectui uses. `index.ts` is unchanged: no new public export. objectui exported `acceptsChildren` and `CHILD_LIST_KEY` from its entry for its own census tests, and nothing here needs that. ## The lockstep pin (maintainer-named in the ruling) `scripts/check-sdui-lockstep.mjs` now also compares the **containment predicate**. The gate extracts the condition of the one `if` in `validate.ts` whose body stamps `not-a-container`. It adds the module-level declarations that condition reaches, transitively (`acceptsChildren`, then `CHILD_LIST_KEY`), and reduces the result to its token sequence. Comments and wrapping do not change the reading. A changed token does. The gate compares this reading against a new `containmentPredicate` field in the vendored objectui record. - The record was regenerated with `pnpm gen:sdui-lockstep` against an objectui checkout parked on the pin `f8a9d0fb0596`. Every other field of the record is byte-unchanged; the only diff is the new `containmentPredicate` block. - If `validate.ts` has zero or two containment branches, the gate refuses (`[containment-unreachable]`). It never measures only one of two. - A new self-test battery with 10 cases is added, and the battery floor goes from 6 to 7. The first case asserts that the flag predicate and the slot predicate read differently. ## The five served components (tracked `sdui.manifest.json` at `0bf85eaa`, 5 of 59 disagree) | component | `isContainer` | declares `children` | before (flag) | after (declared input) | |---|---|---|---|---| | `badge` | false | yes (`slot`) | warns `not-a-container` (false) | no warning | | `alert` | false | yes (`slot`) | warns (false) | no warning | | `button` | false | yes (`slot`) | warns (false) | no warning | | `page:tabs` | true | no | silent | warns `not-a-container` | | `page:accordion` | true | no | silent | warns `not-a-container` | The new `containment-declared-slot.test.ts` uses these five entries, copied verbatim from the manifest. Each gets its own case, plus a control asserting that the two predicates really disagree on every one. The file also carries objectui's 2×2 (flag × slot) reading. ## Ablation (a trap restores the file, and the restore is proven byte-identical to HEAD by blob hash and an empty `git diff HEAD`) - **Leg 1: revert the port.** The branch was changed back to `!comp.isContainer` through `scripts/ablation-replace.mjs`: anchor 1 to 0, blob `819b7b6a83ac` to `670ce98bdb15`. `node scripts/check-sdui-lockstep.mjs` exited **1** with `[containment-drift]`, printing both texts. The file was then restored to blob `819b7b6a83ac` = HEAD. - **Leg 2: sneak the fallback into the helper.** The mutation appended an `isContainer` fallback to the `acceptsChildren` return. The gate exited **1** with `[containment-drift]`. Restored to `819b7b6a83ac`. - **Test-suite leg (same mutation as leg 1).** The new test file went **8 failed / 5 passed** (exit 1). Restored. - **Restore leg.** `pnpm check:sdui-lockstep` on HEAD `f3ff1e6e9` exits **0**: the self-test passes, and the gate reports agreement on all 26 codes and on the `not-a-container` predicate (blob `76c18fb95d1f`). ## Verification (HEAD `f3ff1e6e9`; re-run on `bfb082b0b` in the section below) - `pnpm --filter @objectstack/sdui-parser exec vitest run`: 11 files, 189 tests passed (VERDICT command-exit 0). - `pnpm --filter @objectstack/sdui-parser typecheck`: exit 0. `--listFiles` includes the new test. - `pnpm check:sdui-lockstep`: exit 0. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 81 commands. `--ran` reconciles: 77 exit 0, 4 NOT MEASURED, 0 unrun. - NOT MEASURED, prerequisite unmet: `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` each exited 3 because they need a full workspace build. - NOT MEASURED, over the time cap: `check:pm-dispatch-gates`. Its self-test ran past the foreground cap, was killed twice, and is declared to CI. - `check-plugin-teardown-shape --self-test` first exited 3 because this is a shallow checkout. It exited 0 after fetching its pinned fixture commit. - `eslint --no-inline-config` over the 6 touched source files: 0 errors, 0 warnings. This is a targeted run, not a proven narrowing, so the full `pnpm lint` is declared to CI. ## packages/lint fixture: resolved by PM ruling `5825043664` The PM ruling on #19969 (comment `5825043664`, Q2 = A) widened this card file surface by exactly one test fixture. Commit `bfb082b0b` gives the hand-made `flex` entry at `packages/lint/src/validate-jsx-pages.test.ts:39` the `children` input, the same triage objectui made on its own fixtures: ```ts flex: { type: flex, namespace: ui, isContainer: true, inputs: [{ name: children, type: slot }] }, ``` No other `packages/lint` file changes; `authoring-rules.ts` and the lint caller are untouched. Q1 = A, provisionally: the changeset stays `patch` with `Clause-②: no`, and the contract review judges it on the final head. On head `bfb082b0b`, after rebuilding the `@objectstack/lint` dependency closure: - `pnpm --filter @objectstack/lint exec vitest run`: 108 of 108 files passed; 4133 tests passed, 5 skipped, 0 failed (VERDICT command-exit 0). Before this commit it was 1 failed / 4132 passed. - `pnpm --filter @objectstack/lint typecheck`: exit 0. - sdui-parser suite: 189 of 189 passed. `pnpm --filter @objectstack/sdui-parser typecheck`: exit 0. `pnpm check:sdui-lockstep`: exit 0. - `dispatch-gates --commands` on the 9-path set derived 82 commands (one new: `check:docs-transcript-drift`, exit 0 after building `@objectstack/lint`). `--ran` reconciles: 78 exit 0, 4 NOT MEASURED, 0 unrun. The four are `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` (each exit 3, prerequisite: a full workspace build) and `check:pm-dispatch-gates` (over the foreground cap). All four are declared to CI. ## Acceptance notes - `codegen.ts`'s `generateBlockList` still labels its column "container" from `isContainer`. Under Q2-A that column now means layout container. It is unchanged here, and objectui leaves it as-is too. --- _Generated by [Claude Code](https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a4ca69a commit 338feda

9 files changed

Lines changed: 681 additions & 16 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/sdui-parser': patch
3+
---
4+
5+
fix(sdui-parser): `not-a-container` is decided by the declared `children` input, not `isContainer`. This matches the renderer's copy of the parser (#19969)
6+
7+
The save gate's `validateTree` now warns `not-a-container` on a child list only when the component's manifest entry declares no input named `children`. It never reads `isContainer`, which now means layout containment only, and it has no fallback to it. This is a port of objectui#9910 (objectui `5ea623ea`), which is already inside the pinned console build. Saving and rendering now reach the same verdict on every page again.
8+
9+
Against the served `sdui.manifest.json`, five of its 59 components change:
10+
11+
- `badge`, `alert`, `button` declare a `children` slot and are not flagged `isContainer`. A child list under them no longer draws a false `not-a-container` warning.
12+
- `page:tabs`, `page:accordion` are flagged `isContainer` but declare no `children` input. They render `items[].children`, never `schema.children`, so a child list under them now draws the warning the flag used to silence.
13+
14+
Clause-②: no
15+
16+
`not-a-container` stays a `warning`, so the default save gate refuses no page it accepted before and accepts no page it refused. Only the two modes that treat warnings as errors see the five-component change: `os validate --strict` and `os lint --strict`. Both run `validateJsxPages`. The package's public entry adds no export.

‎packages/lint/src/validate-jsx-pages.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ describe('validateJsxPages (ADR-0080 build gate)', () => {
3636
describe('validateJsxPages — full validation with a manifest', () => {
3737
const manifest = {
3838
components: {
39-
flex: { type: 'flex', namespace: 'ui', isContainer: true, inputs: [] },
39+
// Containment is the declared `children` input, not `isContainer`
40+
// (objectui#9910, ported to the save gate by #19969) — the flag stays as
41+
// the layout fact it is.
42+
flex: { type: 'flex', namespace: 'ui', isContainer: true, inputs: [{ name: 'children', type: 'slot' }] },
4043
'object-table': { type: 'object-table', namespace: 'plugin-grid', inputs: [{ name: 'object', type: 'string', required: true }] },
4144
},
4245
};

‎packages/sdui-parser/objectui-lockstep.json‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,15 @@
5656
"unterminated-brace",
5757
"unterminated-open-tag",
5858
"unterminated-string"
59-
]
59+
],
60+
"containmentPredicate": {
61+
"file": "packages/sdui-parser/src/validate.ts",
62+
"code": "not-a-container",
63+
"reaches": [
64+
"CHILD_LIST_KEY",
65+
"acceptsChildren"
66+
],
67+
"blob": "76c18fb95d1fd04223e3d18deea77223408cc0a8",
68+
"text": "if (node . children ?. length && ! acceptsChildren ( comp ))\nexport const CHILD_LIST_KEY = 'children' ;\nexport function acceptsChildren ( comp : Pick < ManifestComponent , 'inputs' > ) : boolean { return comp . inputs . some ( ( input ) => input . name === CHILD_LIST_KEY ) ; }\n"
69+
}
6070
}

‎packages/sdui-parser/src/__tests__/compile.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@ import { describe, expect, it } from 'vitest';
22
import { compile, generateDts, manifestFromConfigs } from '../index.js';
33

44
// A tiny public-tier manifest, shaped exactly like getAllConfigs() output.
5+
// Containment is the declared `children` slot input, not `isContainer`
6+
// (objectui#9910) — the flag stays on the fixtures as the layout fact it is.
57
const manifest = manifestFromConfigs([
68
{ type: 'flex', namespace: 'ui', isContainer: true, inputs: [
79
{ name: 'direction', type: 'enum', enum: ['row', 'col'] },
810
{ name: 'gap', type: 'number' },
911
{ name: 'wrap', type: 'boolean' },
12+
{ name: 'children', type: 'slot' },
1013
] },
1114
{ type: 'card', namespace: 'ui', isContainer: true, inputs: [
1215
{ name: 'title', type: 'string' },
16+
{ name: 'children', type: 'slot' },
1317
] },
1418
{ type: 'object-table', namespace: 'plugin-grid', isContainer: false, inputs: [
1519
{ name: 'object', type: 'string', required: true, binding: 'object' },
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
/**
2+
* Containment is decided by the declared `children` input, and by NOTHING
3+
* else — the save-gate port (#19969) of objectui#9910 Q1-A.
4+
*
5+
* The renderer's copy of this parser (objectui `packages/sdui-parser`, re-cut
6+
* `5ea623ea`, inside the console pin) moved `not-a-container` off the
7+
* `isContainer` flag onto the registration's declared `children` input. This
8+
* copy runs in the SAVE GATE; if it kept reading the flag, the two would give
9+
* opposite verdicts on the same page (#12719). The 2×2 (flag × slot) below is
10+
* the same reading objectui pins in its own
11+
* `containment-declared-slot-9910.test.ts`, because the two failure modes are
12+
* OPPOSITE: a flag-only cell drawing nothing is the fallback silently
13+
* re-introduced, and a slot-only cell drawing the warning is the defect.
14+
*
15+
* The second half uses the REAL served entries: in the tracked
16+
* `sdui.manifest.json` (regenerated at the console pin, `0bf85eaa`) exactly
17+
* five of 59 components disagree between the flag and the declared input.
18+
* They are copied verbatim below rather than read from the repo root, so this
19+
* package's tests keep reading only this package.
20+
*/
21+
import { describe, expect, it } from 'vitest';
22+
import { manifestFromConfigs, validateTree } from '../index.js';
23+
import { CHILD_LIST_KEY, acceptsChildren } from '../validate.js';
24+
import type { Manifest, ManifestComponent, SchemaElement } from '../types.js';
25+
26+
const CONTAINMENT = 'not-a-container';
27+
const SLOT = { name: 'children', type: 'slot' } as const;
28+
29+
const containment = (node: SchemaElement, manifest: Manifest): string[] =>
30+
validateTree(node, manifest)
31+
.diagnostics.map((d) => d.code)
32+
.filter((code) => code === CONTAINMENT);
33+
34+
/** A child list of one string node: strings are never validated, so the only
35+
* diagnostic this can draw is the containment one on its parent. */
36+
const withChildren = (type: string): SchemaElement => ({ type, children: ['measured'] });
37+
38+
describe('#19969 — the save gate reads the declared `children` input, not the flag (2×2)', () => {
39+
const manifest: Manifest = manifestFromConfigs([
40+
// flag, no slot — the `page:tabs` shape
41+
{ type: 'flag-only', namespace: 'ui', isContainer: true, inputs: [{ name: 'items', type: 'array' }] },
42+
// slot, no flag — the `button` / `badge` shape
43+
{ type: 'slot-only', namespace: 'ui', inputs: [{ name: 'label', type: 'string' }, SLOT] },
44+
// both — the `flex` shape
45+
{ type: 'both', namespace: 'ui', isContainer: true, inputs: [{ name: 'gap', type: 'number' }, SLOT] },
46+
// neither — a leaf
47+
{ type: 'neither', namespace: 'ui', inputs: [{ name: 'content', type: 'string' }] },
48+
// the page kinds publish `children` typed `array`: the NAME is the declaration
49+
{ type: 'array-typed', namespace: 'ui', inputs: [{ name: 'children', type: 'array', of: 'object' }] },
50+
]);
51+
52+
it('the predicate reads exactly the input NAMED `children`, whatever its coarse type', () => {
53+
expect(CHILD_LIST_KEY).toBe('children');
54+
expect(acceptsChildren({ inputs: [SLOT] })).toBe(true);
55+
expect(acceptsChildren({ inputs: [{ name: 'children', type: 'array' }] })).toBe(true);
56+
expect(acceptsChildren({ inputs: [{ name: 'body', type: 'slot' }] })).toBe(false);
57+
expect(acceptsChildren({ inputs: [] })).toBe(false);
58+
});
59+
60+
it('⛔ the flag is NOT a fallback: flag without slot draws `not-a-container`', () => {
61+
expect(containment(withChildren('flag-only'), manifest)).toEqual([CONTAINMENT]);
62+
});
63+
64+
it('the slot alone is sufficient: slot without flag draws nothing', () => {
65+
expect(containment(withChildren('slot-only'), manifest)).toEqual([]);
66+
});
67+
68+
it('both declared draws nothing; neither declared draws the warning, at warning severity', () => {
69+
expect(containment(withChildren('both'), manifest)).toEqual([]);
70+
const found = validateTree(withChildren('neither'), manifest).diagnostics.filter(
71+
(d) => d.code === CONTAINMENT,
72+
);
73+
expect(found.map((d) => [d.code, d.severity, d.tag])).toEqual([[CONTAINMENT, 'warning', 'neither']]);
74+
});
75+
76+
it('an `array`-typed `children` input counts — the page kinds', () => {
77+
expect(containment(withChildren('array-typed'), manifest)).toEqual([]);
78+
});
79+
80+
it('no child list, no verdict: an empty or absent list draws nothing anywhere', () => {
81+
for (const type of ['flag-only', 'slot-only', 'both', 'neither', 'array-typed']) {
82+
expect(containment({ type }, manifest)).toEqual([]);
83+
expect(containment({ type, children: [] }, manifest)).toEqual([]);
84+
}
85+
});
86+
87+
it('a component the manifest does not know draws `unknown-component`, never a containment verdict', () => {
88+
const codes = validateTree(withChildren('nowhere'), manifest).diagnostics.map((d) => d.code);
89+
expect(codes).toEqual(['unknown-component']);
90+
});
91+
});
92+
93+
describe('#19969 — the five served entries where the flag and the declared input disagree', () => {
94+
// Verbatim from the tracked `sdui.manifest.json` at `0bf85eaa`.
95+
const SERVED: Record<string, ManifestComponent> = {
96+
"badge": {
97+
"type": "badge",
98+
"namespace": "ui",
99+
"inputs": [
100+
{
101+
"name": "label",
102+
"type": "string"
103+
},
104+
{
105+
"name": "variant",
106+
"type": "enum",
107+
"enum": [
108+
"default",
109+
"secondary",
110+
"destructive",
111+
"outline"
112+
]
113+
},
114+
{
115+
"name": "className",
116+
"type": "string"
117+
},
118+
{
119+
"name": "children",
120+
"type": "slot",
121+
"description": "Rich label content, rendered when `label` is not set"
122+
}
123+
]
124+
},
125+
"alert": {
126+
"type": "alert",
127+
"namespace": "ui",
128+
"inputs": [
129+
{
130+
"name": "title",
131+
"type": "string",
132+
"required": true
133+
},
134+
{
135+
"name": "description",
136+
"type": "string"
137+
},
138+
{
139+
"name": "variant",
140+
"type": "enum",
141+
"enum": [
142+
"default",
143+
"destructive"
144+
]
145+
},
146+
{
147+
"name": "className",
148+
"type": "string"
149+
},
150+
{
151+
"name": "children",
152+
"type": "slot",
153+
"description": "Rich description content, rendered when `description` is not set"
154+
}
155+
]
156+
},
157+
"button": {
158+
"type": "button",
159+
"namespace": "ui",
160+
"inputs": [
161+
{
162+
"name": "label",
163+
"type": "string"
164+
},
165+
{
166+
"name": "variant",
167+
"type": "enum",
168+
"enum": [
169+
"default",
170+
"secondary",
171+
"destructive",
172+
"outline",
173+
"ghost",
174+
"link"
175+
]
176+
},
177+
{
178+
"name": "size",
179+
"type": "enum",
180+
"enum": [
181+
"default",
182+
"sm",
183+
"lg",
184+
"icon"
185+
]
186+
},
187+
{
188+
"name": "className",
189+
"type": "string"
190+
},
191+
{
192+
"name": "children",
193+
"type": "slot",
194+
"description": "Rich label content, rendered when `label` is not set"
195+
}
196+
]
197+
},
198+
"page:tabs": {
199+
"type": "page:tabs",
200+
"namespace": "page",
201+
"isContainer": true,
202+
"inputs": [
203+
{
204+
"name": "items",
205+
"type": "array",
206+
"of": "object",
207+
"required": true,
208+
"description": "Tab definitions [{ label, value?, icon?, count?, visibleWhen?, children }] — value is the stable ?tab= URL token, count auto-derives from record:related_list descendants when omitted"
209+
},
210+
{
211+
"name": "tabStyle",
212+
"type": "enum",
213+
"enum": [
214+
"line",
215+
"card",
216+
"pill"
217+
]
218+
},
219+
{
220+
"name": "position",
221+
"type": "enum",
222+
"enum": [
223+
"top",
224+
"left"
225+
]
226+
},
227+
{
228+
"name": "alwaysShowStrip",
229+
"type": "boolean",
230+
"description": "Keep the tab strip visible when only one tab survives. Default false: a lone pill is clutter rather than an affordance, so a one-tab strip is hidden and its panel renders bare. Count the tabs AFTER each item visibleWhen predicate has been evaluated — a page authored with four tabs of which three are conditional reaches this rule whenever the other three are false."
231+
}
232+
]
233+
},
234+
"page:accordion": {
235+
"type": "page:accordion",
236+
"namespace": "page",
237+
"isContainer": true,
238+
"inputs": [
239+
{
240+
"name": "items",
241+
"type": "array",
242+
"of": "object",
243+
"required": true,
244+
"description": "Panel definitions [{ label, icon?, collapsed?, children }] — collapsed: false opens a panel by default"
245+
},
246+
{
247+
"name": "allowMultiple",
248+
"type": "boolean"
249+
},
250+
{
251+
"name": "variant",
252+
"type": "enum",
253+
"enum": [
254+
"flush",
255+
"card"
256+
]
257+
}
258+
]
259+
}
260+
} as unknown as Record<string, ManifestComponent>;
261+
const manifest: Manifest = { components: SERVED };
262+
263+
it('control: each entry really does disagree — the flag and the declared input answer differently', () => {
264+
// If a regenerated manifest ever made these agree, the rows below would stop
265+
// distinguishing the two predicates and this file would pass under either.
266+
for (const [type, comp] of Object.entries(SERVED)) {
267+
expect({ type, flag: Boolean(comp.isContainer) }).not.toEqual({ type, flag: acceptsChildren(comp) });
268+
}
269+
});
270+
271+
it.each(['badge', 'alert', 'button'])(
272+
'<%s> declares the slot with the flag off: its child list draws NO `not-a-container` (was a false warning)',
273+
(type) => {
274+
expect(SERVED[type]!.isContainer).toBeFalsy();
275+
expect(containment(withChildren(type), manifest)).toEqual([]);
276+
},
277+
);
278+
279+
it.each(['page:tabs', 'page:accordion'])(
280+
'<%s> carries the flag with no slot: its child list DRAWS `not-a-container` (was silenced by the flag)',
281+
(type) => {
282+
expect(SERVED[type]!.isContainer).toBe(true);
283+
expect(containment(withChildren(type), manifest)).toEqual([CONTAINMENT]);
284+
},
285+
);
286+
});

‎packages/sdui-parser/src/__tests__/type-attribute-collision.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ const manifest = manifestFromConfigs([
1717
{ type: 'flex', namespace: 'ui', isContainer: true, inputs: [
1818
{ name: 'direction', type: 'enum', enum: ['row', 'col'] },
1919
{ name: 'gap', type: 'number' },
20+
{ name: 'children', type: 'slot' },
2021
] },
21-
{ type: 'grid', namespace: 'ui', isContainer: true, inputs: [{ name: 'columns', type: 'number' }] },
22+
{ type: 'grid', namespace: 'ui', isContainer: true, inputs: [{ name: 'columns', type: 'number' }, { name: 'children', type: 'slot' }] },
2223
{ type: 'object-chart', namespace: 'plugin-charts', isContainer: false, inputs: [
2324
{ name: 'objectName', type: 'string', binding: 'object' },
2425
] },

‎packages/sdui-parser/src/types.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,18 @@ export interface ManifestComponent {
159159
type: string;
160160
/** plugin namespace — provenance that drives `requires` */
161161
namespace?: string;
162+
/**
163+
* The declared authoring surface. An input named `children` (type `slot`)
164+
* is ALSO the containment declaration: `validateTree` draws
165+
* `not-a-container` on a child list under any component whose `inputs`
166+
* carry no such entry (objectui#9910; `acceptsChildren` in `validate.ts`).
167+
*/
162168
inputs: ManifestInput[];
169+
/**
170+
* LAYOUT containment (objectui#6804, objectui#9910 Q2-A): the flag the
171+
* react-page JSX scope builder skips and the public layout ledger lists.
172+
* ⛔ Not "accepts children" — this tier's containment check does not read it.
173+
*/
163174
isContainer?: boolean;
164175
}
165176

0 commit comments

Comments
 (0)