Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
309 changes: 309 additions & 0 deletions active-rfcs/0047-ilc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
- Start Date: 2026-03-06
- Target Major Version: 3.x
- Reference Issues:
- Implementation PR:

# Summary

ILC (Inline Local Components)

Allow defining local components inline within a Single File Component (SFC) using a new top-level `<component>` custom block. This enables authors to co-locate small, single-use helper components alongside the parent component that uses them, without creating separate files.

# Basic example

```html
<!-- Parent.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
<button @click="count++">increment</button>
<CountDisplay :count="count" />
</template>

<component name="CountDisplay">
<script setup lang="ts">
const { count } = defineProps<{ count: number }>()
</script>
<template>
<p>Current count: {{ count }}</p>
</template>
</component>
```

The `<component name="CountDisplay">` block defines a local component that is automatically available in the parent's template. Each inline component is a fully isolated Vue component with its own scope, props, emits, slots, and lifecycle.

<details>
<summary>Compiled Output</summary>

```js
import { ref, defineComponent, h } from 'vue'

const CountDisplay = defineComponent({
props: {
count: { type: Number, required: true }
},
setup(props) {
return () => h('p', null, `Current count: ${props.count}`)
}
})

export default defineComponent({
components: { CountDisplay },
setup() {
const count = ref(0)
return { count }
}
})
```

</details>

# Motivation

## Problem

In real-world Vue applications, it is common to extract small template fragments into separate components for readability and reuse within a single parent. Currently, every component requires its own `.vue` file. This leads to:

1. **File proliferation**: A feature directory may contain many tiny single-use components (e.g., `UserAvatar.vue`, `UserBadge.vue`, `UserStatusIcon.vue`) that are only used by one parent component. This adds cognitive overhead when navigating the codebase.

2. **Context switching cost**: When a helper component is tightly coupled to its parent, having them in separate files forces developers to switch between files to understand the full picture.

3. **Boilerplate friction**: Creating a new `.vue` file for a 5-line template fragment feels disproportionate. Developers often inline complex template logic rather than extract it, harming readability.

## Prior art

- **Svelte** provides `{#snippet}` blocks that allow defining reusable template fragments inline. While snippets in Svelte are not full components (no own state/lifecycle), they demonstrate the value of co-location.
- **React** naturally supports this by defining multiple function components in a single file.
- **Vue 2** had `inline-template`, which was removed in Vue 3 (RFC 0016) due to scoping ambiguity. This proposal differs fundamentally: inline local components are fully isolated, with explicit props and their own scope, avoiding the problems that led to the removal of `inline-template`.

## Expected outcome

Developers can define small helper components in the same file as their parent, reducing file count and improving co-location of tightly coupled components, while maintaining Vue's clear component boundary semantics.

# Detailed design

## Syntax

A new top-level custom block `<component>` is introduced in SFC:

```html
<component name="ComponentName">
<!-- standard SFC content: script, template, style -->
</component>
```

### Rules

1. **`name` attribute is required.** It determines the component name available in the parent template. It must be a valid PascalCase or kebab-case identifier.

2. **Each `<component>` block is a self-contained SFC.** It can contain `<script setup>`, `<script>`, `<template>`, and `<style>` blocks, following the same rules as a standalone `.vue` file.

3. **Multiple `<component>` blocks are allowed** in a single SFC. Each defines a separate local component.

