unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
vuejs/core
mid7/10
refactoringerror-handling

fix(runtime-vapor): normalize class prop passed to components

Project: vuejs/core | Language: TypeScript | Concepts: refactoring, error-handling | Difficulty: mid
Link: https://github.com/vuejs/core/pull/15230

The problem

When a Vue component declares class as a prop (e.g., props: { class: String }), and the parent passes both static and dynamic classes (like class="a" :class="skin"), the value received is an array (e.g., ["a", skin]). This fails the type check for a String prop and can trigger warnings or incorrect behavior. The Virtual DOM (VDOM) already normalizes class props, but the Vapor runtime did not, causing inconsistencies.

Why it's non-trivial

A naive fix might just coerce the prop to a string or ignore the type mismatch, but that would:

  • Break expected type safety for declared props.
  • Fail to match the normalization logic of the VDOM, leading to inconsistent behavior between runtimes.
  • Risk breaking edge cases, such as when the prop is absent, empty, or uses default values.

The approach

1. Add a normalization helper for the class prop:

+  const normalizeRawProp = (key: string, value: unknown) =>
+    key === 'class' && value && !isString(value) ? normalizeClass(value) : value

This function checks if the key is 'class' and the value is not a string. If so, it uses Vue's normalizeClass utility to convert the value to a normalized string. Otherwise, it returns the value as-is.

2. Apply normalization in prop resolution:

Wherever a prop value is resolved, the code now passes it through normalizeRawProp:

-              isDynamic ? source[rawKey] : resolveSource(source[rawKey]),
+              normalizeRawProp(
+                key,
+                isDynamic ? source[rawKey] : resolveSource(source[rawKey]),
+              ),

and

-          resolveSource(rawProps[rawKey]),
+          normalizeRawProp(key, resolveSource(rawProps[rawKey])),

This ensures the normalization happens before type checks or default value logic.

3. Add tests to cover normalization and edge cases:

A new test ensures that a declared class prop receives a normalized string and does not warn:

+  test('declared class prop should be normalized', () => {
+    const data = ref({ skin: { b: true, c: false } })
+    const Child = compile(
+      `<script setup vapor>
+        const props = defineProps({ class: { type: String } })
+      </script>
+      <template><div>{{ props.class }}</div></template>`,
+      data,
+    )
+    const Parent = compile(
+      `<script setup vapor>
+        const data = _data
+        const Child = _components.Child
+      </script>
+      <template><Child class="a" :class="data.skin" /></template>`,
+      data,
+      { Child },
+    )
+
+    const { host } = define(Parent).render()
+    expect(host.innerHTML).toBe('<div>a b</div>')
+    expect('Invalid prop').not.toHaveBeenWarned()
+  })

Other new tests check that the normalization only affects passed values and not defaults, and that Boolean casting still works as expected.

From the review thread

No notable review discussion on this PR.

Takeaways

  • Always normalize input data to match the expected type before performing validation or type checks.
  • Ensure consistency between different runtimes or renderers by matching their normalization and coercion logic.
  • Write tests that cover both the common case and edge cases, such as default values and type coercion, to prevent regressions.