From 00042324cebac000ec1a04ef4e6facbd59c8377c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 13:08:45 +0000 Subject: [PATCH] fix(react): stop the `properties` hoist enumerating a degenerate bag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hoist that copies `properties.*` onto a node's top level walked the bag with `Object.entries` unconditionally, so a non-object value was enumerated rather than skipped: measured on `c6732825d`, `{ type, properties: 'not-a-bag' }` reached the element as nine React props named `0` … `8`, and `properties: ['x', 'y']` as `0`, `1`. Arm (a) of the two objectui#6760 left open — guard the hoist with the shared `isConfigBag` predicate (objectui#6761) rather than rule that the hoist may enumerate anything handed to it. The reasoning is written at the hoist: under objectui#5123 a key gets one answer whichever channel reads it, and the other arm would have answered one authored mistake two ways, with the reinterpreting half falling on `properties` — the spec spelling — while the quiet half fell on its `props` legacy alias. Also corrects two comments the change falsifies: the evaluation memo's note that non-objects "reach the hoist exactly as they do today", and `propsWithoutCanonicalKeys`' explanation of its degenerate-`properties` carve-out by what the hoist does with the value. The objectui#6752 pin's `properties` leg is ratcheted, not weakened: it pinned that card's known-and-open asymmetry (nine indexed props, recorded not fixed) and now asserts none. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .../6760-degenerate-properties-hoist.md | 27 ++ packages/react/src/SchemaRenderer.tsx | 87 +++++- ...enderer.degeneratePropertiesHoist.test.tsx | 264 ++++++++++++++++++ ...SchemaRenderer.degeneratePropsBag.test.tsx | 39 ++- 4 files changed, 399 insertions(+), 18 deletions(-) create mode 100644 .changeset/6760-degenerate-properties-hoist.md create mode 100644 packages/react/src/__tests__/SchemaRenderer.degeneratePropertiesHoist.test.tsx diff --git a/.changeset/6760-degenerate-properties-hoist.md b/.changeset/6760-degenerate-properties-hoist.md new file mode 100644 index 000000000..246f74e5a --- /dev/null +++ b/.changeset/6760-degenerate-properties-hoist.md @@ -0,0 +1,27 @@ +--- +'@object-ui/react': patch +--- + +A degenerate `properties` bag no longer reaches the element as indexed React +props (objectui#6760). + +`properties` is the spec spelling of a node's config bag. The hoist that copies +`properties.*` onto the node's top level walked it with `Object.entries` +unconditionally, so a non-object value was enumerated rather than skipped: +measured on `c6732825d`, `{ type, properties: 'not-a-bag' }` reached the element +as nine React props named `0` … `8`, and `properties: ['x', 'y']` as `0`, `1`. +Nobody authored those keys — they are the walk's reading of a string's character +indices. + +The hoist now asks the same `isConfigBag` question the evaluation memo and the +`props` bag already ask (objectui#6752, objectui#6761). Of the two arms the card +left open, this is "guard the hoist" rather than "declare that the hoist may +enumerate anything", because objectui#5123 ruled that a key gets one answer +whichever channel reads it — and the alternative would have answered one +authored mistake two ways, with the reinterpreting half falling on the canonical +spelling while the quiet half fell on its legacy alias. + +Nothing else moves: a real object bag hoists exactly as before (including the +`type`/`id` keys the hoist has always refused to copy), the authored +`properties` value still reaches renderers on both channels unchanged, and +`properties: 42` / `properties: true` were already contributing no keys. diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx index e95eb4372..d269cfa67 100644 --- a/packages/react/src/SchemaRenderer.tsx +++ b/packages/react/src/SchemaRenderer.tsx @@ -182,9 +182,13 @@ const HOIST_PROTECTED_KEYS = new Set(['type', 'id']); * - `type`/`id` are skipped, because the hoist never copied a canonical value * up for them, so dropping the alias would DELETE the prop rather than * replace it; - * - a degenerate (non-object) `properties` is left alone — the hoist and - * `readProps()` both merely object-spread it, and there is no canonical bag - * to prefer. + * - a degenerate (non-object) `properties` is left alone — there is no + * canonical bag to prefer, because a degenerate bag declares no key for + * either spelling to win. (This bullet used to explain the carve-out by + * saying "the hoist and `readProps()` both merely object-spread it". Half + * of that stopped being true at objectui#6760, which stopped the hoist from + * enumerating a degenerate bag; the carve-out itself is unaffected, since + * it never rested on what the hoist does with the value.) * * Adjacent but NOT decided here: objectui#4795's pending question ② (whether * the `properties` envelope is an official `ui:*` authoring channel at all). @@ -899,19 +903,30 @@ export const SchemaRenderer: ForwardRefExoticComponent< // // Guarded by {@link isConfigBag}: a degenerate value must not have its shape // reinterpreted by an object spread. Non-objects skip evaluation and reach - // the hoist exactly as they do today. + // the hoist — which, since objectui#6760, refuses them on its own. // // ⚠️ This guard used to say it was wider than the `props` branch BECAUSE - // this value feeds the hoist. That reason does not survive measurement + // this value feeds the hoist. That reason did not survive measurement // (objectui#6752). Ablating this guard to bare truthiness and re-rendering - // `{ type, properties: 'not-a-bag' }` on `b76ca6764` leaves the indexed + // `{ type, properties: 'not-a-bag' }` on `b76ca6764` left the indexed // keys the hoist puts on the node completely UNCHANGED — `0` … `8` either - // way, because the hoist's own `Object.entries` walk enumerates a string's - // character indices whatever this line did — and moves exactly one thing: + // way, because the hoist's own `Object.entries` walk enumerated a string's + // character indices whatever this line did — and moved exactly one thing: // whether `schema.properties` still holds the value the author wrote // (`'not-a-bag'` guarded, `{ '0': 'n', … }` ablated). So what this buys is // the AUTHORED value's shape, which is channel-independent, and the `props` - // branch below now carries the same guard for the same reason. + // branch below carries the same guard for the same reason. + // + // ⭐ That measurement is also why objectui#6760 gave the HOIST its own + // guard instead of widening this one: this line never reached the indexed + // keys, so nothing written here could have removed them. With both guards + // in place the two are in SERIES, not redundant, and re-running the same + // ablation now reads the opposite way — measured on this card's branch, + // ablating THIS line to bare truthiness brings `0` … `8` BACK, because the + // bare spread manufactures a real `{ '0': 'n', … }` bag out of the string + // and the hoist below then enumerates it legitimately. Each guard is load + // bearing for a different half: this one keeps the authored value's shape, + // the hoist's one declines to enumerate a value that never had keys. // Snapshotted BEFORE evaluation — objectui#5756's diagnostic below reads // the RAW (pre-collapse) text from this reference, since `newSchema.properties` // is about to be replaced with the evaluated copy. @@ -986,7 +1001,59 @@ export const SchemaRenderer: ForwardRefExoticComponent< // (e.g. tab visual style: 'line' | 'card' | 'pill'). Keep `properties` // intact on the schema so renderers can still read these collision-prone // keys via `schema.properties.`. - if (newSchema.properties) { + // + // Guarded by {@link isConfigBag}: a DEGENERATE `properties` contributes no + // node keys at all (objectui#6760). Measured on `c6732825d`, this branch's + // base, with no part of that card in the tree: `properties: 'not-a-bag'` + // reached the element as nine React props named `0` … `8`, and + // `properties: ['x', 'y']` as `0`, `1`. Nobody authored those keys — they + // are `Object.entries`' reading of a string's character indices, and the + // values this loop writes onto the node are spread as React props at the + // `createElement` below. + // + // ## Which arm, and why this one + // + // objectui#6760 left two open, both cheap, both reversible, neither on the + // manual floor (the objectui#6708 census walked every JSON document, every + // `json` doc fence and every TypeScript object literal and found ZERO + // authored degenerate config bags): + // + // (a) guard the hoist the way the evaluation memo above is guarded; + // (b) rule that the hoist may enumerate whatever it is handed, and say + // so here. + // + // Arm (a), for three reasons, in the order they were weighed: + // + // 1. **One answer per key, whichever channel reads it.** That is + // objectui#5123's ruling (maintainer, 2026-08-18), and arm (b) breaks + // it in the loudest available place: after objectui#6752, + // `props: 'not-a-bag'` contributes no keys, so arm (b) would answer the + // SAME authored mistake two ways depending on which of two spellings of + // ONE bag the author used — and the reinterpreting half would be + // `properties`, the SPEC spelling, while the quiet half is `props`, the + // annotated legacy alias. A rule that punishes the canonical spelling + // is not a rule anyone can teach. + // 2. **The reason for the guard was measured, and it is channel-** + // **independent.** objectui#6752 established it by ablation rather than + // by reading a comment (see the evaluation memo above): what a config-bag + // guard buys is the AUTHORED value's shape, which has nothing to do with + // which bag or which site is asking. The hoist is a third site asking the + // same question about the same authored value; a third answer would be + // drift, which is what {@link isConfigBag} exists to end (objectui#6761). + // 3. **The failure it prevents is silent and lands on generated metadata.** + // A degenerate bag is a mistake an author (increasingly, an AI writing + // metadata) makes by writing `properties: 'text'` where a bag belongs. + // Arm (b) turns that into nine plausible-looking props with no warning + // anywhere; arm (a) makes it inert, so the mistake stays legible as the + // `properties` value the author actually wrote. + // + // What arm (a) does NOT change, measured rather than assumed: a real object + // bag hoists exactly as before (`type`/`id` still protected above), and + // `properties: 42` / `properties: true` were already contributing nothing + // — `Object.entries` yields no entries for them, so this guard only ever + // moves the string and array cases. Pinned in + // `__tests__/SchemaRenderer.degeneratePropertiesHoist.test.tsx`. + if (isConfigBag(newSchema.properties)) { const outerType = newSchema.type; const outerId = newSchema.id; const props = newSchema.properties; diff --git a/packages/react/src/__tests__/SchemaRenderer.degeneratePropertiesHoist.test.tsx b/packages/react/src/__tests__/SchemaRenderer.degeneratePropertiesHoist.test.tsx new file mode 100644 index 000000000..48b755fc8 --- /dev/null +++ b/packages/react/src/__tests__/SchemaRenderer.degeneratePropertiesHoist.test.tsx @@ -0,0 +1,264 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#6760 — the `properties` hoist enumerated a DEGENERATE `properties` + * into indexed node keys: `properties: 'not-a-bag'` reached the element as + * nine React props named `0` … `8`. + * + * ## The half objectui#6752 left open, and why it was the loud half + * + * objectui#6752 fixed the `props` bag. Its sibling `properties` — the SPEC + * spelling, of which `props` is the annotated legacy alias — stayed open, and + * the site was never the evaluation memo's guard: it is the hoist's own + * `Object.entries` walk, which enumerates a string's character indices + * whatever any guard upstream of it did. That is a MEASURED fact, not a + * reading of a comment; see "Ablations" below. + * + * ## The arm + * + * The card left two, both cheap and both reversible: (a) guard the hoist the + * way the evaluation memo is guarded, or (b) rule that the hoist may enumerate + * whatever it is handed and say so at the hoist. Arm (a). The reasoning lives + * where the code is — at the hoist in `SchemaRenderer.tsx` — and turns on + * objectui#5123's "one answer per key, whichever channel reads it": arm (b) + * would have answered ONE authored mistake two ways depending on which of two + * spellings of one bag was used, with the reinterpreting half falling on the + * canonical spelling. + * + * ## `BASE_READING` + * + * Captured on `c6732825d` — this branch's base, with `SchemaRenderer.tsx` at + * its committed blob `e95eb4372` and no part of this card in the tree — by + * rendering exactly the nodes below through the real `SchemaRenderer`, and + * pasted verbatim. So the legs that assert "unchanged" are a real before/after + * comparison, and the degenerate legs pin a shape that was measured to be + * there before the guard removed it. A pin that never went red is not + * evidence. + * + * ## Ablations, in both directions + * + * The card's own method note asks for what does NOT move as well as what does, + * because its filing used ablation in REVERSE — to prove a guard was NOT + * responsible for an output. Three runs, all on this branch with the fix + * committed, each restored to `HEAD` and re-verified byte-identical after: + * + * 1. FORWARD — hoist guard back to bare `if (newSchema.properties)`: + * `0` … `8` return for a string bag, `0`, `1` for an array bag. So this + * card's guard is what removes them. + * 2. REVERSE — evaluation memo's `isConfigBag` back to bare truthiness, + * hoist guard kept: on the card's base this changed NOTHING (that is how + * the memo's old "because the value feeds the hoist" comment was + * falsified). On this branch it reads the OTHER way — the indexed keys + * come back, AND the `properties` a renderer reads becomes + * `{ '0': 'n', … }` instead of `'not-a-bag'`, because the bare spread + * manufactures a real bag out of the string and the hoist then + * enumerates it legitimately. The two guards are in SERIES after this + * card, not redundant: the memo's keeps the authored value's shape, and + * this card's declines to enumerate a value that never had keys. The + * file says so at both sites. + * 3. REVERSE — `propsWithoutCanonicalKeys`' `isConfigBag(propertiesBag)` + * early return back to bare truthiness: NOTHING moves in any leg here. + * That site decides objectui#5123 precedence between two co-present bags + * and never fed the indexed keys, so this card must not be credited to + * it. + * + * No rebuild is involved in any of the three: this file imports + * `../SchemaRenderer` by relative path, so vitest reads the source tree, not a + * `dist/`. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRenderer } from '../SchemaRenderer'; +import { SchemaRendererContext } from '../context/SchemaRendererContext'; + +/** The provider really does hold the path the object-bag case spells. */ +const DATA = { customers: ['ada', 'grace'] }; + +const renderWithData = (schema: unknown) => + render( + + + , + ); + +const snap = (v: unknown) => JSON.parse(JSON.stringify(v ?? null)); + +interface Reading { + propKeys: string[]; + indexedPropKeys: string[]; + propsReactProp: unknown; + propertiesReactProp: unknown; + schemaProps: unknown; + schemaProperties: unknown; +} + +const captured: Record = {}; + +/** + * Records BOTH channels: the React props the element actually receives, and + * what a `({ schema })` renderer reads. `indexedPropKeys` is the card's whole + * subject, isolated so a failure names it instead of burying it in a diff of + * every prop. + */ +const makeProbe = (label: string) => { + const Probe: React.FC & { schema?: Record }> = ({ + schema, + ...rest + }) => { + captured[label] = { + propKeys: Object.keys(rest).sort(), + indexedPropKeys: Object.keys(rest) + .filter(k => /^\d+$/.test(k)) + .sort(), + propsReactProp: snap(rest.props ?? null), + propertiesReactProp: snap(rest.properties ?? null), + schemaProps: snap(schema?.props ?? null), + schemaProperties: snap(schema?.properties ?? null), + }; + return
; + }; + return Probe; +}; + +/** + * One node per envelope shape. `not-a-bag` is nine characters. + * + * `propertiesNumber` / `propertiesTrue` are here precisely because they did + * NOT move: `Object.entries(42)` was already empty, so they say where this + * guard's reach stops. `propertiesObjectInnerType` is the `page:tabs` node the + * hoist's `type`/`id` protection exists for. + */ +const CASES: ReadonlyArray = [ + ['propertiesString', { type: 'test:deg', id: 'b', properties: 'not-a-bag' }], + ['propertiesArray', { type: 'test:deg', id: 'g', properties: ['x', 'y'] }], + ['propertiesNumber', { type: 'test:deg', id: 'h', properties: 42 }], + ['propertiesTrue', { type: 'test:deg', id: 'i', properties: true }], + [ + 'propertiesObject', + { type: 'test:deg', id: 'j', properties: { title: 'T', data: '${data.customers}' } }, + ], + ['propertiesObjectInnerType', { type: 'page:tabs', id: 'k', properties: { type: 'line', title: 'T' } }], + ['bothBags', { type: 'test:deg', id: 'l', properties: 'not-a-bag', props: { title: 'P' } }], +]; + +const BASE_READING: Record = JSON.parse(`{ + "propertiesString": {"propKeys":["0","1","2","3","4","5","6","7","8","className","data-obj-id","data-obj-type","disabled","id","properties"],"indexedPropKeys":["0","1","2","3","4","5","6","7","8"],"propsReactProp":null,"propertiesReactProp":"not-a-bag","schemaProps":null,"schemaProperties":"not-a-bag"}, + "propertiesArray": {"propKeys":["0","1","className","data-obj-id","data-obj-type","disabled","id","properties"],"indexedPropKeys":["0","1"],"propsReactProp":null,"propertiesReactProp":["x","y"],"schemaProps":null,"schemaProperties":["x","y"]}, + "propertiesNumber": {"propKeys":["className","data-obj-id","data-obj-type","disabled","id","properties"],"indexedPropKeys":[],"propsReactProp":null,"propertiesReactProp":42,"schemaProps":null,"schemaProperties":42}, + "propertiesTrue": {"propKeys":["className","data-obj-id","data-obj-type","disabled","id","properties"],"indexedPropKeys":[],"propsReactProp":null,"propertiesReactProp":true,"schemaProps":null,"schemaProperties":true}, + "propertiesObject": {"propKeys":["className","data","data-obj-id","data-obj-type","disabled","id","properties","title"],"indexedPropKeys":[],"propsReactProp":null,"propertiesReactProp":{"title":"T","data":["ada","grace"]},"schemaProps":null,"schemaProperties":{"title":"T","data":["ada","grace"]}}, + "propertiesObjectInnerType": {"propKeys":["className","data-obj-id","data-obj-type","disabled","id","properties","title"],"indexedPropKeys":[],"propsReactProp":null,"propertiesReactProp":{"type":"line","title":"T"},"schemaProps":null,"schemaProperties":{"type":"line","title":"T"}}, + "bothBags": {"propKeys":["0","1","2","3","4","5","6","7","8","className","data-obj-id","data-obj-type","disabled","id","properties","props","title"],"indexedPropKeys":["0","1","2","3","4","5","6","7","8"],"propsReactProp":{"title":"P"},"propertiesReactProp":"not-a-bag","schemaProps":{"title":"P"},"schemaProperties":"not-a-bag"} +}`); + +describe('objectui#6760 — a degenerate `properties` is no longer hoisted into indexed node keys', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + for (const key of Object.keys(captured)) delete captured[key]; + for (const [label, schema] of CASES) { + const [namespace, name] = String((schema as { type: string }).type).split(':'); + ComponentRegistry.register(name, makeProbe(label), { namespace, skipFallback: true }); + const { unmount } = renderWithData(schema); + unmount(); + } + }); + + afterEach(() => { + vi.restoreAllMocks(); + ComponentRegistry.unregister?.('deg', 'test'); + ComponentRegistry.unregister?.('tabs', 'page'); + }); + + /* ---------------------------------------------------------------- * + * 1 — the defect, and that it was really there + * ---------------------------------------------------------------- */ + + it('pins the pre-fix shape the card reported: nine node keys named `0`…`8`', () => { + // Not an assertion about today's tree — it states what BASE_READING holds, + // so a future edit that quietly rewrites this constant to match a changed + // renderer has to lie in public rather than in passing. + expect(BASE_READING.propertiesString.indexedPropKeys).toEqual([ + '0', '1', '2', '3', '4', '5', '6', '7', '8', + ]); + expect(BASE_READING.propertiesArray.indexedPropKeys).toEqual(['0', '1']); + }); + + it('a string `properties` now reaches the element with NO indexed props', () => { + expect(captured.propertiesString.indexedPropKeys).toEqual([]); + // The exact shape that must not come back, named rather than implied. + for (const k of ['0', '1', '2', '3', '4', '5', '6', '7', '8']) { + expect(captured.propertiesString.propKeys).not.toContain(k); + } + }); + + it('an array `properties` contributes no keys either', () => { + expect(captured.propertiesArray.indexedPropKeys).toEqual([]); + expect(captured.propertiesArray.propKeys).not.toContain('0'); + }); + + it('the authored value survives instead of the hoist’s reading of it', () => { + // `properties` is still passed through as a React prop of that name and is + // still what a `({ schema })` renderer reads — unchanged by this card. What + // changed is only that its CHARACTERS are no longer node keys. + expect(captured.propertiesString.propertiesReactProp).toBe('not-a-bag'); + expect(captured.propertiesString.schemaProperties).toBe('not-a-bag'); + expect(captured.propertiesArray.propertiesReactProp).toEqual(['x', 'y']); + expect(captured.propertiesArray.schemaProperties).toEqual(['x', 'y']); + }); + + it('a degenerate `properties` no longer drowns a co-declared real `props`', () => { + // The two-bag node: `props: { title: 'P' }` was already reaching the + // element before this card, buried among nine indexed keys. Only the noise + // went away — objectui#5123's precedence is untouched, because a degenerate + // bag declares no key for either spelling to win. + expect(captured.bothBags.indexedPropKeys).toEqual([]); + expect(captured.bothBags.propKeys).toContain('title'); + expect(captured.bothBags.propsReactProp).toEqual({ title: 'P' }); + expect(captured.bothBags.propertiesReactProp).toBe('not-a-bag'); + }); + + /* ---------------------------------------------------------------- * + * 2 — what must NOT move (this is what catches an over-broad guard) + * ---------------------------------------------------------------- */ + + it('a real object `properties` still hoists, byte-for-byte as before this card', () => { + expect(captured.propertiesObject).toEqual(BASE_READING.propertiesObject); + // Spelled out, because this is the leg that fails if the guard is too wide: + // the bag is still evaluated (`${data.customers}` collapsed) and its keys + // are still hoisted onto the node and spread as React props. + expect(captured.propertiesObject.propKeys).toContain('title'); + expect(captured.propertiesObject.propKeys).toContain('data'); + expect(captured.propertiesObject.propertiesReactProp).toEqual({ + title: 'T', + data: ['ada', 'grace'], + }); + }); + + it('the hoist still refuses to let an inner `type` shadow the descriptor', () => { + // `page:tabs` with `properties.type: 'line'` — the reason HOIST_PROTECTED_KEYS + // exists. Guarding the hoist must not disturb what the hoist does with a + // real bag, including what it declines to copy. + expect(captured.propertiesObjectInnerType).toEqual(BASE_READING.propertiesObjectInnerType); + expect(captured.propertiesObjectInnerType.propKeys).not.toContain('type'); + expect(captured.propertiesObjectInnerType.propKeys).toContain('title'); + }); + + it('a number and a boolean `properties` are byte-identical to the base — this guard never reached them', () => { + // `Object.entries(42)` and `Object.entries(true)` were already empty, so + // these two nodes were never part of the defect. Pinned so the card is not + // credited with fixing them, and so an over-broad future guard that starts + // dropping the `properties` React prop itself fails here. + expect(captured.propertiesNumber).toEqual(BASE_READING.propertiesNumber); + expect(captured.propertiesTrue).toEqual(BASE_READING.propertiesTrue); + }); +}); diff --git a/packages/react/src/__tests__/SchemaRenderer.degeneratePropsBag.test.tsx b/packages/react/src/__tests__/SchemaRenderer.degeneratePropsBag.test.tsx index 59f736f62..3ce2e226a 100644 --- a/packages/react/src/__tests__/SchemaRenderer.degeneratePropsBag.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.degeneratePropsBag.test.tsx @@ -212,16 +212,39 @@ describe('objectui#6752 — a degenerate `props` bag is no longer spread into in }); }); - it('the `properties` channel is untouched — this card changed one bag', () => { - expect(captured.propertiesString).toEqual(BASE_READING.propertiesString); - expect(captured.propertiesArray).toEqual(BASE_READING.propertiesArray); - // Recorded, not fixed: the hoist still enumerates a degenerate `properties` - // into `0`…`8`. That is the hoist's own `Object.entries` walk, out of this - // card's scope (which forbids changing hoist behaviour) and filed - // separately. Pinned so the asymmetry is visible rather than forgotten. - expect(captured.propertiesString.indexedPropKeys).toEqual([ + it('the `properties` channel: this card changed one bag, and the other half is now closed too', () => { + // The leg objectui#6752 owns, unchanged: the AUTHORED `properties` value + // still reaches both channels exactly as BASE_READING recorded it. This + // card changed one bag, and that is still true. + expect(captured.propertiesString.propertiesReactProp).toEqual( + BASE_READING.propertiesString.propertiesReactProp, + ); + expect(captured.propertiesString.schemaProperties).toEqual( + BASE_READING.propertiesString.schemaProperties, + ); + expect(captured.propertiesArray.propertiesReactProp).toEqual( + BASE_READING.propertiesArray.propertiesReactProp, + ); + expect(captured.propertiesArray.schemaProperties).toEqual( + BASE_READING.propertiesArray.schemaProperties, + ); + + // ⬆ RATCHET, not a weakening (objectui#6760). This leg used to assert the + // WHOLE `properties` reading still equalled BASE_READING, and said so + // explicitly as "recorded, not fixed": the hoist's own `Object.entries` + // walk still put `0`…`8` on the node, out of this card's scope (which + // forbids changing hoist behaviour) and filed as objectui#6760. That card + // guarded the hoist, so the equality is now false in the direction of LESS + // leakage, and the assertion tightens with it — from "nine indexed props, + // exactly as before" to "none at all". Nothing this file asserted about + // the `props` bag moved; the pre-fix shape it used to pin lives on as + // history in `SchemaRenderer.degeneratePropertiesHoist.test.tsx`, which + // owns the hoist half from here. + expect(BASE_READING.propertiesString.indexedPropKeys).toEqual([ '0', '1', '2', '3', '4', '5', '6', '7', '8', ]); + expect(captured.propertiesString.indexedPropKeys).toEqual([]); + expect(captured.propertiesArray.indexedPropKeys).toEqual([]); }); /* ---------------------------------------------------------------- *