diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx
index 89ee31bb..e963ff8c 100644
--- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx
+++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx
@@ -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(
,
);
@@ -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(
,
);
diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx
index a849ab50..53edeeca 100644
--- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx
+++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx
@@ -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));