Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Attribute Fallthrough + Functional Component Updates #154

Merged
merged 5 commits into from
Apr 16, 2020
Merged
Show file tree
Hide file tree
Changes from 3 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
220 changes: 220 additions & 0 deletions active-rfcs/0000-attr-fallthrough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
- Start Date: 2019-11-05
- Target Major Version: 3.x
- Reference Issues: N/A
- Implementation PR: N/A

# Summary

- `v-on` listeners used on a component will fallthrough and be registered as native listeners on the child component root. `.native` modifier is no longer needed.

- `inheritAttrs: false` now affects `class` and `style`.

- `this.$attrs` now contains everything passed to the component minus those explicitly declared as props, including `class`, `style`, and `v-on` listeners. `this.$listeners` is removed.

- Functional components attribute fallthrough behavior adjusted:
- With explicit `props` declaration: full fallthrough like stateful components.
- With no `props` declaration: only fallthrough for `class`, `style` and `v-on` listeners.

# Motivation

In Vue 2.x, components have an implicit attributes fallthrough behavior. Any attribute passed to a component that is not declared as a prop by the component, is considered an **extraneous attribute**. Fore example:

``` html
<MyComp id="foo"/>
```

If `MyComp` didn't declare a prop named `id`, then the `id` is considered an extraneous attribute and will implicitly be applied to the root node of `MyComp`.

This behavior is very convenient when tweaking layout styling between parent and child (by passing on `class` and `style`), or applying a11y attributes to child components.

This behavior can be disabled with `inheritAttrs: false`, where the user expects to explicitly control where the attributes should be applied. These extraneous attributes are exposed in an instance property: `this.$attrs`.

There are a number of inconsistencies and issues in the 2.x behavior:

- `inheritAttrs: false` does not affect `class` and `style`.

- Implicit fallthrough does not apply for event listeners, leading to the need for `.native` modifier if the user wish to add a native event listener to the child component root.

- `class`, `style` and `v-on` listeners are not included in `$attrs`, making it cumbersome for a higher-order component (HOC) to properly pass everything down to a nested child component.

- Functional components have no implicit attrs fallthrough behavior.

In 3.x, we are also introducing Fragments (multiple root nodes in a component template), which require additional considerations on the behavior.

# Detailed design

## `v-on` Listener Fallthrough

With the following usage:

```html
<MyButton @click="hello" />
```

- In v2, the `@click` will only register a component custom event listener. To attach a native listener to the root of `MyButton`, `@click.native` is needed.

- In v3, the `@click` listener will fallthrough and register a native click listener on the root of `MyButton`. This means component authors no longer need to proxy native events to custom events in order to support `v-on` usage without the `.native` modifier. In fact, the `.native` modifier will be removed altogether.

