Skip to content

perf(vue): keep the state of a form shallow - #2623

Open
KiaraGrouwstra wants to merge 3 commits into
eclipsesource:masterfrom
KiaraGrouwstra:vue-raw-schema
Open

KiaraGrouwstra wants to merge 3 commits into
eclipsesource:masterfrom
KiaraGrouwstra:vue-raw-schema

Conversation

@KiaraGrouwstra

@KiaraGrouwstra KiaraGrouwstra commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Problem

JsonForms is an Options API component that returns schemaToUse, uischemaToUse and the jsonforms object from data(). Vue applies reactive() to the object that data() returns, thus the schema, the UI schema, the renderers, the cells and ajv all become deeply reactive. Vue makes a proxy for each nested node at the first read of that node.

Testers, resolveSchema and the scope resolution read the schema many times for each control. A large form thus makes and reads many thousands of proxies.

In a profile of a 160-field form, isEmpty was 45% of the samples and the proxy ownKeys trap was 15%.

Reactive renderer entries additionally give Vue's "received a Component that was made a reactive object" warning, thus every caller has to apply markRaw to its renderer set by hand.

Change

JsonForms keeps its own state shallow.

  • shallowRef holds schemaToUse, dataToUse and uischemaToUse. The reactive get trap unwraps a ref without a conversion and the set trap writes through it, thus a read gives the raw value and an assignment still notifies the dependents.
  • shallowReactive holds the jsonforms object. reactive() returns such an object as it is, thus the object stays shallow inside the state of data(). provide() gives this same object to the descendants.
  • toRaw unwraps the data, the schema, the uischema and ajv, thus the form always works on the plain objects, also when the application keeps them in reactive() state.
  • ajv must be the instance itself. Ajv de-duplicates the entries of its scope by identity and a proxy gives a new wrapper for each read, thus ajv.compile writes a duplicate identifier and throws a SyntaxError.
  • The renderers, the cells and the uischemas go into the state as they are, thus an in-place change of a reactive renderer set still updates the form.
  • The data watcher compares the raw objects. A parent that passes back the current form data can give a proxy of that data, and without this the form generates a new schema for its own data.

A reducer replaces jsonforms.core for each change and never changes it in place, thus shallow tracking is sufficient. useControl and DispatchRenderer read the state through computed props, which the replacement of core notifies.

On a 160-field form the mount time went from 1790 ms to 1000 ms, with an identical DOM.

Behavior

A new schema, UI schema or renderer set still updates the form. The reactivity stays on the property of the state, and not on the object that the property holds.

An in-place change of these values does not update the form. Renderer props such as labels and descriptions read deep into the schema through mapStateToControlProps, thus such a change did refresh a part of the UI before. The validation did not run again for it, thus the form was in an inconsistent state. This is not a documented behavior and gets no migration entry.

The form data is a sibling of the schema on the core object that the reducer replaces, thus the data is no longer a deep proxy as well. A change of the data needs a dispatch of an update action, which useJsonFormsControl and its relatives do through handleChange. A custom renderer that writes into the data object directly, for example with a v-model on a nested field, must use handleChange instead. The migration entry states this.

The state is the state of JSON Forms, thus JSON Forms writes no flag on the object that the application supplies. A deep watcher of the application on its own schema still runs, and the application can put that same object into reactive() elsewhere.

An application that keeps these values out of deep reactive state no longer needs markRaw. AdditionalProperties of @jsonforms/vue-vuetify gave i18n, middleware and ajv to a nested form with it, which wrote such a flag onto objects of the application. It no longer applies markRaw, and the example application and the AdditionalProperties test no longer apply it to their renderer set and to their ajv.

The form does not unwrap the renderers and the cells. An application that keeps a renderer set in reactive() state thus gives a proxy of it to the form, and Vue gives the warning again. Such an application keeps markRaw for it, or holds the value in shallowRef or shallowReactive state. The migration entry states this as well. The test form of @jsonforms/vue-vuetify keeps markRaw for its renderer set for this reason, because @vue/test-utils makes the props of a mount deeply reactive.

findUISchema returns control.options.detail by reference, thus an inline UI schema below a control is raw as well. This is consistent with the rest of the change.

uischemas keeps its current behavior.

Tests

packages/vue/tests/unit/JsonForms.spec.ts gets eleven tests. @vue/test-utils keeps the props of a mount in reactive(), thus the tests mount the form below a parent that holds its state in shallowReactive, as an application does.

