From c240ec2492e20d91c6c7635070955c5f7ba69381 Mon Sep 17 00:00:00 2001 From: cinereal Date: Sat, 26 Sep 2026 16:50:01 +0200 Subject: [PATCH 1/3] fix(vue): keep renderer bindings stable when they do not change Each change of the JSON Forms state recomputes the `control` bindings of `useControl` and the `renderer` bindings of `useJsonFormsRenderer`. Both getters return a new object each time, also when all the values in it are the same. Vue compares a computed value by identity, thus one edit re-renders all the renderers in the form. On forms with some hundred controls, this gives a delay of hundreds of milliseconds for each keystroke. The computed getters now return the previous bindings object when the new object has the same keys and equal values. `data`, `schema` and `rootSchema` are compared by identity. The other values are compared structurally to a limited depth. Assisted-by: Claude:claude-opus-5-5 Claude-Session: https://claude.ai/code/session_01DngH17MDYXHUMa68WjMzRo --- packages/vue/src/jsonFormsCompositions.ts | 93 ++++++++++++- .../vue/tests/unit/stableBindings.spec.ts | 122 ++++++++++++++++++ 2 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 packages/vue/tests/unit/stableBindings.spec.ts diff --git a/packages/vue/src/jsonFormsCompositions.ts b/packages/vue/src/jsonFormsCompositions.ts index 88540f2806..8cf835e8f1 100644 --- a/packages/vue/src/jsonFormsCompositions.ts +++ b/packages/vue/src/jsonFormsCompositions.ts @@ -148,6 +148,95 @@ export interface ControlProps extends RendererProps { uischema: ControlElement; } +/** + * Compares two values structurally, to at most `depth` levels of objects and + * arrays. At the depth limit, two different objects are not equal. The result + * is conservative: `true` only when the values are deep-equal. + */ +const isEqualToDepth = (a: unknown, b: unknown, depth: number): boolean => { + if (a === b) { + return true; + } + if ( + depth <= 0 || + a === null || + b === null || + typeof a !== 'object' || + typeof b !== 'object' || + Array.isArray(a) !== Array.isArray(b) + ) { + return false; + } + const keysA = Object.keys(a); + const keysB = Object.keys(b); + return ( + keysA.length === keysB.length && + keysA.every( + (key) => + Object.prototype.hasOwnProperty.call(b, key) && + isEqualToDepth( + (a as Record)[key], + (b as Record)[key], + depth - 1 + ) + ) + ); +}; + +/** + * The compare depth for each key of a bindings object: + * - `data`, `schema` and `rootSchema` are compared by identity. The core + * replaces the data immutably, thus changed data always has a new identity. + * The schemas can be large, and a structural compare would be expensive. + * - `uischema` is compared structurally, because renderers often give a new + * clone of a UI schema element to a nested dispatch on each render. + * - All other keys are compared structurally to a small depth. + */ +const compareDepth = (key: string): number => { + if (key === 'data' || key === 'schema' || key === 'rootSchema') { + return 0; + } + return key === 'uischema' ? 8 : 3; +}; + +/** + * Returns `previous` when `next` has the same keys, and each value is equal to + * the value in `previous`. Otherwise returns `next`. + */ +const reuseIfEqual = ( + previous: T | undefined, + next: T +): T => { + if (previous === undefined) { + return next; + } + const previousKeys = Object.keys(previous); + const nextKeys = Object.keys(next); + const equal = + previousKeys.length === nextKeys.length && + nextKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(previous, key) && + isEqualToDepth( + (previous as Record)[key], + (next as Record)[key], + compareDepth(key) + ) + ); + return equal ? previous : next; +}; + +/** + * A `computed` for a bindings object. Each change of the JSON Forms state + * recomputes the bindings of all renderers, and the getter returns a new + * object each time. Vue compares a computed value by identity, thus without + * this wrapper each change re-renders all renderers in the form. When the new + * bindings are equal to the previous bindings, this computed keeps the + * previous object, and Vue does not trigger the dependents. + */ +const stableComputed = (getter: () => T): ComputedRef => + computed((previous?: T) => reuseIfEqual(previous, getter())); + export type Required = T extends object ? { [P in keyof T]-?: NonNullable } : T; @@ -188,7 +277,7 @@ export function useControl< const dispatch = useDispatch(); const id = ref(undefined); - const control = computed(() => ({ + const control = stableComputed(() => ({ ...props, ...stateMap({ jsonforms }, props), id: id.value, @@ -416,7 +505,7 @@ export const useJsonFormsRenderer = (props: RendererProps) => { ); const rootSchema = computed(() => rawProps.value.rootSchema); - const renderer = computed(() => { + const renderer = stableComputed(() => { const { rootSchema: _rootSchema, ...rest } = rawProps.value; return rest; }); diff --git a/packages/vue/tests/unit/stableBindings.spec.ts b/packages/vue/tests/unit/stableBindings.spec.ts new file mode 100644 index 0000000000..28133edcb9 --- /dev/null +++ b/packages/vue/tests/unit/stableBindings.spec.ts @@ -0,0 +1,122 @@ +/* eslint-disable vue/one-component-per-file */ +import { + ControlElement, + isControl, + isLayout, + JsonFormsRendererRegistryEntry, + Layout, + rankWith, +} from '@jsonforms/core'; +import { mount } from '@vue/test-utils'; +import { defineComponent, h, nextTick } from 'vue'; +import { + DispatchRenderer, + JsonForms, + rendererProps, + useJsonFormsControl, + useJsonFormsLayout, +} from '../../src'; + +const renders: Record = {}; +let changeHandlers: Record void> = {}; + +const TestControl = defineComponent({ + props: rendererProps(), + setup(props) { + const { control, handleChange } = useJsonFormsControl(props); + changeHandlers[control.value.path] = handleChange; + return () => { + const path = control.value.path; + renders[path] = (renders[path] ?? 0) + 1; + return h('span', String(control.value.data)); + }; + }, +}); + +const TestLayout = defineComponent({ + props: rendererProps(), + setup(props) { + const { layout } = useJsonFormsLayout(props); + return () => + h( + 'div', + (layout.value.uischema as Layout).elements.map((element) => + h(DispatchRenderer, { + schema: layout.value.schema, + uischema: element, + path: layout.value.path, + enabled: layout.value.enabled, + }) + ) + ); + }, +}); + +const renderers: JsonFormsRendererRegistryEntry[] = [ + { tester: rankWith(1, isControl), renderer: TestControl }, + { tester: rankWith(1, isLayout), renderer: TestLayout }, +]; + +const schema = { + type: 'object', + properties: { + first: { type: 'string' }, + second: { type: 'string' }, + }, +}; + +const uischema = { + type: 'VerticalLayout', + elements: [ + { type: 'Control', scope: '#/properties/first' }, + { type: 'Control', scope: '#/properties/second' }, + ], +}; + +describe('stable bindings', () => { + beforeEach(() => { + for (const key of Object.keys(renders)) { + delete renders[key]; + } + changeHandlers = {}; + }); + + it('does not re-render a control whose bindings did not change', async () => { + const wrapper = mount(JsonForms, { + props: { + data: { first: 'a', second: 'b' }, + schema, + uischema, + renderers, + }, + }); + await nextTick(); + expect(renders).toEqual({ first: 1, second: 1 }); + + changeHandlers.first('first', 'changed'); + await nextTick(); + + expect(wrapper.text()).toContain('changed'); + expect(renders.first).toBe(2); + expect(renders.second).toBe(1); + }); + + it('re-renders a control when its bindings change', async () => { + const wrapper = mount(JsonForms, { + props: { + data: { first: 'a', second: 'b' }, + schema, + uischema, + renderers, + }, + }); + await nextTick(); + + changeHandlers.second('second', 'changed'); + await nextTick(); + + expect(wrapper.text()).toContain('changed'); + expect(renders.first).toBe(1); + expect(renders.second).toBe(2); + }); +}); From f5166d9ee627f8a476981df2494609e6ebcd0f76 Mon Sep 17 00:00:00 2001 From: cinereal Date: Wed, 30 Sep 2026 17:08:49 +0200 Subject: [PATCH 2/3] fix(vue): compare only plain objects and arrays structurally `Date`, `RegExp`, `Map` and `Set` keep their contents out of their own keys. The structural compare found two such values equal when their keys were equal, thus a changed date in a UI schema option gave stale bindings. The compare now uses identity for all objects that are not plain objects or arrays. The spec now marks its renderers raw, to prevent Vue warnings about reactive components. Assisted-by: Claude:claude-opus-5-5 Claude-Session: https://claude.ai/code/session_01DngH17MDYXHUMa68WjMzRo --- packages/vue/src/jsonFormsCompositions.ts | 29 +++++-- .../vue/tests/unit/stableBindings.spec.ts | 86 ++++++++++++++----- 2 files changed, 85 insertions(+), 30 deletions(-) diff --git a/packages/vue/src/jsonFormsCompositions.ts b/packages/vue/src/jsonFormsCompositions.ts index 8cf835e8f1..d30e062c81 100644 --- a/packages/vue/src/jsonFormsCompositions.ts +++ b/packages/vue/src/jsonFormsCompositions.ts @@ -149,9 +149,26 @@ export interface ControlProps extends RendererProps { } /** - * Compares two values structurally, to at most `depth` levels of objects and - * arrays. At the depth limit, two different objects are not equal. The result - * is conservative: `true` only when the values are deep-equal. + * Returns `true` for arrays and plain objects. Other objects (for example + * `Date`, `RegExp`, `Map` or `Set`) keep their contents out of their own keys, + * thus a compare of their keys is not sufficient. + */ +const isPlainObjectOrArray = (value: unknown): value is object => { + if (typeof value !== 'object' || value === null) { + return false; + } + if (Array.isArray(value)) { + return true; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Compares two values structurally, to at most `depth` levels of plain objects + * and arrays. Other objects are compared by identity. At the depth limit, two + * different objects are not equal. The result is conservative: `true` only + * when the values are deep-equal. */ const isEqualToDepth = (a: unknown, b: unknown, depth: number): boolean => { if (a === b) { @@ -159,10 +176,8 @@ const isEqualToDepth = (a: unknown, b: unknown, depth: number): boolean => { } if ( depth <= 0 || - a === null || - b === null || - typeof a !== 'object' || - typeof b !== 'object' || + !isPlainObjectOrArray(a) || + !isPlainObjectOrArray(b) || Array.isArray(a) !== Array.isArray(b) ) { return false; diff --git a/packages/vue/tests/unit/stableBindings.spec.ts b/packages/vue/tests/unit/stableBindings.spec.ts index 28133edcb9..369e427b16 100644 --- a/packages/vue/tests/unit/stableBindings.spec.ts +++ b/packages/vue/tests/unit/stableBindings.spec.ts @@ -8,7 +8,8 @@ import { rankWith, } from '@jsonforms/core'; import { mount } from '@vue/test-utils'; -import { defineComponent, h, nextTick } from 'vue'; +import cloneDeep from 'lodash/cloneDeep'; +import { defineComponent, h, markRaw, nextTick } from 'vue'; import { DispatchRenderer, JsonForms, @@ -28,35 +29,47 @@ const TestControl = defineComponent({ return () => { const path = control.value.path; renders[path] = (renders[path] ?? 0) + 1; - return h('span', String(control.value.data)); + const min = control.value.uischema.options?.min; + return h( + 'span', + min instanceof Date ? min.toISOString() : String(control.value.data) + ); }; }, }); -const TestLayout = defineComponent({ - props: rendererProps(), - setup(props) { - const { layout } = useJsonFormsLayout(props); - return () => - h( - 'div', - (layout.value.uischema as Layout).elements.map((element) => - h(DispatchRenderer, { - schema: layout.value.schema, - uischema: element, - path: layout.value.path, - enabled: layout.value.enabled, - }) - ) - ); - }, -}); +// A layout that dispatches its elements. With `clone`, it gives a new clone of +// each element to the dispatch on each render, as wrapper renderers often do. +const createTestLayout = (clone: boolean) => + defineComponent({ + props: rendererProps(), + setup(props) { + const { layout } = useJsonFormsLayout(props); + return () => + h( + 'div', + (layout.value.uischema as Layout).elements.map((element) => + h(DispatchRenderer, { + schema: layout.value.schema, + uischema: clone ? cloneDeep(element) : element, + path: layout.value.path, + enabled: layout.value.enabled, + }) + ) + ); + }, + }); -const renderers: JsonFormsRendererRegistryEntry[] = [ - { tester: rankWith(1, isControl), renderer: TestControl }, - { tester: rankWith(1, isLayout), renderer: TestLayout }, +const createRenderers = (clone: boolean): JsonFormsRendererRegistryEntry[] => [ + { tester: rankWith(1, isControl), renderer: markRaw(TestControl) }, + { + tester: rankWith(1, isLayout), + renderer: markRaw(createTestLayout(clone)), + }, ]; +const renderers = createRenderers(false); + const schema = { type: 'object', properties: { @@ -119,4 +132,31 @@ describe('stable bindings', () => { expect(renders.first).toBe(1); expect(renders.second).toBe(2); }); + + it('re-renders a control when a date in its UI schema changes', async () => { + const uischemaWithDate = (min: Date) => ({ + type: 'VerticalLayout', + elements: [ + { type: 'Control', scope: '#/properties/first', options: { min } }, + { type: 'Control', scope: '#/properties/second' }, + ], + }); + const wrapper = mount(JsonForms, { + props: { + data: { first: 'a', second: 'b' }, + schema, + uischema: uischemaWithDate(new Date(Date.UTC(1970, 0, 1))), + renderers, + }, + }); + await nextTick(); + expect(wrapper.text()).toContain('1970-01-01'); + + await wrapper.setProps({ + uischema: uischemaWithDate(new Date(Date.UTC(2030, 0, 1))), + }); + await nextTick(); + + expect(wrapper.text()).toContain('2030-01-01'); + }); }); From 2ecf3f484a838cb2c843b1caebc3907cbf9a9e9c Mon Sep 17 00:00:00 2001 From: cinereal Date: Wed, 30 Sep 2026 17:08:49 +0200 Subject: [PATCH 3/3] fix(vue): do not re-render the dispatched renderer for forwarded slots `DispatchRenderer` forwards its slots with a `v-for`. Vue marks such slots as dynamic, and renders the child again each time the dispatcher renders, also when the bindings of the child did not change. This occurs when a parent gives a new clone of a UI schema element to the dispatch. The dispatcher now binds no slots when it has none. Assisted-by: Claude:claude-opus-5-5 Claude-Session: https://claude.ai/code/session_01DngH17MDYXHUMa68WjMzRo --- .../vue/src/components/DispatchRenderer.vue | 15 +++++++++++++- .../vue/tests/unit/stableBindings.spec.ts | 20 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/vue/src/components/DispatchRenderer.vue b/packages/vue/src/components/DispatchRenderer.vue index c9ffcc08c4..848df2a5c1 100644 --- a/packages/vue/src/components/DispatchRenderer.vue +++ b/packages/vue/src/components/DispatchRenderer.vue @@ -1,5 +1,12 @@