Skip to content

Commit 959f8c5

Browse files
authored
feat: add wrapper option (#492)
Add a `wrapper` and `wrapperProps` option to `render` in both the `svelte` and `svelte-core` packages. See: - #284 (comment) - vitest-community/vitest-browser-svelte#30
1 parent faf9ff9 commit 959f8c5

20 files changed

Lines changed: 478 additions & 48 deletions

examples/readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@
55
- [Snippets](./snippets)
66
- [Contexts](./contexts)
77
- [Binds](./binds)
8+
- [Wrappers](./wrappers)
89
- [Deprecated Svelte 3 and 4 features](./deprecated)

examples/wrappers/child.svelte

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<script>
2+
import { getContext } from 'svelte'
3+
4+
let { label } = $props()
5+
6+
const messages = getContext('messages')
7+
</script>
8+
9+
<div role="status" aria-label={label}>
10+
{#each messages.current as message (message.id)}
11+
<p>{message.text}</p>
12+
<hr />
13+
{/each}
14+
</div>

examples/wrappers/child.test.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { render, screen } from '@testing-library/svelte'
2+
import { expect, test } from 'vitest'
3+
4+
import Subject from './child.svelte'
5+
import Wrapper from './wrapper.svelte'
6+
7+
test('notifications with messages from context', () => {
8+
const messages = [
9+
{ id: 'abc', text: 'hello' },
10+
{ id: 'def', text: 'world' },
11+
]
12+
13+
render(
14+
Subject,
15+
{ label: 'Notifications' },
16+
{ wrapper: Wrapper, wrapperProps: { messages } }
17+
)
18+
19+
const status = screen.getByRole('status', { name: 'Notifications' })
20+
21+
expect(status).toHaveTextContent('hello world')
22+
})

examples/wrappers/readme.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Wrappers
2+
3+
Sometimes, a component cannot properly render or operate unless it's the child
4+
of another component. To wrap your component under test in a parent, use the
5+
`wrapper` and `wrapperProps` options.
6+
7+
> \[!TIP]
8+
>
9+
> If you can't test a component in isolation, this may be a sign that you're
10+
> testing at the wrong level, or that your component structure should be
11+
> rethought. Consider if you can make your components more testable in isolation
12+
> before reaching for this option.
13+
14+
While this example uses context to demonstrate the usage of the `wrapper`
15+
options, if you have access to the context directly, not through a provider
16+
component, then the [`context` option](../contexts) is a better choice than
17+
`wrapper`.
18+
19+
## Table of contents
20+
21+
- [`wrapper.svelte`](#wrappersvelte)
22+
- [`child.svelte`](#childsvelte)
23+
- [`child.test.js`](#childtestjs)
24+
25+
## `wrapper.svelte`
26+
27+
```svelte file=./wrapper.svelte
28+
<script>
29+
import { setContext } from 'svelte'
30+
31+
let { messages, children } = $props()
32+
33+
setContext('messages', {
34+
get current() {
35+
return messages
36+
},
37+
})
38+
</script>
39+
40+
{@render children?.()}
41+
```
42+
43+
## `child.svelte`
44+
45+
```svelte file=./child.svelte
46+
<script>
47+
import { getContext } from 'svelte'
48+
49+
let { label } = $props()
50+
51+
const messages = getContext('messages')
52+
</script>
53+
54+
<div role="status" aria-label={label}>
55+
{#each messages.current as message (message.id)}
56+
<p>{message.text}</p>
57+
<hr />
58+
{/each}
59+
</div>
60+
```
61+
62+
## `child.test.js`
63+
64+
```js file=./child.test.js
65+
import { render, screen } from '@testing-library/svelte'
66+
import { expect, test } from 'vitest'
67+
68+
import Subject from './child.svelte'
69+
import Wrapper from './wrapper.svelte'
70+
71+
test('notifications with messages from context', () => {
72+
const messages = [
73+
{ id: 'abc', text: 'hello' },
74+
{ id: 'def', text: 'world' },
75+
]
76+
77+
render(
78+
Subject,
79+
{ label: 'Notifications' },
80+
{ wrapper: Wrapper, wrapperProps: { messages } }
81+
)
82+
83+
const status = screen.getByRole('status', { name: 'Notifications' })
84+
85+
expect(status).toHaveTextContent('hello world')
86+
})
87+
```

examples/wrappers/wrapper.svelte

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<script>
2+
import { setContext } from 'svelte'
3+
4+
let { messages, children } = $props()
5+
6+
setContext('messages', {
7+
get current() {
8+
return messages
9+
},
10+
})
11+
</script>
12+
13+
{@render children?.()}

packages/svelte-core/README.md

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -68,19 +68,20 @@ const { baseElement, container, component, rerender, unmount } = render(
6868
)
6969
```
7070

71-
| Argument | Type | Description |
72-
| ------------------ | ------------------------------------------------------- | --------------------------------------------- |
73-
| `Component` | [Svelte component][svelte-component-docs] | An imported Svelte component |
74-
| `componentOptions` | `Props` or partial [`mount` options][svelte-mount-docs] | Options for how the component will be mounted |
75-
| `setupOptions` | `{ baseElement?: HTMLElement }` | Optionally override `baseElement` |
76-
77-
| Result | Type | Description | Default |
78-
| ------------- | ------------------------------------------ | ---------------------------------------- | ----------------------------------- |
79-
| `baseElement` | `HTMLElement` | The base element | `document.body` |
80-
| `container` | `HTMLElement` | The component's immediate parent element | `<div>` appended to `document.body` |
81-
| `component` | [component exports][svelte-mount-docs] | The component's exports from `mount` | N/A |
82-
| `rerender` | `(props: Partial<Props>) => Promise<void>` | Update the component's props | N/A |
83-
| `unmount` | `() => void` | Unmount the component from the document | N/A |
71+
| Argument | Type | Description |
72+
| ------------------ | ------------------------------------------------------- | ------------------------------------------------------- |
73+
| `Component` | [Svelte component][svelte-component-docs] | An imported Svelte component |
74+
| `componentOptions` | `Props` or partial [`mount` options][svelte-mount-docs] | Options for how the component will be mounted |
75+
| `setupOptions` | [`SetupOptions`](#setup) | Optionally override `baseElement` or wrap the component |
76+
77+
| Result | Type | Description | Default |
78+
| ------------- | ------------------------------------------ | -------------------------------------------------- | ----------------------------------- |
79+
| `baseElement` | `HTMLElement` | The base element | `document.body` |
80+
| `container` | `HTMLElement` | The component's immediate parent element | `<div>` appended to `document.body` |
81+
| `component` | [component exports][svelte-mount-docs] | The component's exports from `mount` | N/A |
82+
| `wrapper` | [component exports][svelte-mount-docs] | The wrapper's exports, if a `wrapper` was provided | `undefined` |
83+
| `rerender` | `(props: Partial<Props>) => Promise<void>` | Update the component's props | N/A |
84+
| `unmount` | `() => void` | Unmount the component from the document | N/A |
8485

8586
> \[!TIP]
8687
> Calling `render` is equivalent to calling [`setup`](#setup) followed by [`mount`](#mount)
@@ -90,7 +91,11 @@ const { baseElement, container, component, rerender, unmount } = render(
9091
> componentOptions,
9192
> setupOptions
9293
> )
93-
> const { component, rerender, unmount } = mount(Component, mountOptions)
94+
> const { component, rerender, unmount } = mount(
95+
> Component,
96+
> mountOptions,
97+
> setupOptions
98+
> )
9499
> ```
95100
96101
[svelte-component-docs]: https://svelte.dev/docs/svelte-components
@@ -110,7 +115,15 @@ const { baseElement, container, mountOptions } = setup(
110115
| Argument | Type | Description |
111116
| ------------------ | ------------------------------------------------------- | --------------------------------------------- |
112117
| `componentOptions` | `Props` or partial [`mount` options][svelte-mount-docs] | Options for how the component will be mounted |
113-
| `setupOptions` | `{ baseElement?: HTMLElement }` | Optionally override `baseElement` |
118+
| `setupOptions` | `SetupOptions` | Configure the document and wrap the component |
119+
120+
`setupOptions` accepts the following fields, all optional:
121+
122+
| Field | Type | Description | Default |
123+
| -------------- | ----------------------------------------- | --------------------------------------------------------------------- | --------------- |
124+
| `baseElement` | `HTMLElement` | The base element to bind queries to | `document.body` |
125+
| `wrapper` | [Svelte component][svelte-component-docs] | A component to wrap the component under test, e.g. a context provider | `undefined` |
126+
| `wrapperProps` | `Props` | Props to pass to the `wrapper` component | `undefined` |
114127

115128
| Result | Type | Description | Default |
116129
| -------------- | ------------------------------------ | ---------------------------------------- | ----------------------------------- |
@@ -123,19 +136,25 @@ const { baseElement, container, mountOptions } = setup(
123136
Mount a Svelte component into the document.
124137

125138
```ts
126-
const { component, unmount, rerender } = mount(Component, mountOptions)
139+
const { component, wrapper, unmount, rerender } = mount(
140+
Component,
141+
mountOptions,
142+
setupOptions
143+
)
127144
```
128145

129-
| Argument | Type | Description |
130-
| -------------- | ----------------------------------------- | -------------------------------------------- |
131-
| `Component` | [Svelte component][svelte-component-docs] | An imported Svelte component |
132-
| `mountOptions` | [component options][svelte-mount-docs] | Options to pass to Svelte's `mount` function |
133-
134-
| Result | Type | Description |
135-
| ----------- | ------------------------------------------ | --------------------------------------- |
136-
| `component` | [component exports][svelte-mount-docs] | The component's exports from `mount` |
137-
| `unmount` | `() => void` | Unmount the component from the document |
138-
| `rerender` | `(props: Partial<Props>) => Promise<void>` | Update the component's props |
146+
| Argument | Type | Description |
147+
| -------------- | ----------------------------------------- | --------------------------------------------------------- |
148+
| `Component` | [Svelte component][svelte-component-docs] | An imported Svelte component |
149+
| `mountOptions` | [component options][svelte-mount-docs] | Options to pass to Svelte's `mount` function |
150+
| `setupOptions` | [`SetupOptions`](#setup) | Optionally wrap the component (`wrapper`, `wrapperProps`) |
151+
152+
| Result | Type | Description |
153+
| ----------- | ------------------------------------------ | -------------------------------------------------- |
154+
| `component` | [component exports][svelte-mount-docs] | The component's exports from `mount` |
155+
| `wrapper` | [component exports][svelte-mount-docs] | The wrapper's exports, if a `wrapper` was provided |
156+
| `unmount` | `() => void` | Unmount the component from the document |
157+
| `rerender` | `(props: Partial<Props>) => Promise<void>` | Update the component's props |
139158

140159
### `cleanup`
141160

packages/svelte-core/src/mount.js

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as Svelte from 'svelte'
66
import { addCleanupTask, removeCleanupTask } from './cleanup.js'
77
import { createProps } from './props.svelte.js'
88
import { IS_MODERN_SVELTE } from './svelte-version.js'
9+
import WrapperScaffold from './wrapper-scaffold.svelte'
910

1011
/**
1112
* Mount a modern Svelte 5 component into the DOM.
@@ -73,22 +74,82 @@ const mountLegacy = (Component, options) => {
7374
/** The mount method in use. */
7475
const mountComponent = IS_MODERN_SVELTE ? mountModern : mountLegacy
7576

77+
/**
78+
* Extract a component from an import.
79+
*
80+
* Allows a dynamic `import('component.svelte')` to be used
81+
* for component values.
82+
*
83+
* @template {import('../types.js').Component} C
84+
* @param {import('../types.js').ComponentImport<C>} componentImport
85+
* @returns {import('../types.js').ComponentType<C>}
86+
*/
87+
const unwrapComponentImport = (componentImport) => {
88+
return 'default' in componentImport
89+
? componentImport.default
90+
: componentImport
91+
}
92+
7693
/**
7794
* Render a Svelte component into the document.
7895
*
7996
* @template {import('../types.js').Component} C
97+
* @template {import('../types.js').Component} [W=never]
98+
*
99+
* @param {import('../types.js').ComponentImport<C>} Component
100+
* @param {import('../types.js').MountOptions<C>} mountOptions
101+
* @param {import('../types.js').SetupOptions<W>} [setupOptions]
102+
* @returns {{componentToMount: import('../types.js').ComponentType<C | W>, mountOptions: import('../types.js').MountOptions<C | W>, isWrapper: boolean}}
103+
*/
104+
const setupComponent = (Component, mountOptions, setupOptions = {}) => {
105+
const componentToMount = unwrapComponentImport(Component)
106+
const { wrapper, wrapperProps } = setupOptions
107+
108+
if (wrapper) {
109+
return {
110+
isWrapper: true,
111+
componentToMount: WrapperScaffold,
112+
mountOptions: {
113+
...mountOptions,
114+
props: {
115+
wrapper: unwrapComponentImport(wrapper),
116+
wrapperProps,
117+
component: componentToMount,
118+
componentProps: mountOptions.props,
119+
},
120+
},
121+
}
122+
}
123+
124+
return { isWrapper: false, componentToMount, mountOptions }
125+
}
126+
127+
/**
128+
* Render a Svelte component into the document.
129+
*
130+
* @template {import('../types.js').Component} C
131+
* @template {import('../types.js').Component} [W=never]
132+
*
80133
* @param {import('../types.js').ComponentImport<C>} Component
81134
* @param {import('../types.js').MountOptions<C>} options
135+
* @param {import('../types.js').SetupOptions<W>} [setupOptions]
82136
* @returns {import('../types.js').MountResult<C>}
83137
*/
84-
const mount = (Component, options) => {
138+
const mount = (Component, options, setupOptions = {}) => {
139+
const { componentToMount, mountOptions, isWrapper } = setupComponent(
140+
Component,
141+
options,
142+
setupOptions
143+
)
144+
85145
const { component, unmount, rerender } = mountComponent(
86-
'default' in Component ? Component.default : Component,
87-
options
146+
componentToMount,
147+
mountOptions
88148
)
89149

90150
return {
91-
component,
151+
component: isWrapper ? component.getComponent() : component,
152+
wrapper: isWrapper ? component.getWrapper() : undefined,
92153
unmount,
93154
rerender: async (props) => {
94155
if ('props' in props) {
@@ -98,6 +159,12 @@ const mount = (Component, options) => {
98159
props = props.props
99160
}
100161

162+
if (isWrapper) {
163+
props = {
164+
componentProps: { ...component.getComponentProps(), ...props },
165+
}
166+
}
167+
101168
rerender(props)
102169
// Await the next tick for Svelte 3/4, which cannot flush changes synchronously
103170
await Svelte.tick()

packages/svelte-core/src/render.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@ import { setup } from './setup.js'
55
* Render a component into the document.
66
*
77
* @template {import('../types.js').Component} C
8+
* @template {import('../types.js').Component} [W=never]
89
*
910
* @param {import('../types.js').ComponentImport<C>} Component - The component to render.
1011
* @param {import('../types.js').ComponentOptions<C>} componentOptions - Customize how Svelte renders the component.
11-
* @param {import('../types.js').SetupOptions} setupOptions - Customize how the document is set up.
12-
* @returns {import('../types.js').RenderResult<C>} The rendered component.
12+
* @param {import('../types.js').SetupOptions<W>} setupOptions - Customize how the document and component is set up.
13+
* @returns {import('../types.js').RenderResult<C, W>} The rendered component.
1314
*/
1415
const render = (Component, componentOptions, setupOptions = {}) => {
1516
const { mountOptions, ...setupResult } = setup(componentOptions, setupOptions)
16-
const mountResult = mount(Component, mountOptions)
17+
const mountResult = mount(Component, mountOptions, setupOptions)
1718

1819
return { ...setupResult, ...mountResult }
1920
}

packages/svelte-core/src/setup.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,9 @@ const validateOptions = (options) => {
5656
* Set up the document to render a component.
5757
*
5858
* @template {import('../types.js').Component} C
59+
*
5960
* @param {import('../types.js').ComponentOptions<C>} componentOptions - props or mount options
60-
* @param {import('../types.js').SetupOptions} setupOptions - base element of the document to bind any queries
61+
* @param {import('../types.js').SetupOptions<any>} setupOptions - base element of the document to bind any queries
6162
* @returns {import('../types.js').SetupResult<C>}
6263
*/
6364
const setup = (componentOptions, setupOptions = {}) => {

0 commit comments

Comments
 (0)