Two tests check that the given and the generated schema and UI schema stay out of the reactive graph, and that a renderer gets the original nested nodes. Two check that a new schema or uischema prop reaches the core state and that the validation runs again for it. Three check the renderers, the cells and both the given and the generated ajv. One checks that a proxy of an application reaches the core state as the ajv instance itself. Two check that the core state gets the plain data, schema and UI schema when the application gives proxies of them, at mount and as new props. One checks that the object of the application gets no flag and that a deep watcher of the application still runs.

  • packages/vue: 35 tests pass.
  • packages/vue-vuetify: 111 tests pass and 3 are todo. There is no "made a reactive object" warning.
  • packages/vue-vanilla: the tests pass.

Assisted-by: Claude:claude-fable-5-1
Assisted-by: Claude:claude-opus-5-5

https://claude.ai/code/session_01ABEfryjEao6ftncdw48d6V

@netlify

netlify Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for jsonforms-examples ready!

Name Link
🔨 Latest commit a181488
🔍 Latest deploy log https://app.netlify.com/projects/jsonforms-examples/deploys/6ad49688a39997a176ec6dc1
😎 Deploy Preview https://deploy-preview-2623--jsonforms-examples.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@sdirix

sdirix commented Sep 15, 2026

Copy link
Copy Markdown
Member

@EclipseSourceAI

@coveralls

coveralls commented Sep 15, 2026 •

Copy link
Copy Markdown

Coverage Status

coverage: 84.766%. remained the same — KiaraGrouwstra:vue-raw-schema into eclipsesource:master

@EclipseSourceAI EclipseSourceAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Autonomous AI review.

This review was done by an AI agent and therefore may contain mistakes. Feel free to ignore any comment you disagree with. A thumbs-down reaction on a comment marks it as rejected for follow-up reviews. Noting why in a reply helps, since replies are read too.

Resolving all AI comments does not lead to an automatic approval. A maintainer still needs to review and sign off on the overall architecture and design.

To get an updated review after pushing changes, a maintainer may re-request a review from this account.

Running in Eclipse Enclave, submitted via review-guard-mcp

What this PR does

Adds an asRaw helper in packages/vue/src/components/JsonForms.vue that runs toRaw then markRaw on the schema and UI schema, applied to the initial values in data() and to the new values in the schema / uischema watchers. This keeps the (UI) schema out of Vue's reactive graph so no proxies are created for the thousands of nested schema node reads that testers, resolveSchema and scope resolution perform. Four unit tests are added.

The approach is sound and consistent with how this repo already deals with the problem (markRaw on ajv, i18n, middleware, renderers at various call sites). Marking the root object rather than using shallowRef is the right call, since jsonforms.core.schema and the provide()d state all need the raw object. I checked the Vue packages for in-place schema or uischema mutation and found none, so nothing in this repo regresses.

Critical points for a maintainer

  1. markRaw mutates a prop object the library does not own. The __v_skip flag is permanent and global. An app that hands JSON Forms a plain schema and later puts that same object into reactive()/ref() loses reactivity silently.
  2. The behavior change is broader than the PR text claims. In-place schema mutations do currently refresh renderer-level props (labels, descriptions) because mapStateToControlProps reads deep into the schema inside a computed. This warrants a MIGRATION.md entry for 3.9.
  3. Test gap: every new test passes a plain object, so the toRaw half of asRaw (the documented common case) is never exercised.

Not verified

The sandbox for this review has no network access and no installed dependencies, so neither the unit tests nor the Netlify deploy preview could be exercised. The runtime schema/uischema swap behavior in the vue-vuetify example app (Monaco editor plus apply) has not been checked visually.

