@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', '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 // Templates
const REACT_TEMPLATE = `\ const REACT_TEMPLATE = `\
// This file was generated by build-components.mjs // This file was generated by build-components.mjs
@ -60,18 +91,18 @@ export default function %%ComponentName%%(props: Omit<%%PropsTypeName%%, 'ctx'>)
const VUE_TEMPLATE = `\ const VUE_TEMPLATE = `\
// This file was generated by build-components.mjs // This file was generated by build-components.mjs
// ANY EDITS WILL BE OVERWRITTEN! // ANY EDITS WILL BE OVERWRITTEN!
import { defineComponent, ref, watch, onMounted, h } from 'vue' import { defineComponent, h, onMounted, ref, watch } from 'vue'
import { import {
%%ComponentName%% as %%PreactComponentName%%, %%ComponentName%% as %%PreactComponentName%%,
type %%PropsTypeName%%, type %%PropsTypeName%%,
} from '@uppy/components' } from '@uppy/components'
import { h as preactH, render as preactRender } from 'preact' import { h as preactH, render as preactRender } from 'preact'
import { shallowEqualObjects } from 'shallow-equal'
import { useUppyContext } from '../useUppyContext.js' import { useUppyContext } from '../useUppyContext.js'
export default defineComponent<Omit<%%PropsTypeName%%, 'ctx'>>({ export default defineComponent({
name: '%%ComponentName%%', name: '%%ComponentName%%',
setup(props, { attrs }) { props: %%PropsArray%%,
setup(props) {
const containerRef = ref<HTMLElement | null>(null) const containerRef = ref<HTMLElement | null>(null)
const ctx = useUppyContext() const ctx = useUppyContext()
@ -79,7 +110,7 @@ export default defineComponent<Omit<%%PropsTypeName%%, 'ctx'>>({
if (containerRef.value) { if (containerRef.value) {
preactRender( preactRender(
preactH(%%PreactComponentName%%, { preactH(%%PreactComponentName%%, {
...(attrs as %%PropsTypeName%%), ...props,
ctx, ctx,
} satisfies %%PropsTypeName%%), } satisfies %%PropsTypeName%%),
containerRef.value, containerRef.value,
@ -97,11 +128,10 @@ export default defineComponent<Omit<%%PropsTypeName%%, 'ctx'>>({
watch( watch(
() => props, () => props,
(current, old) => { () => {
if (!shallowEqualObjects(current, old)) { render%%ComponentName%%()
render%%ComponentName%%()
}
}, },
{ deep: true },
) )
return () => h('div', { ref: containerRef }) return () => h('div', { ref: containerRef })
@ -174,6 +204,11 @@ try {
const componentName = path.basename(file, '.tsx') const componentName = path.basename(file, '.tsx')
const propsTypeName = `${componentName}Props` const propsTypeName = `${componentName}Props`
const preactComponentName = `Preact${componentName}` 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 // Generate React wrapper
const reactContent = REACT_TEMPLATE.replace( const reactContent = REACT_TEMPLATE.replace(
@ -190,6 +225,7 @@ try {
) )
.replace(/%%PreactComponentName%%/g, preactComponentName) .replace(/%%PreactComponentName%%/g, preactComponentName)
.replace(/%%PropsTypeName%%/g, propsTypeName) .replace(/%%PropsTypeName%%/g, propsTypeName)
.replace(/%%PropsArray%%/g, propsArray)
// Generate Svelte wrapper // Generate Svelte wrapper
const svelteContent = SVELTE_TEMPLATE.replace( const svelteContent = SVELTE_TEMPLATE.replace(
@ -213,7 +249,9 @@ try {
vueComponents.push(componentName) vueComponents.push(componentName)
svelteComponents.push(componentName) svelteComponents.push(componentName)
console.log(`${componentName}`) console.log(
`${componentName} (props: ${propNames.join(', ') || 'none'})`,
)
} catch (error) { } catch (error) {
console.error(`Error processing component ${file}:`, error) console.error(`Error processing component ${file}:`, error)
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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