From fa23832f6a77be85b3bbb8a2856adbc8813f19cc Mon Sep 17 00:00:00 2001 From: Merlijn Vos Date: Mon, 19 Jan 2026 09:59:42 +0100 Subject: [PATCH] @uppy/vue: support kebab-case props in generated components (#6125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 ``` ## 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 Co-authored-by: Prakash --- .changeset/clean-monkeys-smoke.md | 6 ++ packages/@uppy/components/migrate.mjs | 58 +++++++++++++++---- .../vue/src/headless/generated/Dropzone.ts | 15 +++-- .../vue/src/headless/generated/FilesGrid.ts | 15 +++-- .../vue/src/headless/generated/FilesList.ts | 15 +++-- .../src/headless/generated/ProviderIcon.ts | 15 +++-- .../vue/src/headless/generated/Thumbnail.ts | 15 +++-- .../src/headless/generated/UploadButton.ts | 15 +++-- 8 files changed, 96 insertions(+), 58 deletions(-) create mode 100644 .changeset/clean-monkeys-smoke.md diff --git a/.changeset/clean-monkeys-smoke.md b/.changeset/clean-monkeys-smoke.md new file mode 100644 index 000000000..f51d946e5 --- /dev/null +++ b/.changeset/clean-monkeys-smoke.md @@ -0,0 +1,6 @@ +--- +"@uppy/components": patch +"@uppy/vue": patch +--- + +- Fix Vue components to work with kebab-case props (`:edit-file` instead of `:editFile`) diff --git a/packages/@uppy/components/migrate.mjs b/packages/@uppy/components/migrate.mjs index 0b5f443a8..7f79361d4 100644 --- a/packages/@uppy/components/migrate.mjs +++ b/packages/@uppy/components/migrate.mjs @@ -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>({ +export default defineComponent({ name: '%%ComponentName%%', - setup(props, { attrs }) { + props: %%PropsArray%%, + setup(props) { const containerRef = ref(null) const ctx = useUppyContext() @@ -79,7 +110,7 @@ export default defineComponent>({ if (containerRef.value) { preactRender( preactH(%%PreactComponentName%%, { - ...(attrs as %%PropsTypeName%%), + ...props, ctx, } satisfies %%PropsTypeName%%), containerRef.value, @@ -97,11 +128,10 @@ export default defineComponent>({ 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) } diff --git a/packages/@uppy/vue/src/headless/generated/Dropzone.ts b/packages/@uppy/vue/src/headless/generated/Dropzone.ts index 1816accb0..41bc3dfec 100644 --- a/packages/@uppy/vue/src/headless/generated/Dropzone.ts +++ b/packages/@uppy/vue/src/headless/generated/Dropzone.ts @@ -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>({ +export default defineComponent({ name: 'Dropzone', - setup(props, { attrs }) { + props: ['width', 'height', 'note', 'noClick'], + setup(props) { const containerRef = ref(null) const ctx = useUppyContext() @@ -20,7 +20,7 @@ export default defineComponent>({ if (containerRef.value) { preactRender( preactH(PreactDropzone, { - ...(attrs as DropzoneProps), + ...props, ctx, } satisfies DropzoneProps), containerRef.value, @@ -38,11 +38,10 @@ export default defineComponent>({ watch( () => props, - (current, old) => { - if (!shallowEqualObjects(current, old)) { - renderDropzone() - } + () => { + renderDropzone() }, + { deep: true }, ) return () => h('div', { ref: containerRef }) diff --git a/packages/@uppy/vue/src/headless/generated/FilesGrid.ts b/packages/@uppy/vue/src/headless/generated/FilesGrid.ts index 409c7c78c..389ac52a2 100644 --- a/packages/@uppy/vue/src/headless/generated/FilesGrid.ts +++ b/packages/@uppy/vue/src/headless/generated/FilesGrid.ts @@ -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>({ +export default defineComponent({ name: 'FilesGrid', - setup(props, { attrs }) { + props: ['editFile', 'columns', 'imageThumbnail'], + setup(props) { const containerRef = ref(null) const ctx = useUppyContext() @@ -20,7 +20,7 @@ export default defineComponent>({ if (containerRef.value) { preactRender( preactH(PreactFilesGrid, { - ...(attrs as FilesGridProps), + ...props, ctx, } satisfies FilesGridProps), containerRef.value, @@ -38,11 +38,10 @@ export default defineComponent>({ watch( () => props, - (current, old) => { - if (!shallowEqualObjects(current, old)) { - renderFilesGrid() - } + () => { + renderFilesGrid() }, + { deep: true }, ) return () => h('div', { ref: containerRef }) diff --git a/packages/@uppy/vue/src/headless/generated/FilesList.ts b/packages/@uppy/vue/src/headless/generated/FilesList.ts index 19c11af08..c91b24b4c 100644 --- a/packages/@uppy/vue/src/headless/generated/FilesList.ts +++ b/packages/@uppy/vue/src/headless/generated/FilesList.ts @@ -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>({ +export default defineComponent({ name: 'FilesList', - setup(props, { attrs }) { + props: ['editFile', 'imageThumbnail'], + setup(props) { const containerRef = ref(null) const ctx = useUppyContext() @@ -20,7 +20,7 @@ export default defineComponent>({ if (containerRef.value) { preactRender( preactH(PreactFilesList, { - ...(attrs as FilesListProps), + ...props, ctx, } satisfies FilesListProps), containerRef.value, @@ -38,11 +38,10 @@ export default defineComponent>({ watch( () => props, - (current, old) => { - if (!shallowEqualObjects(current, old)) { - renderFilesList() - } + () => { + renderFilesList() }, + { deep: true }, ) return () => h('div', { ref: containerRef }) diff --git a/packages/@uppy/vue/src/headless/generated/ProviderIcon.ts b/packages/@uppy/vue/src/headless/generated/ProviderIcon.ts index 5a9d991a4..b7525d154 100644 --- a/packages/@uppy/vue/src/headless/generated/ProviderIcon.ts +++ b/packages/@uppy/vue/src/headless/generated/ProviderIcon.ts @@ -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>({ +export default defineComponent({ name: 'ProviderIcon', - setup(props, { attrs }) { + props: ['provider', 'fill'], + setup(props) { const containerRef = ref(null) const ctx = useUppyContext() @@ -20,7 +20,7 @@ export default defineComponent>({ if (containerRef.value) { preactRender( preactH(PreactProviderIcon, { - ...(attrs as ProviderIconProps), + ...props, ctx, } satisfies ProviderIconProps), containerRef.value, @@ -38,11 +38,10 @@ export default defineComponent>({ watch( () => props, - (current, old) => { - if (!shallowEqualObjects(current, old)) { - renderProviderIcon() - } + () => { + renderProviderIcon() }, + { deep: true }, ) return () => h('div', { ref: containerRef }) diff --git a/packages/@uppy/vue/src/headless/generated/Thumbnail.ts b/packages/@uppy/vue/src/headless/generated/Thumbnail.ts index ae749a1a7..8421facb7 100644 --- a/packages/@uppy/vue/src/headless/generated/Thumbnail.ts +++ b/packages/@uppy/vue/src/headless/generated/Thumbnail.ts @@ -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>({ +export default defineComponent({ name: 'Thumbnail', - setup(props, { attrs }) { + props: ['file', 'width', 'height', 'images'], + setup(props) { const containerRef = ref(null) const ctx = useUppyContext() @@ -20,7 +20,7 @@ export default defineComponent>({ if (containerRef.value) { preactRender( preactH(PreactThumbnail, { - ...(attrs as ThumbnailProps), + ...props, ctx, } satisfies ThumbnailProps), containerRef.value, @@ -38,11 +38,10 @@ export default defineComponent>({ watch( () => props, - (current, old) => { - if (!shallowEqualObjects(current, old)) { - renderThumbnail() - } + () => { + renderThumbnail() }, + { deep: true }, ) return () => h('div', { ref: containerRef }) diff --git a/packages/@uppy/vue/src/headless/generated/UploadButton.ts b/packages/@uppy/vue/src/headless/generated/UploadButton.ts index ea72316a2..f2ea9e4d4 100644 --- a/packages/@uppy/vue/src/headless/generated/UploadButton.ts +++ b/packages/@uppy/vue/src/headless/generated/UploadButton.ts @@ -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>({ +export default defineComponent({ name: 'UploadButton', - setup(props, { attrs }) { + props: [], + setup(props) { const containerRef = ref(null) const ctx = useUppyContext() @@ -20,7 +20,7 @@ export default defineComponent>({ if (containerRef.value) { preactRender( preactH(PreactUploadButton, { - ...(attrs as UploadButtonProps), + ...props, ctx, } satisfies UploadButtonProps), containerRef.value, @@ -38,11 +38,10 @@ export default defineComponent>({ watch( () => props, - (current, old) => { - if (!shallowEqualObjects(current, old)) { - renderUploadButton() - } + () => { + renderUploadButton() }, + { deep: true }, ) return () => h('div', { ref: containerRef })