4. **Inline components are automatically registered** as local components of the parent (the host SFC's default export). No manual `import` or `components` option registration is needed.

5. **Inline components are scoped to the parent file.** They are not exported and cannot be imported by other files.

6. **Inline components can reference each other.** An inline component defined in the same file can be used by the parent or by other inline components in the same file.

## Scoping

An inline component is **fully isolated** from the parent component's scope:

- It does **not** have access to the parent's `<script setup>` bindings.
- Data must be passed explicitly via props, just like any other component.
- It has its own reactive scope, lifecycle hooks, and provide/inject context (inheriting from the parent at runtime as usual).

```html
<script setup>
import { ref } from 'vue'
const parentMsg = ref('hello')
</script>

<template>
<!-- parentMsg is accessible here (parent scope) -->
<Child :msg="parentMsg" />
</template>

<component name="Child">
<script setup>
// parentMsg is NOT accessible here
const { msg } = defineProps<{ msg: string }>()
</script>
<template>
<span>{{ msg }}</span>
</template>
</component>
```

## Styles

Each `<component>` block can have its own `<style>` block:

```html
<component name="Badge">
<template>
<span class="badge"><slot /></span>
</template>
<style scoped>
.badge {
background: coral;
padding: 2px 8px;
border-radius: 4px;
}
</style>
</component>
```

- `scoped` styles within an inline component are scoped to that component, not the parent.
- Unscoped styles behave the same as in a standalone SFC (global).

## TypeScript support

All TypeScript features available in `<script setup>` work identically within inline components:

```html
<component name="UserCard">
<script setup lang="ts">
interface User {
id: number
name: string
}
const { user } = defineProps<{ user: User }>()
</script>
<template>
<div>{{ user.name }}</div>
</template>
</component>
```

Type-checking and IDE support (Volar/vue-tsc) should treat each `<component>` block as if it were a separate SFC for type inference purposes.

## Compilation

The SFC compiler (`@vue/compiler-sfc`) processes each `<component>` block as a nested SFC:

1. Parse the host SFC and extract `<component>` blocks.
2. Compile each `<component>` block independently, producing a component definition object.
3. Inject the compiled inline components into the parent's `components` option (or equivalent `<script setup>` bindings).
4. Generate scoped style IDs independently for each inline component.

## Nesting

Inline components **cannot** be nested within other inline components:

```html
<!-- NOT ALLOWED -->
<component name="Outer">
<template>...</template>
<component name="Inner"> <!-- Error -->
<template>...</template>
</component>
</component>
```

If a component needs sub-components, they should be defined as sibling `<component>` blocks at the top level. This keeps the parsing simple and avoids deep nesting.

```html
<!-- Correct: siblings, not nested -->
<component name="Outer">
<template>
<Inner />
</template>
</component>

<component name="Inner">
<template>...</template>
</component>
```

## Edge cases

### Name conflicts

If an inline component has the same name as an imported component, the compiler should emit a warning. The imported component takes precedence (explicit imports win).

### Recursive components

An inline component can reference itself by its own name for recursive rendering, following the same rules as standalone SFCs.

### `defineExpose`

`defineExpose` works as expected within inline components, controlling what is accessible via template refs.

# Drawbacks

- **Increased file size**: Co-locating multiple components in one file may lead to large files. However, this is opt-in and a matter of developer discipline, similar to defining multiple components in a single JS module in React.

- **Tooling complexity**: SFC tooling (Volar, vue-tsc, vue-loader/vite-plugin-vue) must be updated to parse and process nested SFC blocks within `<component>`. This is a non-trivial implementation effort.

- **Teaching cost**: A new concept to learn. However, the mental model is straightforward: "a `<component>` block is a `.vue` file embedded inside another `.vue` file."

- **Potential misuse**: Developers might overuse this feature and create excessively large files. Linting rules (e.g., max inline components per file) can mitigate this.

- **`<component>` tag ambiguity**: Vue already uses `<component :is="...">` as a built-in dynamic component in templates. However, the new `<component name="...">` block is a **top-level SFC block**, not a template element, so there is no syntactic ambiguity in practice. The SFC parser can distinguish them by context (top-level vs. inside `<template>`).

# Alternatives

## 1. `defineComponent` in `<script setup>`

Developers can already define local components inline using `defineComponent` with a render function:

```html
<script setup>
import { defineComponent, h } from 'vue'

const CountDisplay = defineComponent({
props: ['count'],
setup(props) {
return () => h('p', `Count: ${props.count}`)
}
})
</script>
```

This works but sacrifices the template syntax, which is a core strength of Vue SFCs. It also lacks scoped styles and hot-module replacement for the inline component.

## 2. Template-inline snippets (Svelte-style)

An alternative explored in early drafts of this RFC was defining snippets inside `<template>`:

```html
<template>
<MySnippet :count />

<snippet name="MySnippet" :props="{ count: number }">
<p>hey {{ count }}</p>
</snippet>
</template>
```

This approach is more lightweight but introduces ambiguity: is the snippet a real component (with lifecycle, own scope) or just a template macro? The top-level `<component>` block approach is clearer because it explicitly mirrors the full SFC structure.

## 3. Separate file (status quo)

Not implementing this feature means developers continue creating separate `.vue` files for every component. While this is fine for most cases, it imposes unnecessary friction for small, tightly coupled helpers.

# Adoption strategy

- **Non-breaking change**: This is a purely additive feature. Existing SFCs are unaffected.
- **Opt-in**: Developers choose when to use inline components vs. separate files.
- **Incremental adoption**: Existing separate-file components can be moved inline as desired. No migration is required.
- **Tooling**: Volar and vite-plugin-vue must be updated to support the new `<component>` block. The feature should land in tooling before being advertised as stable.
- **Linting guidance**: ESLint plugin for Vue should provide rules to limit the number or size of inline components per file, helping teams enforce conventions.

# Unresolved questions

1. **Naming of the block**: Should it be `<component>`, `<local-component>`, `<snippet>`, or something else? `<component>` is concise and aligns with Vue's existing terminology but may cause initial confusion with the dynamic `<component :is>` element. `<local-component>` is more explicit but verbose.

2. **Should inline components be exportable?** The current proposal scopes them to the parent file. However, there may be cases where exporting a subset of inline components is useful (e.g., for testing). A possible extension: `<component name="Foo" export>`.

3. **Hot Module Replacement (HMR)**: What is the expected HMR behavior when an inline component is edited? Ideally, only the affected inline component re-renders, not the entire parent. This requires investigation in the vite-plugin-vue implementation.

4. **Ordering**: Does the order of `<component>` blocks matter? The proposal assumes all inline components are hoisted and available to each other regardless of definition order, but this should be confirmed.

5. **Maximum nesting depth**: The current proposal disallows nesting. Should this be revisited in the future if use cases emerge?