Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion packages/vue/src/components/DispatchRenderer.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
<template>
<component :is="determinedRenderer" v-bind="renderer">
<!-- Without slots, bind no slots: forwarded slots are dynamic, and dynamic
slots make Vue render the child again each time this component renders. -->
<component
:is="determinedRenderer"
v-if="!hasSlots()"
v-bind="renderer"
></component>
<component :is="determinedRenderer" v-else v-bind="renderer">
<!-- Forward all slots dynamically -->
<template v-for="(_, slotName) in $slots" :key="slotName" #[slotName]>
<slot :name="slotName"></slot>
Expand Down Expand Up @@ -44,5 +51,11 @@ export default defineComponent({
}
},
},
methods: {
// A method, not a computed value: `$slots` is not reactive.
hasSlots(): boolean {
return Object.keys(this.$slots).length > 0;
},
},
});
</script>
108 changes: 106 additions & 2 deletions packages/vue/src/jsonFormsCompositions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,110 @@ export interface ControlProps extends RendererProps {
uischema: ControlElement;
}

/**
* 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) {
return true;
}
if (
depth <= 0 ||
!isPlainObjectOrArray(a) ||
!isPlainObjectOrArray(b) ||
Array.isArray(a) !== Array.isArray(b)
) {
Comment thread
KiaraGrouwstra marked this conversation as resolved.
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<string, unknown>)[key],
(b as Record<string, unknown>)[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 = <T extends object>(
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<string, unknown>)[key],
(next as Record<string, unknown>)[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 = <T extends object>(getter: () => T): ComputedRef<T> =>
computed((previous?: T) => reuseIfEqual(previous, getter()));

export type Required<T> = T extends object
? { [P in keyof T]-?: NonNullable<T[P]> }
: T;
Expand Down Expand Up @@ -188,7 +292,7 @@ export function useControl<
const dispatch = useDispatch();

const id = ref<string | undefined>(undefined);
const control = computed(() => ({
const control = stableComputed(() => ({
...props,
...stateMap({ jsonforms }, props),
id: id.value,
Expand Down Expand Up @@ -416,7 +520,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;
});
Expand Down
182 changes: 182 additions & 0 deletions packages/vue/tests/unit/stableBindings.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/* eslint-disable vue/one-component-per-file */
import {
ControlElement,
isControl,
isLayout,
JsonFormsRendererRegistryEntry,
Layout,
rankWith,
} from '@jsonforms/core';
import { mount } from '@vue/test-utils';
import cloneDeep from 'lodash/cloneDeep';
import { defineComponent, h, markRaw, nextTick } from 'vue';
import {
DispatchRenderer,
JsonForms,
rendererProps,
useJsonFormsControl,
useJsonFormsLayout,
} from '../../src';

const renders: Record<string, number> = {};
let changeHandlers: Record<string, (path: string, value: unknown) => void> = {};

const TestControl = defineComponent({
props: rendererProps<ControlElement>(),
setup(props) {
const { control, handleChange } = useJsonFormsControl(props);
changeHandlers[control.value.path] = handleChange;
return () => {
const path = control.value.path;
renders[path] = (renders[path] ?? 0) + 1;
const min = control.value.uischema.options?.min;
return h(
'span',
min instanceof Date ? min.toISOString() : String(control.value.data)
);
};
},
});

// 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<Layout>(),
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 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: {
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);
});

it('does not re-render a control when its layout dispatches a cloned UI schema element', async () => {
const wrapper = mount(JsonForms, {
props: {
data: { first: 'a', second: 'b' },
schema,
uischema,
renderers: createRenderers(true),
},
});
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 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');
});
});
Comment thread
KiaraGrouwstra marked this conversation as resolved.