|
| 1 | +- Start Date: 2019-01-19 |
| 2 | +- RFC PR: [#12](https://github.com/sveltejs/rfcs/pull/12) |
| 3 | +- Svelte Issue: (leave this empty) |
| 4 | + |
| 5 | +# Better composition |
| 6 | + |
| 7 | +## Summary |
| 8 | + |
| 9 | +Svelte 3 components can be combined in various ways to do just about anything. But some tasks are unnecessarily difficult or boilerplate-ridden. This RFC proposes a set of changes to the way slotted content behaves to enable more idiomatic and powerful composition patterns. |
| 10 | + |
| 11 | +## Motivation |
| 12 | + |
| 13 | +### Uncontrolled components |
| 14 | + |
| 15 | +We want to be able to do things like this (from https://github.com/reactjs/react-tabs)... |
| 16 | + |
| 17 | +```html |
| 18 | +<Tabs> |
| 19 | + <TabList> |
| 20 | + <Tab>Title 1</Tab> |
| 21 | + <Tab>Title 2</Tab> |
| 22 | + </TabList> |
| 23 | + |
| 24 | + <TabPanel> |
| 25 | + <h2>Any content 1</h2> |
| 26 | + </TabPanel> |
| 27 | + <TabPanel> |
| 28 | + <h2>Any content 2</h2> |
| 29 | + </TabPanel> |
| 30 | +</Tabs> |
| 31 | +``` |
| 32 | + |
| 33 | +...or this... |
| 34 | + |
| 35 | +```html |
| 36 | +<Tabs> |
| 37 | + <Tab title="Title 1"> |
| 38 | + <h2>Any content 1</h2> |
| 39 | + </Tab> |
| 40 | + |
| 41 | + <Tab title="Title 2"> |
| 42 | + <h2>Any content 2</h2> |
| 43 | + </Tab> |
| 44 | +</Tabs> |
| 45 | +``` |
| 46 | + |
| 47 | +...or any other configuration. Clicking on a `<Tab>` component should make the corresponding content visible. Ideally, invisible tabs shouldn't be rendered — in other words, there should only be one `<h2>` element existing at any given moment. Currently, Svelte renders slotted content *eagerly*, similar to light DOM in the web components arena. |
| 48 | + |
| 49 | +Equally, we might want to create compound chart components... |
| 50 | + |
| 51 | +```html |
| 52 | +{#each anscombesQuartet as points} |
| 53 | + <Chart {points}> |
| 54 | + <XAXis ticks labels/> |
| 55 | + <YAXis ticks/> |
| 56 | + <Scatterplot/> |
| 57 | + <VoronoiOverlay on:highlight={highlight}/> |
| 58 | + </Chart> |
| 59 | +{/each} |
| 60 | +``` |
| 61 | + |
| 62 | +...in which the axis and plot components (and any other — e.g. `<Annotation>`) use the scale and data provided by the `<Chart>`. |
| 63 | + |
| 64 | +This all implies inter-component communication that's invisible to the application author — something that's effectively impossible in Svelte currently — and a different approach to passing slotted content to components. |
| 65 | + |
| 66 | + |
| 67 | +### Slot context and iteration |
| 68 | + |
| 69 | +Another problem that comes up from time to time: slots can only be used once. You can't use slotted content to provide the definition for [svelte-virtual-list](https://github.com/sveltejs/svelte-virtual-list), for example, even though that would generally be more convenient than passing in a component as a prop. |
| 70 | + |
| 71 | + |
| 72 | +#### Current approach |
| 73 | + |
| 74 | +```html |
| 75 | +<script> |
| 76 | + import VirtualList from '@sveltejs/svelte-virtual-list'; |
| 77 | + import RowComponent from './RowComponent.html'; |
| 78 | +
|
| 79 | + const things = [ |
| 80 | + { name: 'one', number: 1 }, |
| 81 | + { name: 'two', number: 2 }, |
| 82 | + { name: 'three', number: 3 }, |
| 83 | + // ... |
| 84 | + { name: 'six thousand and ninety-two', number: 6092 } |
| 85 | + ]; |
| 86 | +</script> |
| 87 | + |
| 88 | +<VirtualList items={things} component={RowComponent} /> |
| 89 | +``` |
| 90 | + |
| 91 | +```html |
| 92 | +<!-- RowComponent.html --> |
| 93 | +<div> |
| 94 | + <strong>{number}</strong> |
| 95 | + <span>{name}</span> |
| 96 | +</div> |
| 97 | +``` |
| 98 | + |
| 99 | + |
| 100 | +#### Better approach? |
| 101 | + |
| 102 | +```html |
| 103 | +<script> |
| 104 | + import VirtualList from '@sveltejs/svelte-virtual-list'; |
| 105 | +
|
| 106 | + const things = [ |
| 107 | + // these can be any values you like |
| 108 | + { name: 'one', number: 1 }, |
| 109 | + { name: 'two', number: 2 }, |
| 110 | + { name: 'three', number: 3 }, |
| 111 | + // ... |
| 112 | + { name: 'six thousand and ninety-two', number: 6092 } |
| 113 | + ]; |
| 114 | +</script> |
| 115 | + |
| 116 | +<VirtualList items={things}> |
| 117 | + <div> |
| 118 | + <!-- markup goes here --> |
| 119 | + </div> |
| 120 | +</VirtualList> |
| 121 | +``` |
| 122 | + |
| 123 | + |
| 124 | +## Detailed design |
| 125 | + |
| 126 | +### Lazy rendering |
| 127 | + |
| 128 | +For much of what's outlined above, we need slotted content's lifecycle to be controlled by the child component, rather than the parent. This is at odds with the current approach, in which all slotted content is rendered by the parent and then passed to the child, much like light DOM is passed to a custom element. |
| 129 | + |
| 130 | +I'll update this section with some example code as soon as I can unknot my brain. |
| 131 | + |
| 132 | + |
| 133 | +### Uncontrolled component context |
| 134 | + |
| 135 | +For a pattern like this to work... |
| 136 | + |
| 137 | +```html |
| 138 | +<Tabs> |
| 139 | + <TabList> |
| 140 | + <Tab>Title 1</Tab> |
| 141 | + <Tab>Title 2</Tab> |
| 142 | + </TabList> |
| 143 | + |
| 144 | + <TabPanel> |
| 145 | + <h2>Any content 1</h2> |
| 146 | + </TabPanel> |
| 147 | + <TabPanel> |
| 148 | + <h2>Any content 2</h2> |
| 149 | + </TabPanel> |
| 150 | +</Tabs> |
| 151 | +``` |
| 152 | + |
| 153 | +...each `<Tab>` component needs to be able to tell `<Tabs>` (possibly via `<TabList>`, depending on how the component is authored) that it has been selected. It also needs to know its index relative to its siblings, even if it is created after the initial render, or an earlier tab is removed after the initial render. |
| 154 | + |
| 155 | +Similarly, each `<TabPanel>` needs to know its own index (which is again subject to mutations) and the currently selected index. If a panel *is* selected, it should render its child content, otherwise it should not. |
| 156 | + |
| 157 | +Perhaps it could look like this: |
| 158 | + |
| 159 | +```html |
| 160 | +<!-- Tabs.html --> |
| 161 | +<script context="module"> |
| 162 | + export const TABS = {}; |
| 163 | +</script> |
| 164 | + |
| 165 | +<script> |
| 166 | + import { setContext } from 'svelte'; |
| 167 | + import { writable } from 'svelte/store'; |
| 168 | +
|
| 169 | + const tabs = []; |
| 170 | + const panels = []; |
| 171 | + const selected = writable(null); |
| 172 | +
|
| 173 | + setContext(TABS, { |
| 174 | + registerTab: tab => { |
| 175 | + tabs.push(tab); |
| 176 | + }, |
| 177 | +
|
| 178 | + unregisterTab: tab => { |
| 179 | + const i = tabs.indexOf(tab); |
| 180 | + tabs.splice(i, 1); |
| 181 | + }, |
| 182 | +
|
| 183 | + registerPanel: panel => { |
| 184 | + panels.push(panel); |
| 185 | +
|
| 186 | + // if this is the first panel, select it |
| 187 | + selected.update(current => current || panel); |
| 188 | + }, |
| 189 | +
|
| 190 | + unregisterPanel: panel => { |
| 191 | + const i = panels.indexOf(panel); |
| 192 | + panels.splice(i, 1); |
| 193 | + }, |
| 194 | +
|
| 195 | + selectTab: tab => { |
| 196 | + const i = tabs.indexOf(tab); |
| 197 | + selected.set(panels[i]); |
| 198 | + }, |
| 199 | +
|
| 200 | + selected |
| 201 | + }); |
| 202 | +</script> |
| 203 | + |
| 204 | +<div class="tabs"> |
| 205 | + <slot></slot> |
| 206 | +</div> |
| 207 | +``` |
| 208 | + |
| 209 | +```html |
| 210 | +<!-- Tab.html --> |
| 211 | +<script> |
| 212 | + import { getContext, onDestroy } from 'svelte'; |
| 213 | + import { TABS } from './Tabs.html'; |
| 214 | +
|
| 215 | + const tab = {}; |
| 216 | + const { registerTab, unregisterTab, selectTab } = getContext(TABS); |
| 217 | +
|
| 218 | + registerTab(tab); |
| 219 | +
|
| 220 | + onDestroy(() => { |
| 221 | + unregisterTab(tab); |
| 222 | + }); |
| 223 | +</script> |
| 224 | + |
| 225 | +<button on:click="{() => selectTab(tab)}"> |
| 226 | + <slot></slot> |
| 227 | +</button> |
| 228 | +``` |
| 229 | + |
| 230 | +```html |
| 231 | +<!-- TabPanel.html --> |
| 232 | +<script> |
| 233 | + import { getContext, onDestroy } from 'svelte'; |
| 234 | + import { TABS } from './Tabs.html'; |
| 235 | +
|
| 236 | + const panel = {}; |
| 237 | + const { registerPanel, unregisterPanel, selected } = getContext(TABS); |
| 238 | + registerPanel(panel); |
| 239 | +
|
| 240 | + onDestroy(() => { |
| 241 | + unregisterPanel(panel); |
| 242 | + }); |
| 243 | +</script> |
| 244 | + |
| 245 | +{#if $selected === panel} |
| 246 | + <slot></slot> |
| 247 | +{/if} |
| 248 | +``` |
| 249 | + |
| 250 | +> This is just a first draft — I'm glossing over a few things in this example |
| 251 | +
|
| 252 | +Here, `setContext` and `getContext` are functions that must be called during the component's initialisation, like lifecycle functions. `getContext(arg)` retrieves any context that was set using `arg` (which could be anything including a string like 'tabs', but using `{}` guarantees no collisions) *in a parent component*. This allows any given component to have multiple instances of `<Tabs>`, or even for `<Tabs>` to be nested. |
| 253 | + |
| 254 | +> TODO draw the rest of the owl |
| 255 | +
|
| 256 | + |
| 257 | +### Explicit slot scope |
| 258 | + |
| 259 | +The above example works for *implicit* context, i.e. that shared between components without the app author having to worry about it. Sometimes, we need *explicit* context, or 'scope', such as with the `<VirtualList>` example. |
| 260 | + |
| 261 | +React can achieve this with the render prop pattern: |
| 262 | + |
| 263 | +```html |
| 264 | +<VirtualList items={things}>{({ number, name }) => |
| 265 | + <div> |
| 266 | + <strong>{number}</strong> |
| 267 | + <span>{name}</span> |
| 268 | + </div> |
| 269 | +}</VirtualList> |
| 270 | +``` |
| 271 | + |
| 272 | +That's a little trickier for us. Perhaps we could achieve the same result with a new directive, `let`: |
| 273 | + |
| 274 | +```html |
| 275 | +<VirtualList items={things} let:item> |
| 276 | + <div> |
| 277 | + <strong>{item.number}</strong> |
| 278 | + <span>{item.name}</span> |
| 279 | + </div> |
| 280 | +</VirtualList> |
| 281 | +``` |
| 282 | + |
| 283 | +The `let:item` directive, short for `let:item={item}`, makes `item` available to child content, in much the same way as `{#each items as item}`. The `<VirtualList>` component would make it available like so: |
| 284 | + |
| 285 | +```html |
| 286 | +{#each visible as row (row.index)} |
| 287 | + <div class="row"> |
| 288 | + <slot item={row.item}></slot> |
| 289 | + </div> |
| 290 | +{/each} |
| 291 | +``` |
| 292 | + |
| 293 | +We could potentially allow destructuring as well: |
| 294 | + |
| 295 | +```html |
| 296 | +<VirtualList items={things} let:item="{{ name, number }}"> |
| 297 | + <div> |
| 298 | + <strong>{number}</strong> |
| 299 | + <span>{name}</span> |
| 300 | + </div> |
| 301 | +</VirtualList> |
| 302 | +``` |
| 303 | + |
| 304 | +For non-default slots, the directive would live on the slotted element: |
| 305 | + |
| 306 | +```html |
| 307 | +<div slot="footer" let:year> |
| 308 | + <p>Copyright {year} SvelteJS Inc</p> |
| 309 | +</div> |
| 310 | +``` |
| 311 | + |
| 312 | +In terms of the generated code, the child component would augment its own context with any properties on the slot: |
| 313 | + |
| 314 | +```js |
| 315 | +const slot_scope = Object.assign(component_ctx, { |
| 316 | + item: ctx.item |
| 317 | +}); |
| 318 | +``` |
| 319 | + |
| 320 | +> TODO draw the rest of the owl |
| 321 | +
|
| 322 | + |
| 323 | +## How we teach this |
| 324 | + |
| 325 | +Terminology-wise, 'uncontrolled component' and 'context' are terms in fairly widespread use in the React community, and 'slot scope' is used in Vue-land. It makes sense to use the same language. |
| 326 | + |
| 327 | +For the vast majority of users, these changes are purely additive, requiring no real reorganization of existing documentation. The only change (see 'drawbacks') is that `<slot>` no longer behaves exactly like its native HTML equivalent, since we're now able to inject slot scope. |
| 328 | + |
| 329 | + |
| 330 | +## Drawbacks |
| 331 | + |
| 332 | +It's tricky to implement, and adds complexity. Then again the current slot mechanism is also somewhat tricky. |
| 333 | + |
| 334 | +The current method makes it possible to use the slots API programmatically, by passing a `slot: { default, named }` object at initialisation (where `default` and `named` are HTML elements or document fragments). That would no longer be possible if the lifecycle is controlled by the child rather than the parent. Personally I haven't ever used this API, and I'd be surprised if many people have, but it is a drawback nonetheless. |
| 335 | + |
| 336 | +Finally, this proposal moves us away from alignment with web components. A Svelte component that had `<slot>` inside an each block (such as svelte-virtual-list) couldn't realistically be compiled to a web component. It wouldn't be much use as a web component in its current form though. |
| 337 | + |
| 338 | +## Alternatives |
| 339 | + |
| 340 | +The alternative is to do nothing, and rely on existing methods of composition. As we've seen, there are limits to the current approach. |
| 341 | + |
| 342 | +Context could be designed differently. React does it like this: |
| 343 | + |
| 344 | +```js |
| 345 | +import { createContext, useContext } from 'react'; |
| 346 | + |
| 347 | +const ThemeContext = createContext('light'); |
| 348 | + |
| 349 | +function App() { |
| 350 | + return ( |
| 351 | + <ThemeContext.Provider value="dark"> |
| 352 | + <ChildComponent/> |
| 353 | + </ThemeContext.Provider> |
| 354 | + ); |
| 355 | +} |
| 356 | + |
| 357 | +function ChildComponent() { |
| 358 | + const theme = useContext(ThemeContext); |
| 359 | + |
| 360 | + return ( |
| 361 | + <div className={theme}> |
| 362 | + <p>Current theme is {theme}</p> |
| 363 | + </div> |
| 364 | + ); |
| 365 | +} |
| 366 | +``` |
| 367 | + |
| 368 | +In other words, context can be created anywhere inside the component's markup, rather than just in the `<script>` block upon instantation with `setContext`. This is a theoretical benefit but would become unwieldy when using more complex values (such as the context created by the `<Tabs>` component example). 'Context' in this RFC is more than just a simple value; it's potentially a way for related components to communicate with each other, obviating the need for a component to manipulate its children the way that React uncontrolled components typically do. |
| 369 | + |
| 370 | + |
| 371 | +## Unresolved questions |
| 372 | + |
| 373 | +Have we named everything correctly? |
0 commit comments