Note this may result in unnecessary registration of native event listeners when the user is only listening to component custom events, which we discuss below in [Unresolved Questions](#unresolved-questions).
Copy link
Contributor

@CyberAP CyberAP Apr 2, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given MyButton is authored that way would it produce 2 click events with implicit event listeners fallthrough?

<button @click="$emit('click', $event)">Click me</button>

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, the whole point is you don't proxy events anymore.


## Explicitly Controlling the Fallthrough

### `inheritAttrs: false`

With `inheritAttrs: false`, the implicit fallthrough is disabled. The component can either choose to intentionally ignore all extraneous attrs, or explicitly control where the attrs should be applied via `v-bind="$attrs"`:

``` html
<div class="wrapper">
<!-- apply attrs to an inner element instead of root -->
<input v-bind="$attrs">
</div>
```

`this.$attrs` (and `context.attrs` for `setup()` and functional components) now contains all attributes passed to the component (as long as it is not declared as props). This includes `class`, `style`, normal attributes and `v-on` listeners. This is based on the flat props structure proposed in [Render Function API Change](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0008-render-function-api-change.md#flat-vnode-props-format).

`v-on` listeners are included in `$attrs` as `onXXX` props. For example, `@click` will result in an `onClick` prop in `$attrs`. If the user wants to handle attributes and listeners separately, it can be done with simple helper functions that separate props that start with `on` from those that don't.

### Multiple Root / Fragment Components

In Vue 3, components can have multiple root elements (i.e. fragment root). In such cases, an automatic merge cannot be performed. The user will be responsible for spreading the attrs to the desired element:

Copy link

@tylerkrupicka tylerkrupicka Apr 3, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry if this is an obvious question, with this proposal is there a way to manually indicate what native HTML element is represented by the component?

For example, when building a component in React I can specify (in TypeScript) that my component extends JSX.IntrinsicElements<'button'>, which then gives all consumers of my component proper autocomplete for HTML button attributes + my custom props.

I see here that Vue 3 will try to automatically do pass through, but won't if there's a fragmented root. There are also cases where you could have a wrapper div around a native button, but still want to spread and have prop validation for the underlying button.

Copy link
Member Author

@yyx990803 yyx990803 Apr 3, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be two separate questions.

For TSX inference, it is possible but not as straightforward:

const MyComponent = defineComponent({
  props: {
    foo: String
  }
})

const MyComponentWithButtonProps = MyComponent as {
  new(): InstanceType<typeof MyComponent> & { $props: JSX.IntrinsicElements['a'] }
}

// voila
<MyComponentWithButtonProps href="foo" />

A helper type can be used to make this cleaner:

type ExtendProps<Comp extends { new(): any }, elements extends string> = {
  new(): InstanceType<Comp> & { $props: JSX.IntrinsicElements[elements] }
}

const MyCompWithButtonProps = MyComponent as ExtendProps<typeof MyComponent, 'a'> // you can even use a union type to extend multiple elements

For fragment root / wrapper elements, you can always manually control where the $attrs should be applied to. It's discussed right before this section you are commenting on.

Copy link

@tylerkrupicka tylerkrupicka Apr 3, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I got that you could manually place the attrs anywhere, I just wanted to make sure you could indicate in the types which element you are applying them to. Thanks!

``` html
<template>
<span>hello</span>
<div v-bind="$attrs">main element</div>
</template>
```

If `$attrs` is non-empty, and the user did not perform an explicit spread (checked by access to `this.$attrs` during render), a runtime warning will be emitted. The component should either bind `$attrs` to an element, or explicitly suppress the warning with `inheritAttrs: false`.

### In Render Functions

In manual render functions, it may seem convenient to just use a spread:

``` js
export default {
props: { /* ... */ },
inheritAttrs: false,
render() {
return h('div', { class: 'foo', ...this.$attrs })
}
}
```

However, this will cause attrs to overwrite whatever existing props of the same name. For example, there the local `class` may be overwritten when we probably want to merge the classes instead. Vue provides a `mergeProps` helper that handles the merging of `class`, `style` and `onXXX` listeners:

``` js
import { mergeProps } from 'vue'

export default {
props: { /* ... */ },
inheritAttrs: false,
render() {
return h('div', mergeProps({ class: 'foo' }, this.$attrs))
}
}
```

This is also what `v-bind` uses internally.

If returning the render function from `setup`, the attrs object is exposed on the setup context:

``` js
import { mergeProps } from 'vue'

export default {
props: { /* ... */ },
inheritAttrs: false,
setup(props, { attrs }) {
return () => {
return h('div', mergeProps({ class: 'foo' }, attrs))
}
}
}
```

Note the `attrs` object is updated before every render, so it's ok to destructure it.

## Functional Components

In 2.x, functional components do not support automatic attribute fallthrough and require manual props merging.

In v3, functional components use a different syntax: they are now declared as plain functions (as specified in [Render Function API Change](https://github.com/vuejs/rfcs/blob/master/active-rfcs/0008-render-function-api-change.md#functional-component-signature)).

### With Explicit Props Declaration

A functional component with `props` declaration will have the same automatic fallthrough behavior as stateful components. It can also explicitly control the attrs with `inheritAttrs: false`:

``` js
const Func = (props, { attrs }) => {
return h('div', mergeProps({ class: 'foo' }, attrs), 'hello')
}

Func.props = { /*...*/ }
Func.inheritAttrs = false
```

### With Optional Props Declaration

v3 functional components also support [Optional Props Declaration](#TODO). When a functional component has no `props` option defined, it receives all attributes passed by the parent as its `props`:

``` js
const Foo = props => h('div', { class: 'foo' }, props.msg)
```

When a functional component is leveraging optional props declaration, there is only implicit fallthrough for `class`, `style`, and `v-on` listeners.

The reason for `class`, `style` and `v-on` listeners to be whitelisted is because:

- They cover the most common use cases for attribute fallthrough.
- They have close to no risk of clashing with prop names.
- They require special merge logic instead of simple overwrites, so handling them implicitly yields more convenience value.

If a functional component with optional props declaration needs to support full attribute fallthrough, it needs to declare `inheritAttrs: false`, pick the desired attrs from `props`, and merge it to the root element:

``` js
// destructure props, and use rest spread to grab unused ones as attrs.
const Func = ({ msg, ...attrs }) => {
return h('div', mergeProps({ class: 'foo' }, attrs), msg)
}
Func.inheritAttrs = false
```

## API Deprecations

- `.native` modifier for v-on will be removed.
- `this.$listeners` will be removed.

# Adoption strategy

- Deprecations can be supported in the compat build:

- `.native` modifier will be a no-op and emit a warning during template compilation.
- `this.$listeners` can be supported with a runtime warning.

- There could technically be cases where the user relies on the 2.x behavior where `inheritAttrs: false` does not affect `class` and `style`, but it should be very rare. We will have a dedicated item in the migration guide / helper to remind the developer to check for such cases.

- Since functional components uses a new syntax, they will likely require manual upgrades. We should have a dedicated section for functional components in the migration guide.

# Unresolved questions

## Removing Unwanted Listeners

With flat VNode data and the removal of `.native` modifier, all listeners are passed down to the child component as `onXXX` functions:

``` html
<foo @click="foo" @custom="bar" />
```

compiles to:

``` js
h(foo, {
onClick: foo,
onCustom: bar
})
```

When spreading `$attrs` with `v-bind`, all parent listeners are applied to the target element as native DOM listeners. The problem is that these same listeners can also be triggered by custom events - in the above example, both a native click event and a custom one emitted by `this.$emit('click')` in the child will trigger the parent's `foo` handler. This may lead to unwanted behavior.

Props do not suffer from this problem because declared props are removed from `$attrs`. Therefore we should have a similar way to "declare" emitted events from a component. Event listeners for explicitly declared events will be removed from `$attrs` and can only be triggered by custom events emitted by the component via `this.$emit`. There is currently [an open RFC for it](https://github.com/vuejs/rfcs/pull/16) by @niko278. It is complementary to this RFC but does not affect the design of this RFC, so we can leave it for consideration at a later stage, even after Vue 3 release.
Copy link
Contributor

@CyberAP CyberAP Apr 2, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To support just style and class attributes (without event listeners) on the component root it won't be safe to simply reply on implicit fallthrough because:

  1. All components have a fallthrough for style, class and v-on attributes
  2. Given many of the components emit their custom events we'll assign unnecessary event handlers on their roots from implicit fallthrough
  3. To opt-out of this behaviour we declare inheritAttrs: false for each component that has a custom event
  4. Now to support style and class attributes on the root of those components we write v-bind="filteredAttrs" which contains needed attributes only

Compare that with a v2 behaviour where nothing is required to get this behaviour out of the box and for arbitrary attributes only v-bind="$attrs" is required. Also no extra work is required to make custom events work safely (that may have clashing names with native events).

Even if #16 does become implemented there're too many workarounds required for just custom events to be able to safely work.


I can hardly imagine $attrs containing event listeners to be as useful as $attrs in v2.

Consider this example:

<input @input="$emit('input', $event)" v-bind="$attrs">

If you do this in v3 then you should get 2 input events at once. With inheritAttrs: true this should produce 3 events since we also have implicit fallthrough.

In each of those scenarios you should always filter $attrs and I couldn't find a good use-case where you need both attributes and listeners in one place.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly I don't understand your concerns, so I'll just answer with what I think might address it:

  1. In v2 the fallthrough applies to all attributes. It's not just class and style. You never needed v-bind="$attrs" unless you use inheritAttrs: false. However, because v-on does not automatically fallthrough, users have to manually proxy the native events to custom events.

  2. In v3 with the v-on fallthrough you should never need to explicitly proxy events again. So in most cases implicit fallthrough will just work and you don't need to do anything.

  3. The only downside is when users listen for a custom event, a native listener is also added. This is why emits is suggested here: by declaring the custom events the component intends to emit, Vue can avoid binding them as native listeners.

So the only extra thing you need is the emits option, which can serve documentation and runtime validation purpose as well, just like props.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, because v-on does not automatically fallthrough, users have to manually proxy the native events to custom events.

Having to add a .native modifier is way easier than dealing with filtering $attrs when authoring components. Being able to control whether an event listener should be assigned to the root or not on a component client level looks like a more reasonable approach for me, at least it does solve the issue of event listeners implicit fallthrough.

The naming could probably be reconsidered though. I suggest leaving the modifier behaviour but calling it .root rather than .native (that would better communicate that the listener would be assigned to a component's root and a fragment vnode does not fulfill this requirement).


The problem with this approach is that it simplifies things if you need your listeners on the root element and complicates things if you need them somewhere else. It also complicates things if you have root event listeners emitting anything but the event object, in that case you'll have to disable fallthrough behaviour completely and manually filter $attrs object.

So some components will benefit from that and others will require more work than usual. Vue 2 does not have that problem.

Maybe a balance could be achieved here?

Consider a component that has to have implicit attributes fallthrough, but at the same time emit a custom event:

<input @input="$emit('input', $event.target.value)" v-bind="filteredAttrs">

<script>
  export default {
    inheritAttrs: false,
    computed: {
      filteredAttrs() {...}
    }
  }
</script>

The component above should benefit from attributes fallthrough in theory but it has to disable that behaviour in order to have a custom event. filteredAttrs should now filter all attributes except style, class and onInput listener.

Since #16 is not part of this RFC at the current state this is how I imagine it's supposed to work for this case when this RFC is accepted. If the new behaviour requires emits to deal with the issues above I think it should be a part of this RFC.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Getting rid of .native is a very good thing imo

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, the primary concern is for components that emit custom events with the same names as native events? How often does that really happen?

For most components that are wrappers of native equivalents (e.g. <MyButton>, <MyInput>), users would expect native event names to behave like native listeners. And for input components, you really should support v-model as the primary usage anyway, so I believe the cases where you need to emit a custom event named input AND the root element happens to be an <input> element is quite rare.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my codebase this happens a lot. This change would stop me from migrating to v3. Also there is not a single case of .native in my project, but for some reason I'll be forced to go through a migration step in order to remove it.

Copy link
Member Author

@yyx990803 yyx990803 Apr 3, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CyberAP assuming emits option is added, you can explicit control the events that you want to "take over". E.g. with emits: ['input'], @input will no longer fallthrough as a native listener and you can decide what to do with it, with any other event listeners still automatically fallthrough. If you use inheritAttrs: true, listeners listed in emits are also removed from $attrs. Does that resolve your problem?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That does resolve my problem provided that this migration step is not manual.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically a codemod can scan all instances of $emit calls in a component and generate the emits option for you.

74 changes: 44 additions & 30 deletions active-rfcs/0007-functional-async-api-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
- Functional components must be written as plain functions
- `{ functional: true }` option removed
- `<template functional>` no longer supported
- Async component must be created via the `createAsyncComponent` API method
- Async component must now be created via a dedicated API method

# Basic example

Expand All @@ -21,9 +21,9 @@ const FunctionalComp = props => {
```

``` js
import { createAsyncComponent } from 'vue'
import { defineAsyncComponent } from 'vue'

const AsyncComp = createAsyncComponent(() => import('./Foo.vue'))
const AsyncComp = defineAsyncComponent(() => import('./Foo.vue'))
```

# Motivation
Expand Down Expand Up @@ -58,7 +58,7 @@ In 3.x, we intend to support functional components **only** as plain functions:
``` js
import { h } from 'vue'

const FunctionalComp = (props, slots) => {
const FunctionalComp = (props, { slots, attrs, emit }) => {
return h('div', `Hello! ${props.name}`)
}
```
Expand All @@ -67,11 +67,47 @@ const FunctionalComp = (props, slots) => {

- SFCs will no longer support `<template functional>` - if you need anything more than a function, just use a normal component.

- The function signature has also changed - `h` is now imported globally. Instead of a render context, props and slots and other values are passed in. For more details on how the new arguments can replace 2.x functional render context, see the [Render Function API Change RFC](https://github.com/vuejs/rfcs/pull/28).
- The function signature has also changed:
- `h` is now imported globally.
- The function receives two arguments: `props` and a context object that exposes `slots`, `attrs` and `emit`. These are equivalent to their `$`-prefixed equivalents on a stateful component.

## Runtime Props Validation
## Comparison with Old Syntax

Props declaration is now optional (only necessary when runtime validation is needed). To add runtime validation or default values, attach `props` to the function itself:
The new function arguments should provide the ability to fully replace the [2.x functional render context](https://vuejs.org/v2/guide/render-function.html#Functional-Components):

- `props` and `slots` have equivalent values;
- `data` and `children` are no longer necessary (just use `props` and `slots`);
- `listeners` will be included in `attrs`;
- `injections` can be replaced using the new `inject` API (part of [Composition API](https://vue-composition-api-rfc.netlify.com/api.html#provide-inject)):

``` js
import { inject } from 'vue'
import { themeSymbol } from './ThemeProvider'

const FunctionalComp = props => {
const theme = inject(themeSymbol)
return h('div', `Using theme ${theme}`)
}
```

- `parent` access will be removed. This was an escape hatch for some internal use cases - in userland code, props and injections should be preferred.

## Optional Props Declaration

To make it easier to use for simple cases, 3.x functional components do not require `props` to be declared:

```js
const Foo = props => h('div', props.msg)
```
``` html
<Foo msg="hello!" />
```

With no explicit props declaration, the first argument `props` will contain everything passed to the component by the parent.

## Explicit Props Declaration

To add explicit props declarations, attach `props` to the function itself:

``` js
const FunctionalComp = props => {
Expand All @@ -85,27 +121,7 @@ FunctionalComp.props = {

## Async Component Creation

With the functional component change, Vue's runtime won't be able to tell whether a function is being provided as a functional component or an async component factory. So in v3 async components must now be created via a new API method:

``` js
import { createAsyncComponent } from 'vue'

const AsyncComp = createAsyncComponent(() => import('./Foo.vue'))
```

The method also supports advanced options:

``` js
const AsyncComp = createAsyncComponent({
factory: () => import('./Foo.vue'),
delay: 200,
timeout: 3000,
error: ErrorComponent,
loading: LoadingComponent
})
```

This will make async component creation a little more verbose, but async component creation is typically a low-frequency use case, and are often grouped in the same file (the routing configuration).
The new async component API is discussed in [its own dedicated RFC](https://github.com/vuejs/rfcs/pull/148).

# Drawbacks

Expand All @@ -119,6 +135,4 @@ N/A

- For functional components, a compatibility mode can be provided for one-at-a-time migration.

- For async components, the migration is straightforward and we can emit warnings when function components return Promise instead of VNodes.

- SFCs using `<template functional>` should be converted to normal SFCs.
Loading