docs: mark the JSON half of #2026 latent, not reproducible today

Copilot review: the code and test comments still said a defaulted array or
object argument *was* uneditable. It would be, but not through today's seeding
path — `collectSchemaDefaults` assigns `fieldSchema.default` itself, so the
substitution returns the same reference and the re-sync never fires. It needs a
structurally-equal but distinct value object, which parsed wire/deep-link
values and rebuilt nested-object defaults produce, and which these tests
construct. The number field has no such protection and did revert in the app.

State the boundary in all three places, and stop claiming the distinct-object
seed is how the Tools tab seeds the field, which it is not.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-16 00:44:53 -04:00
commit c6edc13a9f
2 changed files with 37 additions and 12 deletions

View file

@ -1651,10 +1651,22 @@ describe("JSON editor escaping (#1853, #1856, #1885)", () => {
// A schema `default` is what a field *opens* with, not a value re-imposed on
// every unsendable draft. Unsendable text reports `undefined` by design, and
// `resolveValue` used to turn that straight back into the default — a real
// change from the previous value, so the draft/value re-sync rewrote the box
// and reverted the keystroke. Almost every edit passes through an unsendable
// state, which left a defaulted array or object argument uneditable.
// `resolveValue` used to turn that straight back into the default — a change
// from the previous value, so the draft/value re-sync rewrote the box and
// reverted the keystroke.
//
// The two halves differed in how reachable they were, which the tests below
// mirror deliberately:
//
// - **The number field reverted in the app.** Numbers compare by value, so
// clearing a defaulted box (value `3` → resolved default `30`) always fired
// the re-sync and refilled itself.
// - **The JSON field was latent.** `collectSchemaDefaults` assigns
// `fieldSchema.default` itself, so a field seeded from the schema holds the
// *same reference* the substitution returns and nothing fires. It needs a
// structurally-equal but distinct value object to become observable — which
// is what parsed wire/deep-link values and rebuilt nested-object defaults
// produce, and what these tests construct.
describe("SchemaForm defaulted fields (#2026)", () => {
function DefaultHarness({
schema,
@ -1681,8 +1693,10 @@ describe("SchemaForm defaulted fields (#2026)", () => {
it("keeps a keystroke typed into a defaulted JSON field", async () => {
const user = userEvent.setup();
// Seeded the way the Tools tab seeds it — from the schema's defaults, as a
// separate value than the schema's own. Sharing one reference hid this.
// Seeded with a value that is equal to the schema default but is not the
// same object — how values parsed off the wire or out of a deep link
// arrive. Sharing the reference (what `collectSchemaDefaults` produces) is
// what keeps this latent rather than what makes it safe.
renderWithMantine(
<DefaultHarness schema={arrayWithDefault} initial={{ tags: ["a"] }} />,
);
@ -1697,6 +1711,7 @@ describe("SchemaForm defaulted fields (#2026)", () => {
it("lets a defaulted JSON field be edited to a new value", async () => {
const user = userEvent.setup();
// Distinct-object seed again, for the reason above.
renderWithMantine(
<DefaultHarness schema={arrayWithDefault} initial={{ tags: ["a"] }} />,
);

View file

@ -268,12 +268,22 @@ function SchemaJsonField({
//
// The value compared here is the field's *raw* entry, never `resolveValue`'s
// default substitution (#2026). Unsendable text reports `undefined`, and a
// defaulted field resolves that straight back to its default — a real change
// from the previous value, so this would rewrite the draft to the default and
// revert the keystroke. Almost every edit passes through an unsendable state,
// which made a defaulted array or object argument uneditable. The default
// belongs to what the field *opens* with, seeded above, not to what is
// re-imposed while it is being typed into.
// defaulted field resolves that straight back to its default — a change from
// the previous value, so comparing against the resolved one would rewrite the
// draft to the default and revert the keystroke. Almost every edit passes
// through an unsendable state, so a defaulted array or object argument would
// be uneditable.
//
// Would be, not was: this is latent under today's seeding, where
// `collectSchemaDefaults` assigns `fieldSchema.default` itself, so the
// substitution returns the *same reference* and `useValueChange` never fires.
// It goes live as soon as the value is a structurally-equal but distinct
// object — parsed from the wire or a deep link, or a nested-object default,
// which `collectSchemaDefaults` rebuilds per call. `SchemaNumberInput` has no
// such accidental protection, since a number compares by value.
//
// Either way the default belongs to what the field *opens* with, seeded
// above, not to what is re-imposed while it is being typed into.
useValueChange(value, (next) => {
if (!isSameJson(parseJsonDraft(draft), next)) {
setDraft(next === undefined ? "" : serializeJson(next));