*/
function asRaw<T>(schema: T): T {
if (schema !== null && typeof schema === 'object') {
return markRaw(toRaw(schema) as object) as T;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

markRaw writes a non-removable __v_skip flag onto the caller's object, which JSON Forms does not own. An app that passes a plain schema here and later puts that same object into reactive() or ref() (schema editor with live preview) silently gets a non-reactive object back. Every existing markRaw in this repo sits at a call site that owns the object (example), so a maintainer should sign off on mutating a prop value here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally speaking, the AI is right here with that we should never modify a user object. Looking at the code we already do that already in a Vuetify component and should remove it there.

Regarding the PR: I think we should not play around with markRaw/toRaw but instead use shallowRef consistently for all of these large objects. The JSON Forms architecture, especially the @jsonforms/core utils were designed for shallow references as they were originally developed for React which does not support deep references.

This might expose some rerendering issues which are currently masked by the deep reactivity, but all of these can be fixed.

Comment thread packages/vue/src/components/JsonForms.vue Outdated
Comment on lines +42 to +52
/**
* Keeps a schema out of Vue's reactive graph.
*
* JSON Forms only reads the schema and the UI schema, thus Vue does not need
* to make them reactive. Without this, Vue makes a proxy for each nested
* schema node when a renderer reads that node for the first time. Testers and
* scope resolution read the schema many times for each control. Thus these
* proxies make a large form slow to mount.
*
* `toRaw` gets the initial object if the caller keeps the schema in reactive
* state, which is usual. `markRaw` then prevents a new proxy.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

17 lines of JSDoc for a 5 line helper, and most of it is PR description material. Trim it to the rule and its consequence, the profiling rationale belongs in the commit message.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this comment is good. We should just not keep the toRaw/markRaw behavior but the comment itself was fine.

Comment thread packages/vue/src/components/JsonForms.vue
Comment thread packages/vue/tests/unit/JsonForms.spec.ts Outdated
@KiaraGrouwstra KiaraGrouwstra changed the title perf(vue): keep the schema out of the reactive graph perf(vue): keep the schema and the renderers out of the reactive graph Sep 15, 2026
@KiaraGrouwstra KiaraGrouwstra changed the title perf(vue): keep the schema and the renderers out of the reactive graph perf(vue): keep the state of a form shallow Sep 15, 2026
@KiaraGrouwstra
KiaraGrouwstra force-pushed the vue-raw-schema branch 3 times, most recently from 4d4963a to 2c1b22a Compare September 15, 2026 19:07
Comment thread packages/vue-vuetify/tests/unit/util/util.ts Outdated
Comment thread packages/vue/src/components/JsonForms.vue
Comment thread packages/vue/src/components/JsonForms.vue
`JsonForms` returns `schemaToUse`, `uischemaToUse` and the `jsonforms`
object from `data()`, thus Vue makes a deep proxy for the schema, the UI
schema, the renderers, the cells and `ajv`. Testers and the scope
resolution read the schema many times for each control, thus a large form
makes and reads many thousands of proxies. A profile of a 160-field form
gave 45% of the samples to `isEmpty` and 15% to the proxy `ownKeys` trap.
The mount time of that form goes from 1790 ms to 1000 ms.

`shallowRef` and `shallowReactive` keep this state shallow. Vue keeps a
`shallowReactive` object as it is when it makes the object of `data()`
reactive, and the reactive traps read and write through a `shallowRef`,
thus the component keeps its usual behavior. A reducer replaces `core`
for each change, thus shallow tracking is sufficient for the renderers,
which read the state through computed props.

The state holds the value of a prop without `markRaw`, thus JSON Forms
writes no flag on the object of the application, and a deep watcher of
the application still runs. `toRaw` gets the `ajv` instance itself,
because Ajv cannot compile a schema through a proxy of itself. The `data`
watcher compares the raw objects, because a parent that passes back the
current form data can give a proxy of that data. Without this, the form
generates a new schema for its own data.

An application that keeps these values out of deep reactive state no
longer needs `markRaw` for them. `AdditionalProperties` of
`@jsonforms/vue-vuetify`, the example application and the tests no longer
apply it.

`MIGRATION.md` gets an entry. The form data that a renderer gets is no
longer a proxy, thus a change of the data needs `handleChange`.

Assisted-by: Claude:claude-fable-5-1
Claude-Session: https://claude.ai/code/session_016KGK8Duuq2tHJMm2mVcdFu
An application that keeps the `data`, the `schema` or the `uischema` in
`reactive()` state gives a proxy of it to `JsonForms`. The watchers of
these props already get the plain object, because the reactive `set`
trap of the state calls `toRaw`. `data()` did not unwrap the initial
values, thus the form started with proxies and made the deep proxies
again.

`data()` and the watchers now apply `toRaw` to these values, thus the
form always works on the plain objects.

Assisted-by: Claude:claude-opus-5-5
Claude-Session: https://claude.ai/code/session_01ABEfryjEao6ftncdw48d6V
`@vue/test-utils` keeps the props of a mount in `reactive()`, thus the
renderers get to `JsonForms` as a proxy. Vue then gives the "received a
Component that was made a reactive object" warning 120 times in the
tests of `@jsonforms/vue-vuetify`. `mountJsonForms` applies `markRaw` to
the renderers again.

Assisted-by: Claude:claude-opus-5-5
Claude-Session: https://claude.ai/code/session_01ABEfryjEao6ftncdw48d6V

@sdirix sdirix left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank your for the changes!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants