Skip to content
Merged
23 changes: 23 additions & 0 deletions packages/common/refresh-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,21 @@ function isLikelyComponentType(type) {
}
}

function isCompoundComponent(type) {
if (!isPlainObject(type)) return false
for (const key in type) {
if (!isLikelyComponentType(type[key])) return false
}
return true
}

function isPlainObject(obj) {
return (
Object.prototype.toString.call(obj) === '[object Object]' &&
(obj.constructor === Object || obj.constructor === undefined)
)
}

/**
* Plugin utils
*/
Expand All @@ -565,6 +580,13 @@ export function registerExportsForReactRefresh(filename, moduleExports) {
// The register function has an identity check to not register twice the same component,
// so this is safe to not used the same key here.
register(exportValue, filename + ' export ' + key)
} else if (isCompoundComponent(exportValue)) {
for (const subKey in exportValue) {
register(
exportValue[subKey],
filename + ' export ' + key + '-' + subKey,
)
}
}
}
}
Expand Down Expand Up @@ -618,6 +640,7 @@ export function validateRefreshBoundaryAndEnqueueUpdate(
(key, value) => {
hasExports = true
if (isLikelyComponentType(value)) return true
if (isCompoundComponent(value)) return true
return prevExports[key] === nextExports[key]
},
)
Expand Down
11 changes: 11 additions & 0 deletions packages/plugin-react-oxc/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## Unreleased

### Add HMR support for compound components ([#518](https://github.com/vitejs/vite-plugin-react/pull/518))

HMR now works for compound components like this:

```tsx
const Root = () => <div>Accordion Root</div>
const Item = () => <div>Accordion Item</div>

export const Accordion = { Root, Item }
```

### Return `Plugin[]` instead of `PluginOption[]`

## 0.2.3 (2025-06-16)
Expand Down
11 changes: 11 additions & 0 deletions packages/plugin-react-swc/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## Unreleased

### Add HMR support for compound components ([#518](https://github.com/vitejs/vite-plugin-react/pull/518))

HMR now works for compound components like this:

```tsx
const Root = () => <div>Accordion Root</div>
const Item = () => <div>Accordion Item</div>

export const Accordion = { Root, Item }
```

### Return `Plugin[]` instead of `PluginOption[]`

## 3.10.2 (2025-06-10)
Expand Down
11 changes: 11 additions & 0 deletions packages/plugin-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## Unreleased

### Add HMR support for compound components ([#518](https://github.com/vitejs/vite-plugin-react/pull/518))

HMR now works for compound components like this:

```tsx
const Root = () => <div>Accordion Root</div>
const Item = () => <div>Accordion Item</div>

export const Accordion = { Root, Item }
```

### Return `Plugin[]` instead of `PluginOption[]`

The return type has changed from `react(): PluginOption[]` to more specialized type `react(): Plugin[]`. This allows for type-safe manipulation of plugins, for example:
Expand Down
5 changes: 5 additions & 0 deletions playground/react/App.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from 'react'
import Button from 'jsx-entry'
import Dummy from './components/Dummy?qs-should-not-break-plugin-react'
import { Accordion } from './components/Accordion'
import Parent from './hmr/parent'
import { JsxImportRuntime } from './hmr/jsx-import-runtime'
import { CountProvider } from './context/CountProvider'
Expand Down Expand Up @@ -38,6 +39,10 @@ function App() {
</header>

<Dummy />
<Accordion.Root>
<Accordion.Item>First Item</Accordion.Item>
<Accordion.Item>Second Item</Accordion.Item>
</Accordion.Root>
<Parent />
<JsxImportRuntime />
<Button>button</Button>
Expand Down
15 changes: 15 additions & 0 deletions playground/react/__tests__/react.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,19 @@ if (!isBuild) {

expect(await page.textContent('#state-button')).toMatch('count is: 1')
})

// #493
test('should hmr compound components', async () => {
await untilBrowserLogAfter(
() =>
editFile('components/Accordion.jsx', (code) =>
code.replace('Accordion Root', 'Accordion Root Updated'),
),
['[vite] hot updated: /components/Accordion.jsx'],
)

await expect
.poll(() => page.textContent('#accordion-root'))
.toMatch('Accordion Root Updated')
})
}
37 changes: 37 additions & 0 deletions playground/react/components/Accordion.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
function Root({ children }) {
return (
<div
style={{
border: '1px solid #ccc',
borderRadius: '4px',
margin: '16px 0',
}}
>
<h3
id="accordion-root"
style={{ padding: '12px', margin: '0', backgroundColor: '#f5f5f5' }}
>
Accordion Root
</h3>
<div style={{ padding: '12px' }}>{children}</div>
</div>
)
}

function Item({ children }) {
return (
<div
style={{
padding: '8px 12px',
border: '1px solid #e0e0e0',
margin: '4px 0',
borderRadius: '2px',
backgroundColor: '#fafafa',
}}
>
{children}
</div>
)
}

export const Accordion = { Root, Item }
Loading