@uppy/vue: support kebab-case props in generated components (#6125)

## Summary

- Fix Vue components to work with kebab-case props (`:edit-file` instead
of `:editFile`)
- Update `migrate.mjs` to parse prop names from TypeScript source files
and generate explicit `props` arrays

## Problem

The generated Vue components didn't work correctly with Vue's standard
kebab-case prop convention:

```vue
<!-- This didn't work -->
<FilesList :edit-file="handleEdit" />

<!-- Only this worked (non-standard) -->
<FilesList :editFile="handleEdit" />
```

## Root Cause

The original Vue template used `attrs` to pass props to Preact:

```ts
setup(props, { attrs }) {
  preactRender(preactH(PreactComponent, {
    ...(attrs as Props),  // attrs preserves kebab-case!
    ctx,
  }), container)
}
```

When using `:edit-file` in a Vue template, Vue passes
`attrs['edit-file']` (kebab-case preserved), but Preact expects
`editFile` (camelCase).

## Solution

Generate Vue components with explicit `props` declarations:

```ts
defineComponent({
  props: ['editFile', 'columns', 'imageThumbnail'],
  setup(props) {
    preactRender(preactH(PreactComponent, {
      ...props,  // Vue already converted kebab → camelCase
      ctx,
    }), container)
  }
})
```

When Vue components declare their props, Vue automatically converts
kebab-case template usage to camelCase in the `props` object. This is
standard Vue behavior.

## Why Simpler Alternatives Don't Work

### "Just use `props` instead of `attrs`"

Without a `props` declaration, Vue doesn't know which attributes are
props. The `props` object will be empty and everything goes to `attrs`:

```ts
// Without props declaration
defineComponent({
  setup(props, { attrs }) {
    // props = {}  (empty!)
    // attrs = { 'edit-file': fn }  (kebab-case preserved)
  }
})
```

### "Accept all props dynamically"

Vue doesn't have a "accept all props and convert casing" option. You
must explicitly declare which props you expect for Vue to handle the
conversion.

### "Just document to use camelCase"

This works but violates Vue conventions. Every Vue developer expects
`:edit-file` to work.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Prakash <qxprakash@gmail.com>
This commit is contained in:
Merlijn Vos 2026-01-19 09:59:42 +01:00 committed by GitHub
parent 8912bafaf4
commit fa23832f6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 96 additions and 58 deletions

View file

@ -0,0 +1,6 @@
---
"@uppy/components": patch
"@uppy/vue": patch
---
- Fix Vue components to work with kebab-case props (`:edit-file` instead of `:editFile`)

View file

@ -25,6 +25,37 @@ const SVELTE_DIR = path.join(
'packages/@uppy/svelte/src/lib/components/headless/generated',
)
/**
* Parse prop names from a TypeScript component file
* Extracts property names from the Props type definition, excluding 'ctx'
*/
async function parsePropsFromFile(filePath) {
const content = await fs.readFile(filePath, 'utf-8')
// Match the Props type definition: export type ComponentNameProps = { ... }
const propsMatch = content.match(
/export\s+type\s+\w+Props\s*=\s*\{([^}]+)\}/s,
)
if (!propsMatch) {
return []
}
const propsBlock = propsMatch[1]
// Extract property names (handle optional ? and required properties)
const propNames = []
const propRegex = /^\s*(\w+)\??:/gm
for (const match of propsBlock.matchAll(propRegex)) {
const propName = match[1]
// Exclude 'ctx' as it's provided by the context
if (propName !== 'ctx') {
propNames.push(propName)
}
}
return propNames
}
// Templates
const REACT_TEMPLATE = `\
// This file was generated by build-components.mjs
@ -60,18 +91,18 @@ export default function %%ComponentName%%(props: Omit<%%PropsTypeName%%, 'ctx'>)
const VUE_TEMPLATE = `\
// This file was generated by build-components.mjs
// ANY EDITS WILL BE OVERWRITTEN!
import { defineComponent, ref, watch, onMounted, h } from 'vue'
import { defineComponent, h, onMounted, ref, watch } from 'vue'
import {
%%ComponentName%% as %%PreactComponentName%%,
type %%PropsTypeName%%,
} from '@uppy/components'
import { h as preactH, render as preactRender } from 'preact'
import { shallowEqualObjects } from 'shallow-equal'
import { useUppyContext } from '../useUppyContext.js'
export default defineComponent<Omit<%%PropsTypeName%%, 'ctx'>>({
export default defineComponent({
name: '%%ComponentName%%',
setup(props, { attrs }) {
props: %%PropsArray%%,
setup(props) {
const containerRef = ref<HTMLElement | null>(null)
const ctx = useUppyContext()
@ -79,7 +110,7 @@ export default defineComponent<Omit<%%PropsTypeName%%, 'ctx'>>({
if (containerRef.value) {
preactRender(
preactH(%%PreactComponentName%%, {
...(attrs as %%PropsTypeName%%),
...props,
ctx,
} satisfies %%PropsTypeName%%),
containerRef.value,
@ -97,11 +128,10 @@ export default defineComponent<Omit<%%PropsTypeName%%, 'ctx'>>({
watch(
() => props,
(current, old) => {
if (!shallowEqualObjects(current, old)) {
render%%ComponentName%%()
}
() => {
render%%ComponentName%%()
},
{ deep: true },
)
return () => h('div', { ref: containerRef })
@ -174,6 +204,11 @@ try {
const componentName = path.basename(file, '.tsx')
const propsTypeName = `${componentName}Props`
const preactComponentName = `Preact${componentName}`
const filePath = path.join(COMPONENTS_DIR, file)
// Parse props from the source file
const propNames = await parsePropsFromFile(filePath)
const propsArray = JSON.stringify(propNames)
// Generate React wrapper
const reactContent = REACT_TEMPLATE.replace(
@ -190,6 +225,7 @@ try {
)
.replace(/%%PreactComponentName%%/g, preactComponentName)
.replace(/%%PropsTypeName%%/g, propsTypeName)
.replace(/%%PropsArray%%/g, propsArray)
// Generate Svelte wrapper
const svelteContent = SVELTE_TEMPLATE.replace(
@ -213,7 +249,9 @@ try {
vueComponents.push(componentName)
svelteComponents.push(componentName)
console.log(`${componentName}`)
console.log(
`${componentName} (props: ${propNames.join(', ') || 'none'})`,
)
} catch (error) {
console.error(`Error processing component ${file}:`, error)
}

View file

@ -6,13 +6,13 @@ import {
Dropzone as PreactDropzone,
} from '@uppy/components'
import { h as preactH, render as preactRender } from 'preact'
import { shallowEqualObjects } from 'shallow-equal'
import { defineComponent, h, onMounted, ref, watch } from 'vue'
import { useUppyContext } from '../useUppyContext.js'
export default defineComponent<Omit<DropzoneProps, 'ctx'>>({
export default defineComponent({
name: 'Dropzone',
setup(props, { attrs }) {
props: ['width', 'height', 'note', 'noClick'],
setup(props) {
const containerRef = ref<HTMLElement | null>(null)
const ctx = useUppyContext()
@ -20,7 +20,7 @@ export default defineComponent<Omit<DropzoneProps, 'ctx'>>({
if (containerRef.value) {
preactRender(
preactH(PreactDropzone, {
...(attrs as DropzoneProps),
...props,
ctx,
} satisfies DropzoneProps),
containerRef.value,
@ -38,11 +38,10 @@ export default defineComponent<Omit<DropzoneProps, 'ctx'>>({
watch(
() => props,
(current, old) => {
if (!shallowEqualObjects(current, old)) {
renderDropzone()
}
() => {
renderDropzone()
},
{ deep: true },
)
return () => h('div', { ref: containerRef })

View file

@ -6,13 +6,13 @@ import {
FilesGrid as PreactFilesGrid,
} from '@uppy/components'
import { h as preactH, render as preactRender } from 'preact'
import { shallowEqualObjects } from 'shallow-equal'
import { defineComponent, h, onMounted, ref, watch } from 'vue'
import { useUppyContext } from '../useUppyContext.js'
export default defineComponent<Omit<FilesGridProps, 'ctx'>>({
export default defineComponent({
name: 'FilesGrid',
setup(props, { attrs }) {
props: ['editFile', 'columns', 'imageThumbnail'],
setup(props) {
const containerRef = ref<HTMLElement | null>(null)
const ctx = useUppyContext()
@ -20,7 +20,7 @@ export default defineComponent<Omit<FilesGridProps, 'ctx'>>({
if (containerRef.value) {
preactRender(
preactH(PreactFilesGrid, {
...(attrs as FilesGridProps),
...props,
ctx,
} satisfies FilesGridProps),
containerRef.value,
@ -38,11 +38,10 @@ export default defineComponent<Omit<FilesGridProps, 'ctx'>>({
watch(
() => props,
(current, old) => {
if (!shallowEqualObjects(current, old)) {
renderFilesGrid()
}
() => {
renderFilesGrid()
},
{ deep: true },
)
return () => h('div', { ref: containerRef })

View file

@ -6,13 +6,13 @@ import {
FilesList as PreactFilesList,
} from '@uppy/components'
import { h as preactH, render as preactRender } from 'preact'
import { shallowEqualObjects } from 'shallow-equal'
import { defineComponent, h, onMounted, ref, watch } from 'vue'
import { useUppyContext } from '../useUppyContext.js'
export default defineComponent<Omit<FilesListProps, 'ctx'>>({
export default defineComponent({
name: 'FilesList',
setup(props, { attrs }) {
props: ['editFile', 'imageThumbnail'],
setup(props) {
const containerRef = ref<HTMLElement | null>(null)
const ctx = useUppyContext()
@ -20,7 +20,7 @@ export default defineComponent<Omit<FilesListProps, 'ctx'>>({
if (containerRef.value) {
preactRender(
preactH(PreactFilesList, {
...(attrs as FilesListProps),
...props,
ctx,
} satisfies FilesListProps),
containerRef.value,
@ -38,11 +38,10 @@ export default defineComponent<Omit<FilesListProps, 'ctx'>>({
watch(
() => props,
(current, old) => {
if (!shallowEqualObjects(current, old)) {
renderFilesList()
}
() => {
renderFilesList()
},
{ deep: true },
)
return () => h('div', { ref: containerRef })

View file

@ -6,13 +6,13 @@ import {
type ProviderIconProps,
} from '@uppy/components'
import { h as preactH, render as preactRender } from 'preact'
import { shallowEqualObjects } from 'shallow-equal'
import { defineComponent, h, onMounted, ref, watch } from 'vue'
import { useUppyContext } from '../useUppyContext.js'
export default defineComponent<Omit<ProviderIconProps, 'ctx'>>({
export default defineComponent({
name: 'ProviderIcon',
setup(props, { attrs }) {
props: ['provider', 'fill'],
setup(props) {
const containerRef = ref<HTMLElement | null>(null)
const ctx = useUppyContext()
@ -20,7 +20,7 @@ export default defineComponent<Omit<ProviderIconProps, 'ctx'>>({
if (containerRef.value) {
preactRender(
preactH(PreactProviderIcon, {
...(attrs as ProviderIconProps),
...props,
ctx,
} satisfies ProviderIconProps),
containerRef.value,
@ -38,11 +38,10 @@ export default defineComponent<Omit<ProviderIconProps, 'ctx'>>({
watch(
() => props,
(current, old) => {
if (!shallowEqualObjects(current, old)) {
renderProviderIcon()
}
() => {
renderProviderIcon()
},
{ deep: true },
)
return () => h('div', { ref: containerRef })

View file

@ -6,13 +6,13 @@ import {
type ThumbnailProps,
} from '@uppy/components'
import { h as preactH, render as preactRender } from 'preact'
import { shallowEqualObjects } from 'shallow-equal'
import { defineComponent, h, onMounted, ref, watch } from 'vue'
import { useUppyContext } from '../useUppyContext.js'
export default defineComponent<Omit<ThumbnailProps, 'ctx'>>({
export default defineComponent({
name: 'Thumbnail',
setup(props, { attrs }) {
props: ['file', 'width', 'height', 'images'],
setup(props) {
const containerRef = ref<HTMLElement | null>(null)
const ctx = useUppyContext()
@ -20,7 +20,7 @@ export default defineComponent<Omit<ThumbnailProps, 'ctx'>>({
if (containerRef.value) {
preactRender(
preactH(PreactThumbnail, {
...(attrs as ThumbnailProps),
...props,
ctx,
} satisfies ThumbnailProps),
containerRef.value,
@ -38,11 +38,10 @@ export default defineComponent<Omit<ThumbnailProps, 'ctx'>>({
watch(
() => props,
(current, old) => {
if (!shallowEqualObjects(current, old)) {
renderThumbnail()
}
() => {
renderThumbnail()
},
{ deep: true },
)
return () => h('div', { ref: containerRef })

View file

@ -6,13 +6,13 @@ import {
type UploadButtonProps,
} from '@uppy/components'
import { h as preactH, render as preactRender } from 'preact'
import { shallowEqualObjects } from 'shallow-equal'
import { defineComponent, h, onMounted, ref, watch } from 'vue'
import { useUppyContext } from '../useUppyContext.js'
export default defineComponent<Omit<UploadButtonProps, 'ctx'>>({
export default defineComponent({
name: 'UploadButton',
setup(props, { attrs }) {
props: [],
setup(props) {
const containerRef = ref<HTMLElement | null>(null)
const ctx = useUppyContext()
@ -20,7 +20,7 @@ export default defineComponent<Omit<UploadButtonProps, 'ctx'>>({
if (containerRef.value) {
preactRender(
preactH(PreactUploadButton, {
...(attrs as UploadButtonProps),
...props,
ctx,
} satisfies UploadButtonProps),
containerRef.value,
@ -38,11 +38,10 @@ export default defineComponent<Omit<UploadButtonProps, 'ctx'>>({
watch(
() => props,
(current, old) => {
if (!shallowEqualObjects(current, old)) {
renderUploadButton()
}
() => {
renderUploadButton()
},
{ deep: true },
)
return () => h('div', { ref: containerRef })