Skip to content

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

Open
KiaraGrouwstra wants to merge 1 commit into
eclipsesource:masterfrom
KiaraGrouwstra:vue-raw-schema
Open

KiaraGrouwstra wants to merge 1 commit 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.
  • The state takes the value of a prop as it is. There is no toRaw for the schema, the uischema, the renderers and the cells.
  • ajv is the one exception, for which toRaw gets 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.

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. The example application and the tests applied it to their renderer set and to their ajv. There is no markRaw left in the packages.

An application that keeps such a value in reactive() state gives a proxy of the value to the form, because Vue keeps the props of a component shallow. A proxied renderer set gives the Vue warning again, thus such an application keeps markRaw for it, or holds the value in shallowRef or shallowReactive state. The migration entry states this as well.

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 nine 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. One checks that the object of the application gets no flag and that a deep watcher of the application still runs.

  • packages/vue: 12 tests pass.
  • packages/vue-vuetify: 95 tests pass.
  • packages/vue-vanilla fails on dev/components/App.vue, before and after this change alike.

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

@netlify

netlify Bot commented Sep 14, 2026

Copy link
Copy Markdown

Deploy Preview for jsonforms-examples ready!

Name Link
🔨 Latest commit 2c1b22a
🔍 Latest deploy log https://app.netlify.com/projects/jsonforms-examples/deploys/6aa9975b0f3f560008d569b6
😎 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.289%. 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 2 times, most recently from d2163ae to 4d4963a Compare September 15, 2026 13:57
`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 takes the value of a prop as it is. JSON Forms thus writes no
flag on the object of the application, and a deep watcher of the
application still runs. An application that keeps such a value in
`reactive()` state gives a proxy of the value to the form. `ajv` is the
one exception, for which `toRaw` gets the instance itself, because Ajv
cannot compile a schema through a proxy of itself.

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
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.

4 participants