Skip to content

Commit 04b652c

Browse files
authored
Merge pull request #12 from sveltejs/better-composition
Better composition
2 parents 7e46f10 + 3ed1ba8 commit 04b652c

File tree

2 files changed

+374
-0
lines changed

2 files changed

+374
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/scratch

text/0000-better-composition.md

Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
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

Comments
 (0)